mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user