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.
This commit is contained in:
Neil
2026-08-15 18:17:20 -07:00
committed by GitHub
parent 15e1ba3f84
commit 83117f2860
83 changed files with 4051 additions and 3697 deletions
+5 -9
View File
@@ -96,15 +96,11 @@ import {
} from './github-api-repository'
import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key'
export { _resetOwnerRepoCache } from './gh-utils'
export {
getIssue,
listIssues,
createIssue,
updateIssue,
addIssueComment,
listLabels,
listAssignableUsers
} from './issues'
export { getIssue, listIssues } from './issues'
export { createIssue } from './issue-create'
export { updateIssue } from './issue-update'
export { addIssueComment } from './issue-comment'
export { listLabels, listAssignableUsers } from './issue-field-options'
import {
mapCheckRunRESTStatus,
mapCheckRunRESTConclusion,
+77
View File
@@ -0,0 +1,77 @@
import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types'
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
import { getIssueGitHubApiRepository, resolveGitHubRepoExecution } from './github-api-repository'
import { acquire, classifyGhError, ghExecFileAsync, release } from './gh-utils'
/**
* 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<GitHubCommentResult> {
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
node_id?: string | null
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,
reactionSubjectId: data.node_id?.trim() || undefined,
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()
}
}
+133
View File
@@ -0,0 +1,133 @@
import type {
GitHubCreateIssueFields,
GitHubCreateIssueResult
} from '../../shared/issue-mutation-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions } from './gh-utils'
import {
resolveGitHubRepoExecution,
resolveIssueGitHubApiRepositorySource
} from './github-api-repository'
import { acquire, extractExecError, ghExecFileAsync, release } from './gh-utils'
function githubIssueErrorMessage(error: unknown): string {
const { stderr, stdout } = extractExecError(error)
return stderr.trim() || stdout.trim()
}
/**
* 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<GitHubCreateIssueResult> {
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()
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { GitHubAssignableUser } from '../../shared/github/pull-request-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import type { LocalGitExecOptions } from './gh-utils'
import {
resolveGitHubRepoExecution,
resolveIssueGitHubApiRepositorySource
} from './github-api-repository'
import { acquire, ghExecFileAsync, release } from './gh-utils'
export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string[]> {
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<GitHubAssignableUser[]> {
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()
}
}
+130
View File
@@ -0,0 +1,130 @@
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 }
}
+5 -9
View File
@@ -68,15 +68,11 @@ vi.mock('./github-api-repository', async (importOriginal) => {
}
})
import {
addIssueComment,
createIssue,
getIssue,
listAssignableUsers,
listIssues,
listLabels,
updateIssue
} from './issues'
import { getIssue, listIssues } from './issues'
import { createIssue } from './issue-create'
import { updateIssue } from './issue-update'
import { addIssueComment } from './issue-comment'
import { listAssignableUsers, listLabels } from './issue-field-options'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
+3 -448
View File
@@ -1,25 +1,15 @@
/* 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 } from '../../shared/classified-error'
import type { GitHubCommentResult, PRComment } from '../../shared/github/comment-types'
import type { GitHubAssignableUser, IssueInfo } from '../../shared/github/pull-request-types'
import type {
GitHubCreateIssueFields,
GitHubCreateIssueResult,
GitHubIssueUpdate
} from '../../shared/issue-mutation-types'
import type { IssueInfo } from '../../shared/github/pull-request-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import { mapIssueInfo } from './mappers'
import type { LocalGitExecOptions, OwnerRepo } from './gh-utils'
import type { LocalGitExecOptions } from './gh-utils'
import {
getIssueGitHubApiRepository,
resolveGitHubRepoExecution,
resolveIssueGitHubApiRepositorySource
} from './github-api-repository'
// prettier-ignore
import { ghExecFileAsync, acquire, release, classifyGhError, classifyListIssuesError, extractExecError } from './gh-utils'
import { ghExecFileAsync, acquire, release, classifyListIssuesError } 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
@@ -35,11 +25,6 @@ export type IssueListResult = {
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.
@@ -182,433 +167,3 @@ export async function listIssues(
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<GitHubCreateIssueResult> {
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<GitHubCommentResult> {
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
node_id?: string | null
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,
reactionSubjectId: data.node_id?.trim() || undefined,
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<string[]> {
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<GitHubAssignableUser[]> {
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()
}
}
+14 -10
View File
@@ -22,8 +22,6 @@ import {
type GhGraphqlErrorShape
} from './project-view/project-error-classification'
import type {
GetProjectViewTableArgs,
GetProjectViewTableResult,
GitHubProjectField,
GitHubProjectFieldValue,
GitHubProjectIteration,
@@ -37,16 +35,22 @@ import type {
GitHubProjectTable,
GitHubProjectUser,
GitHubProjectView,
GitHubProjectViewError,
GitHubProjectViewLayout,
GitHubProjectViewSummary,
ListAccessibleProjectsArgs,
ListAccessibleProjectsResult,
ListProjectViewsArgs,
ListProjectViewsResult,
ResolveProjectRefArgs,
ResolveProjectRefResult
GitHubProjectViewSummary
} from '../../shared/github/project-types'
import type {
GetProjectViewTableResult,
GitHubProjectViewError,
ListAccessibleProjectsResult,
ListProjectViewsResult,
ResolveProjectRefResult
} from '../../shared/github/project-result-types'
import type {
GetProjectViewTableArgs,
ListAccessibleProjectsArgs,
ListProjectViewsArgs,
ResolveProjectRefArgs
} from '../../shared/github/project-request-types'
import {
GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR,
isGitHubProjectRefInputTooLarge
+1 -1
View File
@@ -9,7 +9,7 @@ import {
noteRepositoryRateLimitSpend,
type RateLimitBucketKind
} from '../rate-limit'
import type { GitHubProjectViewError } from '../../../shared/github/project-types'
import type { GitHubProjectViewError } from '../../../shared/github/project-result-types'
import { githubProjectHost } from '../../../shared/github/project-identity'
import { isDefaultGitHubHost } from '../../../shared/github/repository-identity-key'
import { isGitHubHostAuthenticatedForGlobalCli } from '../github-enterprise-repository'
+10 -8
View File
@@ -21,27 +21,29 @@ import { githubProjectHost } from '../../../shared/github/project-identity'
import type { PRComment } from '../../../shared/github/comment-types'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types'
import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types'
import type {
GitHubProjectCommentMutationResult,
GitHubProjectMutationResult,
ListAssignableUsersBySlugResult,
ListIssueTypesBySlugResult,
ListLabelsBySlugResult,
ProjectWorkItemDetailsBySlugResult
} from '../../../shared/github/project-result-types'
import type {
AddIssueCommentBySlugArgs,
ClearProjectItemFieldArgs,
DeleteIssueCommentBySlugArgs,
GitHubProjectCommentMutationResult,
GitHubProjectFieldMutationValue,
GitHubProjectMutationResult,
ListAssignableUsersBySlugArgs,
ListAssignableUsersBySlugResult,
ListIssueTypesBySlugArgs,
ListIssueTypesBySlugResult,
ListLabelsBySlugArgs,
ListLabelsBySlugResult,
ProjectWorkItemDetailsBySlugArgs,
ProjectWorkItemDetailsBySlugResult,
UpdateIssueBySlugArgs,
UpdateIssueCommentBySlugArgs,
UpdateIssueTypeBySlugArgs,
UpdatePullRequestBySlugArgs,
UpdateProjectItemFieldArgs
} from '../../../shared/github/project-types'
} from '../../../shared/github/project-request-types'
function githubHostExecOptions(args: { host?: string }): { host: string } {
return { host: githubProjectHost(args.host) }
@@ -1,7 +1,7 @@
// Why: turns raw gh stderr/stdout into the typed GitHubProjectViewError the
// renderer can act on (auth vs scope vs rate limit vs drift), shared by the
// project-view read and mutation paths.
import type { GitHubProjectViewError } from '../../../shared/github/project-types'
import type { GitHubProjectViewError } from '../../../shared/github/project-result-types'
import { githubProjectHost } from '../../../shared/github/project-identity'
export type GhGraphqlErrorShape = {