feat(issues): make issues page writable with inline editing (#1017)

This commit is contained in:
Jinwoo Hong
2026-04-24 10:41:21 -07:00
committed by GitHub
parent 1772087db5
commit faed904aae
22 changed files with 2756 additions and 132 deletions
+171 -4
View File
@@ -1,6 +1,6 @@
import type { IssueInfo } from '../../shared/types'
import type { IssueInfo, GitHubIssueUpdate } from '../../shared/types'
import { mapIssueInfo } from './mappers'
import { ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
import { ghExecFileAsync, acquire, release, getOwnerRepo, classifyGhError } from './gh-utils'
/**
* Get a single issue by number.
@@ -98,9 +98,9 @@ export async function createIssue(
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`,
'-f',
'--raw-field',
`title=${trimmedTitle}`,
'-f',
'--raw-field',
`body=${body}`
],
{ cwd: repoPath }
@@ -121,3 +121,170 @@ export async function createIssue(
release()
}
}
/**
* Update an existing GitHub issue. Fans out to separate gh commands for
* state changes vs field edits since `gh issue edit` does not support state.
*/
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitHubIssueUpdate
): Promise<{ ok: true } | { ok: false; error: string }> {
const ownerRepo = await getOwnerRepo(repoPath)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
const repo = `${ownerRepo.owner}/${ownerRepo.repo}`
const errors: string[] = []
// State change requires a separate command
if (updates.state) {
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
await ghExecFileAsync(['issue', cmd, String(issueNumber), '--repo', repo], {
cwd: repoPath
})
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
// Treat "already closed/open" as a no-op
if (!stderr.toLowerCase().includes('already')) {
errors.push(classifyGhError(stderr).message)
}
} finally {
release()
}
}
// Field edits (labels, assignees, title) via gh issue edit
const editArgs: string[] = ['issue', 'edit', String(issueNumber), '--repo', repo]
let hasEditArgs = false
if (updates.title) {
editArgs.push('--title', updates.title)
hasEditArgs = true
}
for (const label of updates.addLabels ?? []) {
editArgs.push('--add-label', label)
hasEditArgs = true
}
for (const label of updates.removeLabels ?? []) {
editArgs.push('--remove-label', label)
hasEditArgs = true
}
for (const assignee of updates.addAssignees ?? []) {
editArgs.push('--add-assignee', assignee)
hasEditArgs = true
}
for (const assignee of updates.removeAssignees ?? []) {
editArgs.push('--remove-assignee', assignee)
hasEditArgs = true
}
if (hasEditArgs) {
await acquire()
try {
await ghExecFileAsync(editArgs, { cwd: repoPath })
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGhError(stderr).message)
} finally {
release()
}
}
if (errors.length > 0) {
return { ok: false, error: errors.join('; ') }
}
return { ok: true }
}
export async function addIssueComment(
repoPath: string,
issueNumber: number,
body: string
): Promise<{ ok: true; id: number } | { ok: false; error: string }> {
const ownerRepo = await getOwnerRepo(repoPath)
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'-X',
'POST',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments`,
'--raw-field',
`body=${body}`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as { id?: number }
return { ok: true, id: data.id ?? 0 }
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return { ok: false, error: classifyGhError(stderr).message }
} finally {
release()
}
}
export async function listLabels(repoPath: string): Promise<string[]> {
const ownerRepo = await getOwnerRepo(repoPath)
if (!ownerRepo) {
return []
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'--paginate',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/labels`,
'--jq',
'.[].name'
],
{ cwd: repoPath }
)
return stdout
.trim()
.split('\n')
.filter((l) => l.length > 0)
} catch {
return []
} finally {
release()
}
}
export async function listAssignableUsers(repoPath: string): Promise<string[]> {
const ownerRepo = await getOwnerRepo(repoPath)
if (!ownerRepo) {
return []
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'--paginate',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/assignees`,
'--jq',
'.[].login'
],
{ cwd: repoPath }
)
return stdout
.trim()
.split('\n')
.filter((l) => l.length > 0)
} catch {
return []
} finally {
release()
}
}