/* 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, GitHubCommentResult, GitHubIssueUpdate, IssueInfo, IssueSourcePreference, PRComment } from '../../shared/types' import { mapIssueInfo } from './mappers' import type { OwnerRepo } from './gh-utils' // prettier-ignore import { ghExecFileAsync, acquire, release, getIssueOwnerRepo, resolveIssueSource, classifyGhError, classifyListIssuesError, ghRepoExecOptions, githubRepoContext } 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 } /** * 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 ): Promise { const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId) 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 ): Promise { const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) const { source: ownerRepo } = await resolveIssueSource(repoPath, preference, connectionId) 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 ): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> { const trimmedTitle = title.trim() if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) const { source: ownerRepo } = await resolveIssueSource(repoPath, preference, connectionId) if (!ownerRepo) { return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } await acquire() try { const args = [ 'api', '-X', 'POST', `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues`, '--raw-field', `title=${trimmedTitle}`, '--raw-field', `body=${body}` ] for (const label of fields?.labels ?? []) { args.push('--raw-field', `labels[]=${label}`) } for (const assignee of fields?.assignees ?? []) { args.push('--raw-field', `assignees[]=${assignee}`) } const { stdout } = await ghExecFileAsync(args, ghOptions) const data = JSON.parse(stdout) as { number?: number; html_url?: string; url?: string } 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) { const message = err instanceof Error ? err.message : String(err) return { ok: false, error: message } } 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 ): Promise<{ ok: true } | { ok: false; error: string }> { const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) const ownerRepo = await getIssueOwnerRepo(repoPath, connectionId) 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], { ...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 ): Promise { const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) const ownerRepo = ownerRepoOverride ?? (await getIssueOwnerRepo(repoPath, connectionId)) 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 ): Promise { const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) const { source: ownerRepo } = await resolveIssueSource(repoPath, preference, connectionId) 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 ): Promise { const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) const { source: ownerRepo } = await resolveIssueSource(repoPath, preference, connectionId) 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() } }