/* eslint-disable max-lines -- Why: co-locating issue list/create/update/ comment operations keeps the shared acquire/release + error-classification pattern obvious. Each function is short; the file is long because the surface is broad. */ import type { ClassifiedError, GitHubAssignableUser, GitHubCreateIssueFields, GitHubCreateIssueResult, GitHubCommentResult, GitHubIssueUpdate, IssueInfo, IssueSourcePreference, PRComment } from '../../shared/types' import { mapIssueInfo } from './mappers' import type { LocalGitExecOptions, OwnerRepo } from './gh-utils' import { getIssueGitHubApiRepository, resolveGitHubRepoExecution, resolveIssueGitHubApiRepositorySource } from './github-api-repository' // prettier-ignore import { ghExecFileAsync, acquire, release, classifyGhError, classifyListIssuesError, extractExecError } from './gh-utils' // Why: distinguishes a successful-empty listing from a failed fetch. The // previous `catch { return [] }` conflated a 403 on a private upstream with an // empty backlog. Callers decide how to surface `error`. // // Why no `fellBack` here: the fell-back signal for the renderer toast rides on // `ListWorkItemsResult.issueSourceFellBack` (the Tasks list's envelope). The // only consumer of `listIssues` — the `gh:listIssues` IPC handler — unwraps // to `.items` and has no UI hook to surface a fallback toast. Adding a dead // `fellBack` field here invited drift between the JSDoc promise and reality. export type IssueListResult = { items: IssueInfo[] error?: ClassifiedError } function githubIssueErrorMessage(error: unknown): string { const { stderr, stdout } = extractExecError(error) return stderr.trim() || stdout.trim() } /** * Get a single issue by number. * Uses gh api --cache so 304 Not Modified responses don't count against the rate limit. * * Why this path doesn't take a preference: linked-issue lookups persist a * number to a worktree at creation time. Routing detail lookups through the * live per-repo preference would silently flip an existing link to a * different repo after the user toggled the selector — the opposite of what * #1186 / the parent design doc guard against. List and create paths honor * preference; number-resolution stays on the heuristic. */ export async function getIssue( repoPath: string, issueNumber: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, () => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), connectionId, localGitOptions ) // Why: a connection-backed request has no local cwd, so the non-GitHub // fallback below would let gh target its default repository. Refuse instead. if (connectionId && !ownerRepo) { return null } await acquire() try { if (ownerRepo) { const { stdout } = await ghExecFileAsync( [ 'api', '--cache', '300s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}` ], ghOptions ) const data = JSON.parse(stdout) return mapIssueInfo(data) } // Fallback for non-GitHub remotes const { stdout } = await ghExecFileAsync( ['issue', 'view', String(issueNumber), '--json', 'number,title,state,url,labels'], ghOptions ) const data = JSON.parse(stdout) return mapIssueInfo(data) } catch { return null } finally { release() } } /** * List issues for a repo. * Uses gh api --cache so 304 Not Modified responses don't count against the rate limit. * * Why: returns a structured result so a 403 (e.g. fork contributor without * read access to a private upstream) surfaces as an error the UI can render * instead of collapsing to "No issues". The empty-list-on-error behavior this * replaces was explicitly flagged as a merge-blocker in the parent design doc * (§3) — silently hiding failures re-creates the same silent-source-switch * class of wrongness #1186 warned against, one level deeper. */ export async function listIssues( repoPath: string, limit = 20, preference?: IssueSourcePreference, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, async () => ( await resolveIssueGitHubApiRepositorySource( repoPath, preference, connectionId, localGitOptions ) ).source, connectionId, localGitOptions ) // Why: a connection-backed request has no local cwd, so the non-GitHub // fallback below would let gh list its default repository. Refuse instead. if (connectionId && !ownerRepo) { return { items: [], error: { type: 'not_found', message: 'Could not resolve GitHub owner/repo for this repository' } } } await acquire() try { if (ownerRepo) { const { stdout } = await ghExecFileAsync( [ 'api', '--cache', '120s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc` ], ghOptions ) const data = JSON.parse(stdout) as Record[] // Why: the GitHub REST `/repos/{owner}/{repo}/issues` endpoint returns // pull requests alongside issues (PRs carry a `pull_request` key). // Strip them here so `listIssues` only returns true issues, matching the // filter applied in `listRecentWorkItems` (src/main/github/client.ts). return { items: data .filter((d) => !('pull_request' in d)) .map((d) => mapIssueInfo(d as Parameters[0])) } } // Fallback for non-GitHub remotes const { stdout } = await ghExecFileAsync( ['issue', 'list', '--json', 'number,title,state,url,labels', '--limit', String(limit)], ghOptions ) const data = JSON.parse(stdout) as unknown[] return { items: data.map((d) => mapIssueInfo(d as Parameters[0])) } } catch (err) { const stderr = err instanceof Error ? err.message : String(err) return { items: [], error: classifyListIssuesError(stderr) } } finally { release() } } /** * Create a new GitHub issue. Uses `gh api` with explicit owner/repo so the * call does not depend on the current working directory having a remote that * matches the repo the user picked in the tasks page. */ export async function createIssue( repoPath: string, title: string, body: string, preference?: IssueSourcePreference, connectionId?: string | null, fields?: GitHubCreateIssueFields, localGitOptions: LocalGitExecOptions = {} ): Promise { const trimmedTitle = title.trim() if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, async () => ( await resolveIssueGitHubApiRepositorySource( repoPath, preference, connectionId, localGitOptions ) ).source, connectionId, localGitOptions ) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } await acquire() try { const createArgs = (issueBody: string) => { const args = [ 'api', '-X', 'POST', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, '--raw-field', `title=${trimmedTitle}`, '--raw-field', `body=${issueBody}` ] for (const label of fields?.labels ?? []) { args.push('--raw-field', `labels[]=${label}`) } for (const assignee of fields?.assignees ?? []) { args.push('--raw-field', `assignees[]=${assignee}`) } return args } const parseIssue = (stdout: string) => JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } let data: { number?: number; html_url?: string; url?: string } try { const { stdout } = await ghExecFileAsync(createArgs(body), ghOptions) data = parseIssue(stdout) } catch (err) { const message = githubIssueErrorMessage(err) if (!/body is too long \(maximum is \d+ characters\)/i.test(message)) { return { ok: false, error: message } } // Why: GitHub rejects oversized bodies on create but accepts the same body // on update, so establish the issue before attaching its body. const { stdout } = await ghExecFileAsync(createArgs(''), ghOptions) data = parseIssue(stdout) if (typeof data.number !== 'number') { return { ok: false, error: 'Unexpected response from GitHub' } } try { await ghExecFileAsync( [ 'api', '-X', 'PATCH', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${data.number}`, '--raw-field', `body=${body}` ], ghOptions ) } catch (patchErr) { const patchMessage = githubIssueErrorMessage(patchErr) const identity = data.html_url ?? data.url ?? `#${data.number}` return { ok: true, number: data.number, url: String(data.html_url ?? data.url ?? ''), bodySaveWarning: `Issue ${identity} was created, but saving its body failed: ${patchMessage}` } } } if (typeof data.number !== 'number') { return { ok: false, error: 'Unexpected response from GitHub' } } return { ok: true, number: data.number, url: String(data.html_url ?? data.url ?? '') } } catch (err) { return { ok: false, error: githubIssueErrorMessage(err) } } finally { 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. * * 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 } } /** * Add a comment to an existing GitHub issue. * * Why this path doesn't take a preference (mirrors `getIssue` / `updateIssue`): * a comment is posted against an issue number already bound to a worktree or * surfaced from a prior read. Routing through the live per-repo preference * would let a user read upstream#N, toggle the selector to origin, and have * their reply silently post on origin#N — a different issue entirely. That * is the same 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 addIssueComment( repoPath: string, issueNumber: number, body: string, connectionId?: string | null, ownerRepoOverride?: OwnerRepo | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, ownerRepoOverride ?? (() => getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions)), connectionId, localGitOptions ) 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}` ], ghOptions ) const data = JSON.parse(stdout) as { id?: number user: { login: string; avatar_url: string; type?: string } | null body?: string created_at?: string html_url?: string } if (typeof data.id !== 'number' || !Number.isSafeInteger(data.id) || data.id < 1) { return { ok: false, error: 'Unexpected response from GitHub' } } const comment: PRComment = { id: data.id, author: data.user?.login ?? 'You', authorAvatarUrl: data.user?.avatar_url ?? '', body: data.body ?? body, createdAt: data.created_at ?? new Date().toISOString(), url: data.html_url ?? '', isBot: data.user?.type === 'Bot' } return { ok: true, comment } } 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, preference?: IssueSourcePreference, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, async () => ( await resolveIssueGitHubApiRepositorySource( repoPath, preference, connectionId, localGitOptions ) ).source, connectionId, localGitOptions ) if (!ownerRepo) { return [] } await acquire() try { const { stdout } = await ghExecFileAsync( [ 'api', '--paginate', `repos/${ownerRepo.owner}/${ownerRepo.repo}/labels`, '--jq', '.[].name' ], ghOptions ) return stdout .trim() .split('\n') .filter((l) => l.length > 0) } catch { return [] } finally { release() } } export async function listAssignableUsers( repoPath: string, preference?: IssueSourcePreference, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( repoPath, async () => ( await resolveIssueGitHubApiRepositorySource( repoPath, preference, connectionId, localGitOptions ) ).source, connectionId, localGitOptions ) if (!ownerRepo) { return [] } await acquire() try { // Why: paginate through all assignable users — GraphQL's assignableUsers // maxes out at 100 per page and large orgs/repos silently lose assignees // beyond the first page. REST /assignees with --paginate walks every page; // --jq collapses per-page arrays into NDJSON so we don't have to stitch // JSON arrays that gh concatenates back-to-back. const { stdout } = await ghExecFileAsync( [ 'api', '--paginate', `repos/${ownerRepo.owner}/${ownerRepo.repo}/assignees?per_page=100`, '--jq', '.[] | {login, avatar_url}' ], ghOptions ) type RESTAssignee = { login?: string; avatar_url?: string | null } const users: GitHubAssignableUser[] = [] for (const line of stdout.split('\n')) { const trimmed = line.trim() if (!trimmed) { continue } try { const user = JSON.parse(trimmed) as RESTAssignee if (user.login) { users.push({ login: user.login, name: null, avatarUrl: user.avatar_url ?? '' }) } } catch { // Skip malformed NDJSON lines defensively. } } return users } catch { return [] } finally { release() } }