mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 08:02:33 +00:00
feat(issues): make issues page writable with inline editing (#1017)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user