Files
orca/src/main/github/issue-update.ts
T
Neil 83117f2860 refactor(integrations): split issue-tracker clients under the max-lines budget (#14704)
The GitLab, GitHub, Jira and Linear integration modules, their two IPC
registrars, and the shared GitHub project types each carried a file-level
`eslint-disable max-lines` and ran 351-614 counted lines against a 300-line
budget. AGENTS.md calls for splitting rather than suppressing, and
config/max-lines-baseline.txt is a shrink-only ratchet, so this removes all
eight suppressions and prunes their entries (341 -> 333).

Pure move, no behavior change. Each client is cut along the seam it already
had: per-operation modules for the issue APIs (create / update / comment /
field options), and for Jira the request queue, site credential store,
authenticated request, and site identity. The two IPC registrars keep their own
handlers and delegate the rest to per-domain sub-registrars, so they remain
real entry points rather than re-export shims.

The IPC surface is proved intact rather than assumed: comparing (method,
channel) multisets between HEAD and the split gives 52 registrations across 52
distinct channels on both sides.

Provider-neutrality is preserved -- GitLab and GitHub keep separate, parallel
module layouts rather than being merged behind a shared abstraction.

Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green
(the one remaining failure is a pre-existing load flake in an untouched file,
green when re-run serially), no new runtime import cycles among 744 modules,
and no lint suppression added anywhere.
2026-08-15 18:17:20 -07:00

131 lines
4.3 KiB
TypeScript

import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types'
import type { LocalGitExecOptions } from './gh-utils'
import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
/**
* 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.
*
* Why this path doesn't take a preference (mirrors `getIssue`): mutations
* target an issue number already bound to a worktree / linked elsewhere in
* the UI. Routing an update through the live per-repo preference would let
* a user open upstream#N, toggle the selector to origin, save, and silently
* write to origin#N — a different issue (or 404). That is the exact
* silent-source-switch class of wrongness #1186 / the parent design doc
* guard against. List and create paths honor preference; mutations stay on
* the heuristic `getIssueOwnerRepo`.
*/
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitHubIssueUpdate,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
connectionId,
localGitOptions
)
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 {
if (updates.state === 'closed') {
const closeArgs = ['issue', 'close', String(issueNumber), '--repo', repo]
if (updates.stateReason === 'completed') {
closeArgs.push('--reason', 'completed')
} else if (updates.stateReason === 'not_planned') {
closeArgs.push('--reason', 'not planned')
} else if (updates.stateReason === 'duplicate' && updates.duplicateOf) {
closeArgs.push('--duplicate-of', String(updates.duplicateOf))
}
await ghExecFileAsync(closeArgs, ghOptions)
} else {
await ghExecFileAsync(['issue', 'reopen', String(issueNumber), '--repo', repo], ghOptions)
}
} 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()
}
}
if (updates.body !== undefined) {
await acquire()
try {
await ghExecFileAsync(
[
'api',
'-X',
'PATCH',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}`,
'--raw-field',
`body=${updates.body}`
],
ghOptions
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
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, ghOptions)
} 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 }
}