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
+9 -1
View File
@@ -13,7 +13,15 @@ import { sortWorkItemsByUpdatedAt } from '../../shared/work-items'
import { getPRConflictSummary } from './conflict-summary'
import { execFileAsync, ghExecFileAsync, acquire, release, getOwnerRepo } from './gh-utils'
export { _resetOwnerRepoCache } from './gh-utils'
export { getIssue, listIssues, createIssue } from './issues'
export {
getIssue,
listIssues,
createIssue,
updateIssue,
addIssueComment,
listLabels,
listAssignableUsers
} from './issues'
import {
mapCheckRunRESTStatus,
mapCheckRunRESTConclusion,
+30
View File
@@ -1,6 +1,7 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { gitExecFileAsync, ghExecFileAsync } from '../git/runner'
import type { ClassifiedError } from '../../shared/types'
// Why: legacy generic execFile wrapper — only used by callers that don't need
// WSL-aware routing (e.g. non-repo-scoped gh commands). Repo-scoped callers
@@ -34,6 +35,35 @@ export function release(): void {
}
}
// ── Error classification ─────────────────────────────────────────────
// Why: gh CLI surfaces API errors as unstructured stderr. This helper maps
// known patterns to typed errors so callers can show user-friendly messages.
export function classifyGhError(stderr: string): ClassifiedError {
const s = stderr.toLowerCase()
if (s.includes('http 403') || s.includes('resource not accessible')) {
return {
type: 'permission_denied',
message: "You don't have permission to edit this issue. Check your GitHub token scopes."
}
}
if (s.includes('http 404') || s.includes('could not resolve')) {
return { type: 'not_found', message: 'Issue not found — it may have been deleted.' }
}
if (s.includes('http 422') || s.includes('validation failed')) {
return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` }
}
if (s.includes('rate limit')) {
return {
type: 'rate_limited',
message: 'GitHub rate limit hit. Try again in a few minutes.'
}
}
if (s.includes('timeout') || s.includes('no such host') || s.includes('network')) {
return { type: 'network_error', message: 'Network error — check your connection.' }
}
return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` }
}
// ── Owner/repo resolution for gh api --cache ──────────────────────────
const ownerRepoCache = new Map<string, { owner: string; repo: string } | null>()
+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()
}
}
+14 -8
View File
@@ -128,7 +128,7 @@ async function getPRFiles(repoPath: string, prNumber: number): Promise<GitHubPRF
async function getIssueBodyAndComments(
repoPath: string,
issueNumber: number
): Promise<{ body: string; comments: PRComment[] }> {
): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> {
const ownerRepo = await getOwnerRepo(repoPath)
try {
if (ownerRepo) {
@@ -152,7 +152,10 @@ async function getIssueBodyAndComments(
{ cwd: repoPath }
)
])
const issue = JSON.parse(issueResult.stdout) as { body?: string | null }
const issue = JSON.parse(issueResult.stdout) as {
body?: string | null
assignees?: { login: string }[]
}
type RESTComment = {
id: number
user: { login: string; avatar_url: string } | null
@@ -170,11 +173,12 @@ async function getIssueBodyAndComments(
url: c.html_url
})
)
return { body: issue.body ?? '', comments }
const assignees = (issue.assignees ?? []).map((a) => a.login)
return { body: issue.body ?? '', comments, assignees }
}
// Fallback: non-GitHub remote
const { stdout } = await ghExecFileAsync(
['issue', 'view', String(issueNumber), '--json', 'body,comments'],
['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as {
@@ -185,6 +189,7 @@ async function getIssueBodyAndComments(
createdAt: string
url: string
}[]
assignees?: { login: string }[]
}
const comments = (data.comments ?? []).map(
(c, i): PRComment => ({
@@ -196,9 +201,10 @@ async function getIssueBodyAndComments(
url: c.url ?? ''
})
)
return { body: data.body ?? '', comments }
const fallbackAssignees = (data.assignees ?? []).map((a) => a.login)
return { body: data.body ?? '', comments, assignees: fallbackAssignees }
} catch {
return { body: '', comments: [] }
return { body: '', comments: [], assignees: [] }
}
}
@@ -238,8 +244,8 @@ export async function getWorkItemDetails(
await acquire()
try {
if (item.type === 'issue') {
const { body, comments } = await getIssueBodyAndComments(repoPath, item.number)
return { item, body, comments }
const { body, comments, assignees } = await getIssueBodyAndComments(repoPath, item.number)
return { item, body, comments, assignees }
}
// PR: fetch body + comments + checks + files + head/base SHAs in parallel.