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
-8
View File
@@ -41,31 +41,24 @@ inline src/main/git/runner.ts
inline src/main/git/status.ts
inline src/main/git/worktree.ts
inline src/main/github/client.ts
inline src/main/github/issues.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
inline src/main/github/work-item-details.ts
inline src/main/gitlab/client.ts
inline src/main/gitlab/issues.ts
inline src/main/gitlab/work-item-details.ts
inline src/main/hermes/hook-service.ts
inline src/main/hooks.ts
inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/github.ts
inline src/main/ipc/gitlab.ts
inline src/main/ipc/linear.ts
inline src/main/ipc/pty.ts
inline src/main/ipc/repos.ts
inline src/main/ipc/ssh.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/ipc/worktrees.ts
inline src/main/jira/client.ts
inline src/main/jira/issues.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/client.ts
inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
@@ -218,7 +211,6 @@ inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/github/project-types.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
inline src/shared/remote-runtime-client.ts
+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 = {
+3 -9
View File
@@ -1370,15 +1370,9 @@ export async function updateMR(
/** Re-export so callers don't need to know the gl-utils module split. */
export { _resetProjectRefCache } from './gl-utils'
export {
addIssueComment,
createIssue,
getIssue,
listAssignableUsers,
listIssues,
listLabels,
updateIssue
} from './issues'
export { addIssueComment, createIssue, getIssue, listIssues } from './issues'
export { updateIssue } from './issue-update'
export { listAssignableUsers, listLabels } from './project-label-and-member-lookup'
// Re-exported so paste-URL call sites don't import getProjectRefForRemote from gl-utils directly.
export { getProjectRefForRemote }
@@ -0,0 +1,22 @@
import type { GitLabAssignableUser } from '../../shared/gitlab-types'
export type GitLabRawUser = {
id?: number
username?: string | null
name?: string | null
avatar_url?: string | null
state?: string | null
}
export function mapGitLabUser(raw: GitLabRawUser | null | undefined): GitLabAssignableUser | null {
if (!raw?.username) {
return null
}
return {
...(typeof raw.id === 'number' ? { id: raw.id } : {}),
username: raw.username,
name: raw.name ?? null,
avatarUrl: raw.avatar_url ?? '',
...(raw.state !== undefined ? { state: raw.state } : {})
}
}
+155
View File
@@ -0,0 +1,155 @@
import type { GitLabIssueUpdate } from '../../shared/gitlab-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import {
acquire,
classifyGlabError,
getGlabKnownHosts,
glabExecFileAsync,
glabHostnameArgs,
glabRepoExecOptions,
release,
resolveIssueSource,
type LocalGitExecOptions,
type ProjectRef
} from './gl-utils'
import { encodedProject } from './project-path-encoding'
/**
* Update an existing GitLab issue.
*
* Why: callers that list through a per-repo issue source preference must
* mutate the same GitLab project, or identical IIDs on origin/upstream can
* silently edit the wrong issue.
*/
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitLabIssueUpdate,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRefOverride?: ProjectRef | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const projectRef =
projectRefOverride ??
(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(connectionId, localGitOptions),
connectionId,
localGitOptions
)
).source
if (!projectRef) {
return {
ok: false,
error: 'Could not resolve GitLab project for this repository'
}
}
const repoFlag = projectRef.path
const errors: string[] = []
// State change requires a separate command (parallel to github's split).
if (updates.state) {
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
await glabExecFileAsync(
[
'issue',
cmd,
String(issueNumber),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
// Treat "already closed/reopened" as a no-op (matches gh path).
if (!stderr.toLowerCase().includes('already')) {
errors.push(classifyGlabError(stderr).message)
}
} finally {
release()
}
}
if (updates.body !== undefined) {
await acquire()
try {
await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'PUT',
`projects/${encodedProject(repoFlag)}/issues/${issueNumber}`,
'-f',
`description=${updates.body}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGlabError(stderr).message)
} finally {
release()
}
}
// Field edits via `glab issue update`.
const editArgs: string[] = [
'issue',
'update',
String(issueNumber),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
]
let hasEditArgs = false
if (updates.title) {
editArgs.push('--title', updates.title)
hasEditArgs = true
}
for (const label of updates.addLabels ?? []) {
editArgs.push('--label', label)
hasEditArgs = true
}
for (const label of updates.removeLabels ?? []) {
editArgs.push('--unlabel', label)
hasEditArgs = true
}
for (const assignee of updates.addAssignees ?? []) {
editArgs.push('--assignee', assignee)
hasEditArgs = true
}
for (const assignee of updates.removeAssignees ?? []) {
editArgs.push('--unassignee', assignee)
hasEditArgs = true
}
if (hasEditArgs) {
await acquire()
try {
await glabExecFileAsync(
editArgs,
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGlabError(stderr).message)
} finally {
release()
}
}
if (errors.length > 0) {
return { ok: false, error: errors.join('; ') }
}
return { ok: true }
}
+3 -9
View File
@@ -30,15 +30,9 @@ vi.mock('./gl-utils', async () => {
}
})
import {
addIssueComment,
createIssue,
getIssue,
listAssignableUsers,
listIssues,
listLabels,
updateIssue
} from './issues'
import { addIssueComment, createIssue, getIssue, listIssues } from './issues'
import { updateIssue } from './issue-update'
import { listAssignableUsers, listLabels } from './project-label-and-member-lookup'
describe('gitlab issue operations', () => {
beforeEach(() => {
+2 -269
View File
@@ -1,19 +1,10 @@
/* eslint-disable max-lines -- Why: parallel to src/main/github/issues.ts —
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 {
GitLabAssignableUser,
GitLabCommentResult,
GitLabIssueInfo,
GitLabIssueUpdate,
MRComment
} from '../../shared/gitlab-types'
import type { GitLabCommentResult, GitLabIssueInfo, MRComment } from '../../shared/gitlab-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import { mapGitLabIssueInfo } from './mappers'
// prettier-ignore
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListFetchError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, parseGlabJsonList, type LocalGitExecOptions, type ProjectRef } from './gl-utils'
import { encodedProject } from './project-path-encoding'
// Why: parallel to GitHub's IssueListResult — distinguishes a successful-
// empty listing from a failed fetch.
@@ -22,13 +13,6 @@ export type IssueListResult = {
error?: ClassifiedError
}
// Why: GitLab REST API addresses projects by URL-encoded path. Centralize
// the encoding so a future call site can't forget it (the slash escapes
// are easy to miss).
function encodedProject(projectPath: string): string {
return encodeURIComponent(projectPath)
}
/**
* Get a single issue by number.
*
@@ -209,146 +193,6 @@ export async function createIssue(
}
}
/**
* Update an existing GitLab issue.
*
* Why: callers that list through a per-repo issue source preference must
* mutate the same GitLab project, or identical IIDs on origin/upstream can
* silently edit the wrong issue.
*/
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitLabIssueUpdate,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRefOverride?: ProjectRef | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{ ok: true } | { ok: false; error: string }> {
const projectRef =
projectRefOverride ??
(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(connectionId, localGitOptions),
connectionId,
localGitOptions
)
).source
if (!projectRef) {
return {
ok: false,
error: 'Could not resolve GitLab project for this repository'
}
}
const repoFlag = projectRef.path
const errors: string[] = []
// State change requires a separate command (parallel to github's split).
if (updates.state) {
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
await glabExecFileAsync(
[
'issue',
cmd,
String(issueNumber),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
// Treat "already closed/reopened" as a no-op (matches gh path).
if (!stderr.toLowerCase().includes('already')) {
errors.push(classifyGlabError(stderr).message)
}
} finally {
release()
}
}
if (updates.body !== undefined) {
await acquire()
try {
await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'PUT',
`projects/${encodedProject(repoFlag)}/issues/${issueNumber}`,
'-f',
`description=${updates.body}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGlabError(stderr).message)
} finally {
release()
}
}
// Field edits via `glab issue update`.
const editArgs: string[] = [
'issue',
'update',
String(issueNumber),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
]
let hasEditArgs = false
if (updates.title) {
editArgs.push('--title', updates.title)
hasEditArgs = true
}
for (const label of updates.addLabels ?? []) {
editArgs.push('--label', label)
hasEditArgs = true
}
for (const label of updates.removeLabels ?? []) {
editArgs.push('--unlabel', label)
hasEditArgs = true
}
for (const assignee of updates.addAssignees ?? []) {
editArgs.push('--assignee', assignee)
hasEditArgs = true
}
for (const assignee of updates.removeAssignees ?? []) {
editArgs.push('--unassignee', assignee)
hasEditArgs = true
}
if (hasEditArgs) {
await acquire()
try {
await glabExecFileAsync(
editArgs,
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGlabError(stderr).message)
} finally {
release()
}
}
if (errors.length > 0) {
return { ok: false, error: errors.join('; ') }
}
return { ok: true }
}
/**
* Add a comment (note) to an existing GitLab issue. Mirrors
* github/addIssueComment.
@@ -419,114 +263,3 @@ export async function addIssueComment(
release()
}
}
export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string[]> {
const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId,
localGitOptions
)
if (!projectRef) {
return []
}
await acquire()
try {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/labels`,
'--jq',
'.[].name'
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
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<GitLabAssignableUser[]> {
const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId,
localGitOptions
)
if (!projectRef) {
return []
}
await acquire()
try {
// Why: `members/all` returns project members including those inherited
// from parent groups — important for projects under a top-level group
// where assignable users typically come from the group, not the project.
// --paginate walks every page; --jq emits NDJSON.
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/members/all?per_page=100`,
'--jq',
'.[] | {id, username, name, avatar_url, state}'
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
type RESTMember = {
id?: number
username?: string
name?: string | null
avatar_url?: string | null
state?: string | null
}
const users: GitLabAssignableUser[] = []
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
try {
const user = JSON.parse(trimmed) as RESTMember
if (user.username) {
users.push({
...(typeof user.id === 'number' ? { id: user.id } : {}),
username: user.username,
name: user.name ?? null,
avatarUrl: user.avatar_url ?? '',
...(user.state !== undefined ? { state: user.state } : {})
})
}
} catch {
// Skip malformed NDJSON lines defensively.
}
}
return users
} catch {
return []
} finally {
release()
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { MRComment } from '../../shared/gitlab-types'
import { encodedProject } from './project-path-encoding'
import {
glabHostnameArgs,
glabRepoExecOptions,
glabExecFileAsync,
type LocalGitExecOptions,
type ProjectRef
} from './gl-utils'
// ── Discussion → MRComment flattening ──────────────────────────────
// GitLab returns discussions with nested notes; the dialog renders a
// flat conversation. We drop system notes ("X assigned the MR", auto-
// generated changelog entries) since they aren't user-authored content.
type GitLabRawNote = {
id?: number
body?: string
author?: { username?: string | null; avatar_url?: string | null; state?: string } | null
created_at?: string
system?: boolean
resolvable?: boolean
resolved?: boolean
position?: { new_path?: string; new_line?: number; old_line?: number } | null
}
export type GitLabRawDiscussion = {
id?: string
individual_note?: boolean
notes?: GitLabRawNote[]
}
export function flattenDiscussions(discussions: GitLabRawDiscussion[]): MRComment[] {
const out: MRComment[] = []
for (const discussion of discussions) {
const notes = discussion.notes ?? []
for (const note of notes) {
if (note.system === true) {
// Why: skip GitLab's auto-generated activity entries — they
// would dominate a busy MR's conversation tab if rendered.
continue
}
out.push({
id: note.id ?? 0,
author: note.author?.username ?? 'unknown',
authorAvatarUrl: note.author?.avatar_url ?? '',
body: note.body ?? '',
createdAt: note.created_at ?? '',
url: '',
isBot: note.author?.state === 'bot',
...(discussion.id ? { threadId: discussion.id } : {}),
...(note.resolvable === true ? { isResolved: note.resolved === true } : {}),
...(note.position?.new_path ? { path: note.position.new_path } : {}),
...(typeof note.position?.new_line === 'number' ? { line: note.position.new_line } : {})
})
}
}
// Why: oldest-first matches gitlab.com's conversation rendering and
// makes "what's new" intuitive when polling for updates later.
return out.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''))
}
export async function fetchDiscussions(
repoPath: string,
projectRef: ProjectRef,
type: 'issue' | 'mr',
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabRawDiscussion[]> {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
// Why: detail drawers need a bounded recent conversation snapshot.
// Walking every historic discussion can retain and render huge note sets.
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
return JSON.parse(stdout) as GitLabRawDiscussion[]
}
+94
View File
@@ -0,0 +1,94 @@
import type { GitLabMRFile } from '../../shared/gitlab-types'
import { encodedProject } from './project-path-encoding'
import {
glabHostnameArgs,
glabRepoExecOptions,
glabExecFileAsync,
type LocalGitExecOptions,
type ProjectRef
} from './gl-utils'
/**
* Counts the added/removed lines in a single GitLab MR file's unified diff,
* feeding the +N/-N shown in the MR file list. `---`/`+++` are file headers
* only before the first `@@` hunk; every `+`/`-` line inside a hunk is content.
* Requires hunk headers: a diff with no `@@` counts zero, so do not reuse this
* for header-less agent-tool diffs (see `diffFromText` in shared/native-chat-diff).
*
* @internal - exposed for tests only.
*/
export function countDiffLines(diff: string): { additions: number; deletions: number } {
let additions = 0
let deletions = 0
// Why: `---`/`+++` are file headers only before the first hunk. A removed line
// whose original text began with `--` (SQL/Lua/Haskell `-- comment`) becomes a
// diff line `---<content>`, colliding with the `--- a/file` header — so it must
// be counted once inside a hunk, not skipped.
let inHunk = false
for (const line of diff.split('\n')) {
if (line.startsWith('@@')) {
inHunk = true
continue
}
if (!inHunk) {
continue
}
if (line.startsWith('+')) {
additions += 1
} else if (line.startsWith('-')) {
deletions += 1
}
}
return { additions, deletions }
}
function mapMRFile(raw: {
new_path?: string
old_path?: string
diff?: string
new_file?: boolean
deleted_file?: boolean
renamed_file?: boolean
binary?: boolean
too_large?: boolean
}): GitLabMRFile {
const diff = raw.diff ?? ''
const counts = countDiffLines(diff)
const status = raw.new_file
? 'added'
: raw.deleted_file
? 'removed'
: raw.renamed_file
? 'renamed'
: 'modified'
return {
path: raw.new_path ?? raw.old_path ?? '',
...(raw.old_path && raw.old_path !== raw.new_path ? { oldPath: raw.old_path } : {}),
status,
additions: counts.additions,
deletions: counts.deletions,
isBinary: Boolean(raw.binary || raw.too_large || !diff),
...(diff ? { diff } : {})
}
}
export async function fetchMRFiles(
repoPath: string,
projectRef: ProjectRef,
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabMRFile[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
// Why: GitLab deprecated the all-in-one `changes` endpoint in favor of
// the paginated diffs endpoint; cap the file snapshot at one visible page.
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/diffs?per_page=100`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as Parameters<typeof mapMRFile>[0][]
return data.map(mapMRFile).filter((file) => file.path)
}
@@ -0,0 +1,94 @@
import type { GitLabAssignableUser, GitLabMRApprovalState } from '../../shared/gitlab-types'
import { mapGitLabUser, type GitLabRawUser } from './gitlab-assignable-user-mapping'
import { encodedProject } from './project-path-encoding'
import {
glabHostnameArgs,
glabRepoExecOptions,
glabExecFileAsync,
type LocalGitExecOptions,
type ProjectRef
} from './gl-utils'
export async function fetchMRReviewers(
repoPath: string,
projectRef: ProjectRef,
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabAssignableUser[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/reviewers`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as { user?: GitLabRawUser | null }[]
return data
.map((entry) => mapGitLabUser(entry.user))
.filter((u): u is GitLabAssignableUser => !!u)
}
export async function fetchMRApprovalState(
repoPath: string,
projectRef: ProjectRef,
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabMRApprovalState | undefined> {
const [approvalsRes, stateRes] = await Promise.allSettled([
glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/approvals`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
),
glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/approval_state`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
])
if (approvalsRes.status === 'rejected' && stateRes.status === 'rejected') {
return undefined
}
const approvals =
approvalsRes.status === 'fulfilled'
? (JSON.parse(approvalsRes.value.stdout) as {
approvals_required?: number | null
approvals_left?: number | null
approved_by?: { user?: GitLabRawUser | null }[]
})
: null
const state =
stateRes.status === 'fulfilled'
? (JSON.parse(stateRes.value.stdout) as {
rules?: {
id?: number
name?: string
approvals_required?: number
approved?: boolean
}[]
})
: null
return {
approvalsRequired:
typeof approvals?.approvals_required === 'number' ? approvals.approvals_required : null,
approvalsLeft: typeof approvals?.approvals_left === 'number' ? approvals.approvals_left : null,
approvedBy: (approvals?.approved_by ?? [])
.map((entry) => mapGitLabUser(entry.user))
.filter((u): u is GitLabAssignableUser => !!u),
rules: (state?.rules ?? []).map((rule) => ({
id: rule.id ?? 0,
name: rule.name ?? 'Approval rule',
approvalsRequired: rule.approvals_required ?? 0,
approved: Boolean(rule.approved)
}))
}
}
+241
View File
@@ -0,0 +1,241 @@
import type { GitLabPipelineJob } from '../../shared/gitlab-types'
import { encodedProject } from './project-path-encoding'
import {
glabHostnameArgs,
glabRepoExecOptions,
glabExecFileAsync,
type LocalGitExecOptions,
type ProjectRef
} from './gl-utils'
// ── Pipeline jobs ──────────────────────────────────────────────────
// Why: GitLab's `/pipelines/:id/jobs` only returns jobs owned by that pipeline.
// Trigger/include bridges live under `/bridges` and their real CI jobs live on
// the child pipeline — Orca used to show only the parent (often just SAST), so
// Checks looked empty next to gitlab.com's full graph.
const PIPELINE_JOB_PAGE_SIZE = 100
/** Cap expanded child-pipeline fan-out so one MR details load stays bounded. */
const MAX_CHILD_PIPELINES_TO_EXPAND = 20
// Why: every fetch spawns a `glab` binary (a remote exec over SSH), and this runs on
// the Checks poll timer. Match gl-utils' MAX_CONCURRENT so a bridge-heavy MR trickles
// its children instead of bursting 20 processes at once.
const MAX_CONCURRENT_CHILD_FETCHES = 4
type GitLabRawJob = {
id?: number
name?: string
stage?: string
status?: string
web_url?: string
duration?: number | null
}
type GitLabRawBridge = {
id?: number
name?: string
stage?: string
status?: string
web_url?: string
duration?: number | null
downstream_pipeline?: {
id?: number
project_id?: number
status?: string
web_url?: string
} | null
}
function mapPipelineJob(raw: GitLabRawJob, pipelineId: number): GitLabPipelineJob {
return {
id: raw.id ?? 0,
pipelineId,
name: raw.name ?? '',
stage: raw.stage ?? '',
status: raw.status ?? '',
webUrl: raw.web_url ?? '',
duration: typeof raw.duration === 'number' ? raw.duration : null
}
}
function mapBridgeAsJob(raw: GitLabRawBridge, pipelineId: number): GitLabPipelineJob {
const childStatus = raw.downstream_pipeline?.status
return {
// Why: bridges are not real jobs — omit a positive id so Checks won't try
// job-trace/retry APIs on them. Rows still render via name + webUrl.
id: 0,
pipelineId,
name: raw.name ?? 'bridge',
stage: raw.stage ?? '',
// Prefer the child pipeline's rollup when present — the bridge job itself
// often stays `success` while the downstream graph is still running/failed.
status: childStatus || raw.status || '',
webUrl: raw.downstream_pipeline?.web_url ?? raw.web_url ?? '',
duration: typeof raw.duration === 'number' ? raw.duration : null
}
}
async function fetchPipelineJobPage(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number,
connectionId: string | null | undefined,
localGitOptions: LocalGitExecOptions
): Promise<GitLabPipelineJob[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=${PIPELINE_JOB_PAGE_SIZE}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as GitLabRawJob[]
if (!Array.isArray(data)) {
return []
}
return data.map((job) => mapPipelineJob(job, pipelineId))
}
async function fetchPipelineBridges(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number,
connectionId: string | null | undefined,
localGitOptions: LocalGitExecOptions
): Promise<GitLabRawBridge[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/bridges?per_page=${PIPELINE_JOB_PAGE_SIZE}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as GitLabRawBridge[]
return Array.isArray(data) ? data : []
}
function childPipelineTarget(
bridge: GitLabRawBridge,
parentProjectRef: ProjectRef
): { projectRef: ProjectRef; pipelineId: number } | null {
const childId = bridge.downstream_pipeline?.id
if (typeof childId !== 'number') {
return null
}
// Why: prefer path from web_url so same- and cross-project children both work
// without a project-id lookup. If the URL is missing/unparseable, fall back to
// the parent project (same-project triggers); wrong-project calls fail soft.
const webUrl = bridge.downstream_pipeline?.web_url
if (webUrl) {
try {
const url = new URL(webUrl)
// web_url shape: https://host/group/project/-/pipelines/123
const marker = url.pathname.indexOf('/-/pipelines/')
if (marker > 0) {
const path = url.pathname.slice(1, marker).replace(/\/$/, '')
if (path) {
return {
projectRef: { host: url.host || parentProjectRef.host, path },
pipelineId: childId
}
}
}
} catch {
// fall through to parent project
}
}
return { projectRef: parentProjectRef, pipelineId: childId }
}
/** Results stay in input order; a shared cursor keeps fast workers from idling behind a slow batch. */
async function mapWithConcurrencyLimit<T, R>(
items: T[],
limit: number,
run: (item: T) => Promise<R>
): Promise<R[]> {
const out = Array.from({ length: items.length }) as R[]
let cursor = 0
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor
cursor += 1
out[index] = await run(items[index])
}
})
)
return out
}
export async function fetchPipelineJobs(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabPipelineJob[]> {
const [parentJobs, bridges] = await Promise.all([
fetchPipelineJobPage(repoPath, projectRef, pipelineId, connectionId, localGitOptions),
fetchPipelineBridges(repoPath, projectRef, pipelineId, connectionId, localGitOptions).catch(
() => [] as GitLabRawBridge[]
)
])
const bridgeRows = bridges.map((bridge) => mapBridgeAsJob(bridge, pipelineId))
const childTargets: { projectRef: ProjectRef; pipelineId: number }[] = []
const seenChildIds = new Set<number>()
for (const bridge of bridges) {
const target = childPipelineTarget(bridge, projectRef)
if (!target || seenChildIds.has(target.pipelineId)) {
continue
}
seenChildIds.add(target.pipelineId)
childTargets.push(target)
if (childTargets.length >= MAX_CHILD_PIPELINES_TO_EXPAND) {
break
}
}
const childJobBatches = await mapWithConcurrencyLimit(
childTargets,
MAX_CONCURRENT_CHILD_FETCHES,
(target) =>
fetchPipelineJobPage(
repoPath,
target.projectRef,
target.pipelineId,
connectionId,
localGitOptions
).catch(() => [] as GitLabPipelineJob[])
)
// Parent jobs first, then each child's jobs. Bridge rollup rows last and only
// when no expanded child job already carries the same name (avoid duplicates).
const seenJobIds = new Set<number>()
const seenNames = new Set<string>()
const out: GitLabPipelineJob[] = []
for (const job of [...parentJobs, ...childJobBatches.flat()]) {
if (job.id) {
if (seenJobIds.has(job.id)) {
continue
}
seenJobIds.add(job.id)
}
out.push(job)
if (job.name) {
seenNames.add(job.name)
}
}
for (const bridge of bridgeRows) {
if (bridge.name && seenNames.has(bridge.name)) {
continue
}
out.push(bridge)
if (bridge.name) {
seenNames.add(bridge.name)
}
}
return out
}
@@ -0,0 +1,124 @@
import type { GitLabAssignableUser } from '../../shared/gitlab-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import {
acquire,
getGlabKnownHosts,
glabExecFileAsync,
glabHostnameArgs,
glabRepoExecOptions,
release,
resolveIssueSource,
type LocalGitExecOptions
} from './gl-utils'
import { encodedProject } from './project-path-encoding'
export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string[]> {
const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId,
localGitOptions
)
if (!projectRef) {
return []
}
await acquire()
try {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/labels`,
'--jq',
'.[].name'
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
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<GitLabAssignableUser[]> {
const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId,
localGitOptions
)
if (!projectRef) {
return []
}
await acquire()
try {
// Why: `members/all` returns project members including those inherited
// from parent groups — important for projects under a top-level group
// where assignable users typically come from the group, not the project.
// --paginate walks every page; --jq emits NDJSON.
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/members/all?per_page=100`,
'--jq',
'.[] | {id, username, name, avatar_url, state}'
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
type RESTMember = {
id?: number
username?: string
name?: string | null
avatar_url?: string | null
state?: string | null
}
const users: GitLabAssignableUser[] = []
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
try {
const user = JSON.parse(trimmed) as RESTMember
if (user.username) {
users.push({
...(typeof user.id === 'number' ? { id: user.id } : {}),
username: user.username,
name: user.name ?? null,
avatarUrl: user.avatar_url ?? '',
...(user.state !== undefined ? { state: user.state } : {})
})
}
} catch {
// Skip malformed NDJSON lines defensively.
}
}
return users
} catch {
return []
} finally {
release()
}
}
+6
View File
@@ -0,0 +1,6 @@
// Why: GitLab REST API addresses projects by URL-encoded path. Centralize
// the encoding so a future call site can't forget it (the slash escapes
// are easy to miss).
export function encodedProject(projectPath: string): string {
return encodeURIComponent(projectPath)
}
+2 -1
View File
@@ -32,7 +32,8 @@ vi.mock('./gl-utils', () => ({
glabRepoExecOptions: glabRepoExecOptionsMock
}))
import { countDiffLines, getWorkItemDetails } from './work-item-details'
import { countDiffLines } from './mr-file-diffs'
import { getWorkItemDetails } from './work-item-details'
describe('getWorkItemDetails', () => {
beforeEach(() => {
+7 -506
View File
@@ -1,19 +1,20 @@
/* eslint-disable max-lines -- Why: aggregated detail-fetch for GitLabItemDialog spans issues, MRs, comments, pipelines, reviewers, approvals, and changed files; splitting would obscure the shared fetch context. */
// Why: aggregated detail-fetch for GitLabItemDialog. Parallel of
// src/main/github/work-item-details.ts but scoped to v1 surface —
// description body, flattened discussion notes, MR pipeline jobs/reviewers.
// Files / inline review-comment positioning are deferred.
import type {
GitLabAssignableUser,
GitLabMRApprovalState,
GitLabMRFile,
GitLabPipelineJob,
GitLabWorkItem,
GitLabWorkItemDetails,
MRComment
GitLabWorkItemDetails
} from '../../shared/gitlab-types'
import type { IssueSourcePreference } from '../../shared/repo-types'
import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers'
import { mapGitLabUser, type GitLabRawUser } from './gitlab-assignable-user-mapping'
import { encodedProject } from './project-path-encoding'
import { fetchDiscussions, flattenDiscussions } from './mr-discussion-notes'
import { fetchMRFiles } from './mr-file-diffs'
import { fetchMRApprovalState, fetchMRReviewers } from './mr-reviewers-and-approvals'
import { fetchPipelineJobs } from './pipeline-job-graph'
import {
acquire,
getGlabKnownHosts,
@@ -26,422 +27,6 @@ import {
type ProjectRef
} from './gl-utils'
function encodedProject(projectPath: string): string {
return encodeURIComponent(projectPath)
}
// ── Discussion → MRComment flattening ──────────────────────────────
// GitLab returns discussions with nested notes; the dialog renders a
// flat conversation. We drop system notes ("X assigned the MR", auto-
// generated changelog entries) since they aren't user-authored content.
type GitLabRawNote = {
id?: number
body?: string
author?: { username?: string | null; avatar_url?: string | null; state?: string } | null
created_at?: string
system?: boolean
resolvable?: boolean
resolved?: boolean
position?: { new_path?: string; new_line?: number; old_line?: number } | null
}
type GitLabRawDiscussion = {
id?: string
individual_note?: boolean
notes?: GitLabRawNote[]
}
function flattenDiscussions(discussions: GitLabRawDiscussion[]): MRComment[] {
const out: MRComment[] = []
for (const discussion of discussions) {
const notes = discussion.notes ?? []
for (const note of notes) {
if (note.system === true) {
// Why: skip GitLab's auto-generated activity entries — they
// would dominate a busy MR's conversation tab if rendered.
continue
}
out.push({
id: note.id ?? 0,
author: note.author?.username ?? 'unknown',
authorAvatarUrl: note.author?.avatar_url ?? '',
body: note.body ?? '',
createdAt: note.created_at ?? '',
url: '',
isBot: note.author?.state === 'bot',
...(discussion.id ? { threadId: discussion.id } : {}),
...(note.resolvable === true ? { isResolved: note.resolved === true } : {}),
...(note.position?.new_path ? { path: note.position.new_path } : {}),
...(typeof note.position?.new_line === 'number' ? { line: note.position.new_line } : {})
})
}
}
// Why: oldest-first matches gitlab.com's conversation rendering and
// makes "what's new" intuitive when polling for updates later.
return out.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''))
}
async function fetchDiscussions(
repoPath: string,
projectRef: ProjectRef,
type: 'issue' | 'mr',
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabRawDiscussion[]> {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
// Why: detail drawers need a bounded recent conversation snapshot.
// Walking every historic discussion can retain and render huge note sets.
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
return JSON.parse(stdout) as GitLabRawDiscussion[]
}
// ── Pipeline jobs ──────────────────────────────────────────────────
// Why: GitLab's `/pipelines/:id/jobs` only returns jobs owned by that pipeline.
// Trigger/include bridges live under `/bridges` and their real CI jobs live on
// the child pipeline — Orca used to show only the parent (often just SAST), so
// Checks looked empty next to gitlab.com's full graph.
const PIPELINE_JOB_PAGE_SIZE = 100
/** Cap expanded child-pipeline fan-out so one MR details load stays bounded. */
const MAX_CHILD_PIPELINES_TO_EXPAND = 20
// Why: every fetch spawns a `glab` binary (a remote exec over SSH), and this runs on
// the Checks poll timer. Match gl-utils' MAX_CONCURRENT so a bridge-heavy MR trickles
// its children instead of bursting 20 processes at once.
const MAX_CONCURRENT_CHILD_FETCHES = 4
type GitLabRawJob = {
id?: number
name?: string
stage?: string
status?: string
web_url?: string
duration?: number | null
}
type GitLabRawBridge = {
id?: number
name?: string
stage?: string
status?: string
web_url?: string
duration?: number | null
downstream_pipeline?: {
id?: number
project_id?: number
status?: string
web_url?: string
} | null
}
type GitLabRawUser = {
id?: number
username?: string | null
name?: string | null
avatar_url?: string | null
state?: string | null
}
function mapGitLabUser(raw: GitLabRawUser | null | undefined): GitLabAssignableUser | null {
if (!raw?.username) {
return null
}
return {
...(typeof raw.id === 'number' ? { id: raw.id } : {}),
username: raw.username,
name: raw.name ?? null,
avatarUrl: raw.avatar_url ?? '',
...(raw.state !== undefined ? { state: raw.state } : {})
}
}
function mapPipelineJob(raw: GitLabRawJob, pipelineId: number): GitLabPipelineJob {
return {
id: raw.id ?? 0,
pipelineId,
name: raw.name ?? '',
stage: raw.stage ?? '',
status: raw.status ?? '',
webUrl: raw.web_url ?? '',
duration: typeof raw.duration === 'number' ? raw.duration : null
}
}
function mapBridgeAsJob(raw: GitLabRawBridge, pipelineId: number): GitLabPipelineJob {
const childStatus = raw.downstream_pipeline?.status
return {
// Why: bridges are not real jobs — omit a positive id so Checks won't try
// job-trace/retry APIs on them. Rows still render via name + webUrl.
id: 0,
pipelineId,
name: raw.name ?? 'bridge',
stage: raw.stage ?? '',
// Prefer the child pipeline's rollup when present — the bridge job itself
// often stays `success` while the downstream graph is still running/failed.
status: childStatus || raw.status || '',
webUrl: raw.downstream_pipeline?.web_url ?? raw.web_url ?? '',
duration: typeof raw.duration === 'number' ? raw.duration : null
}
}
async function fetchPipelineJobPage(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number,
connectionId: string | null | undefined,
localGitOptions: LocalGitExecOptions
): Promise<GitLabPipelineJob[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=${PIPELINE_JOB_PAGE_SIZE}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as GitLabRawJob[]
if (!Array.isArray(data)) {
return []
}
return data.map((job) => mapPipelineJob(job, pipelineId))
}
async function fetchPipelineBridges(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number,
connectionId: string | null | undefined,
localGitOptions: LocalGitExecOptions
): Promise<GitLabRawBridge[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/bridges?per_page=${PIPELINE_JOB_PAGE_SIZE}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as GitLabRawBridge[]
return Array.isArray(data) ? data : []
}
function childPipelineTarget(
bridge: GitLabRawBridge,
parentProjectRef: ProjectRef
): { projectRef: ProjectRef; pipelineId: number } | null {
const childId = bridge.downstream_pipeline?.id
if (typeof childId !== 'number') {
return null
}
// Why: prefer path from web_url so same- and cross-project children both work
// without a project-id lookup. If the URL is missing/unparseable, fall back to
// the parent project (same-project triggers); wrong-project calls fail soft.
const webUrl = bridge.downstream_pipeline?.web_url
if (webUrl) {
try {
const url = new URL(webUrl)
// web_url shape: https://host/group/project/-/pipelines/123
const marker = url.pathname.indexOf('/-/pipelines/')
if (marker > 0) {
const path = url.pathname.slice(1, marker).replace(/\/$/, '')
if (path) {
return {
projectRef: { host: url.host || parentProjectRef.host, path },
pipelineId: childId
}
}
}
} catch {
// fall through to parent project
}
}
return { projectRef: parentProjectRef, pipelineId: childId }
}
/** Results stay in input order; a shared cursor keeps fast workers from idling behind a slow batch. */
async function mapWithConcurrencyLimit<T, R>(
items: T[],
limit: number,
run: (item: T) => Promise<R>
): Promise<R[]> {
const out = Array.from({ length: items.length }) as R[]
let cursor = 0
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const index = cursor
cursor += 1
out[index] = await run(items[index])
}
})
)
return out
}
async function fetchPipelineJobs(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabPipelineJob[]> {
const [parentJobs, bridges] = await Promise.all([
fetchPipelineJobPage(repoPath, projectRef, pipelineId, connectionId, localGitOptions),
fetchPipelineBridges(repoPath, projectRef, pipelineId, connectionId, localGitOptions).catch(
() => [] as GitLabRawBridge[]
)
])
const bridgeRows = bridges.map((bridge) => mapBridgeAsJob(bridge, pipelineId))
const childTargets: { projectRef: ProjectRef; pipelineId: number }[] = []
const seenChildIds = new Set<number>()
for (const bridge of bridges) {
const target = childPipelineTarget(bridge, projectRef)
if (!target || seenChildIds.has(target.pipelineId)) {
continue
}
seenChildIds.add(target.pipelineId)
childTargets.push(target)
if (childTargets.length >= MAX_CHILD_PIPELINES_TO_EXPAND) {
break
}
}
const childJobBatches = await mapWithConcurrencyLimit(
childTargets,
MAX_CONCURRENT_CHILD_FETCHES,
(target) =>
fetchPipelineJobPage(
repoPath,
target.projectRef,
target.pipelineId,
connectionId,
localGitOptions
).catch(() => [] as GitLabPipelineJob[])
)
// Parent jobs first, then each child's jobs. Bridge rollup rows last and only
// when no expanded child job already carries the same name (avoid duplicates).
const seenJobIds = new Set<number>()
const seenNames = new Set<string>()
const out: GitLabPipelineJob[] = []
for (const job of [...parentJobs, ...childJobBatches.flat()]) {
if (job.id) {
if (seenJobIds.has(job.id)) {
continue
}
seenJobIds.add(job.id)
}
out.push(job)
if (job.name) {
seenNames.add(job.name)
}
}
for (const bridge of bridgeRows) {
if (bridge.name && seenNames.has(bridge.name)) {
continue
}
out.push(bridge)
if (bridge.name) {
seenNames.add(bridge.name)
}
}
return out
}
/**
* Counts the added/removed lines in a single GitLab MR file's unified diff,
* feeding the +N/-N shown in the MR file list. `---`/`+++` are file headers
* only before the first `@@` hunk; every `+`/`-` line inside a hunk is content.
* Requires hunk headers: a diff with no `@@` counts zero, so do not reuse this
* for header-less agent-tool diffs (see `diffFromText` in shared/native-chat-diff).
*
* @internal - exposed for tests only.
*/
export function countDiffLines(diff: string): { additions: number; deletions: number } {
let additions = 0
let deletions = 0
// Why: `---`/`+++` are file headers only before the first hunk. A removed line
// whose original text began with `--` (SQL/Lua/Haskell `-- comment`) becomes a
// diff line `---<content>`, colliding with the `--- a/file` header — so it must
// be counted once inside a hunk, not skipped.
let inHunk = false
for (const line of diff.split('\n')) {
if (line.startsWith('@@')) {
inHunk = true
continue
}
if (!inHunk) {
continue
}
if (line.startsWith('+')) {
additions += 1
} else if (line.startsWith('-')) {
deletions += 1
}
}
return { additions, deletions }
}
function mapMRFile(raw: {
new_path?: string
old_path?: string
diff?: string
new_file?: boolean
deleted_file?: boolean
renamed_file?: boolean
binary?: boolean
too_large?: boolean
}): GitLabMRFile {
const diff = raw.diff ?? ''
const counts = countDiffLines(diff)
const status = raw.new_file
? 'added'
: raw.deleted_file
? 'removed'
: raw.renamed_file
? 'renamed'
: 'modified'
return {
path: raw.new_path ?? raw.old_path ?? '',
...(raw.old_path && raw.old_path !== raw.new_path ? { oldPath: raw.old_path } : {}),
status,
additions: counts.additions,
deletions: counts.deletions,
isBinary: Boolean(raw.binary || raw.too_large || !diff),
...(diff ? { diff } : {})
}
}
async function fetchMRFiles(
repoPath: string,
projectRef: ProjectRef,
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabMRFile[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
// Why: GitLab deprecated the all-in-one `changes` endpoint in favor of
// the paginated diffs endpoint; cap the file snapshot at one visible page.
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/diffs?per_page=100`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as Parameters<typeof mapMRFile>[0][]
return data.map(mapMRFile).filter((file) => file.path)
}
// ── Top-level aggregator ───────────────────────────────────────────
type GitLabRawIssue = Parameters<typeof mapIssueToWorkItem>[0] & {
@@ -457,90 +42,6 @@ type GitLabRawMR = Parameters<typeof mapMRToWorkItem>[0] & {
reviewers?: GitLabRawUser[] | null
}
async function fetchMRReviewers(
repoPath: string,
projectRef: ProjectRef,
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabAssignableUser[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/reviewers`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as { user?: GitLabRawUser | null }[]
return data
.map((entry) => mapGitLabUser(entry.user))
.filter((u): u is GitLabAssignableUser => !!u)
}
async function fetchMRApprovalState(
repoPath: string,
projectRef: ProjectRef,
iid: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabMRApprovalState | undefined> {
const [approvalsRes, stateRes] = await Promise.allSettled([
glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/approvals`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
),
glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/approval_state`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
])
if (approvalsRes.status === 'rejected' && stateRes.status === 'rejected') {
return undefined
}
const approvals =
approvalsRes.status === 'fulfilled'
? (JSON.parse(approvalsRes.value.stdout) as {
approvals_required?: number | null
approvals_left?: number | null
approved_by?: { user?: GitLabRawUser | null }[]
})
: null
const state =
stateRes.status === 'fulfilled'
? (JSON.parse(stateRes.value.stdout) as {
rules?: {
id?: number
name?: string
approvals_required?: number
approved?: boolean
}[]
})
: null
return {
approvalsRequired:
typeof approvals?.approvals_required === 'number' ? approvals.approvals_required : null,
approvalsLeft: typeof approvals?.approvals_left === 'number' ? approvals.approvals_left : null,
approvedBy: (approvals?.approved_by ?? [])
.map((entry) => mapGitLabUser(entry.user))
.filter((u): u is GitLabAssignableUser => !!u),
rules: (state?.rules ?? []).map((rule) => ({
id: rule.id ?? 0,
name: rule.name ?? 'Approval rule',
approvalsRequired: rule.approvals_required ?? 0,
approved: Boolean(rule.approved)
}))
}
}
/**
* Fetch full details for a GitLab MR or issue: the work item itself,
* description body, discussion notes flattened to MRComment[], and (for
+1 -1
View File
@@ -108,7 +108,7 @@ import type {
UpdateIssueTypeBySlugArgs,
UpdateProjectItemFieldArgs,
UpdatePullRequestBySlugArgs
} from '../../shared/github/project-types'
} from '../../shared/github/project-request-types'
import { appStarSourceSchema } from '../../shared/gh-star-source'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
+50
View File
@@ -0,0 +1,50 @@
import { ipcMain } from 'electron'
import { toGitLabJobLogExcerptResult } from '../../shared/gitlab-job-log-excerpt'
import type { Store } from '../persistence'
import { getJobTrace, retryJob } from '../gitlab/client'
import type { ProjectRef } from '../gitlab/gl-utils'
import type { GitLabRepoSelectorArgs } from './gitlab-repo-access'
import { assertRegisteredRepo, localGitOptionArgs, repoConnectionId } from './gitlab-repo-access'
export function registerGitLabCiJobHandlers(store: Store): void {
ipcMain.handle(
'gitlab:jobTrace',
async (
_event,
args: GitLabRepoSelectorArgs & {
jobId: number
projectRef?: ProjectRef | null
logExcerpt?: boolean
}
) => {
const repo = assertRegisteredRepo(args, store)
const result = await getJobTrace(
repo.path,
args.jobId,
repo.issueSourcePreference,
repoConnectionId(repo),
args.projectRef,
...localGitOptionArgs(store, repo)
)
return args.logExcerpt ? toGitLabJobLogExcerptResult(result) : result
}
)
ipcMain.handle(
'gitlab:retryJob',
async (
_event,
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null }
) => {
const repo = assertRegisteredRepo(args, store)
return retryJob(
repo.path,
args.jobId,
repo.issueSourcePreference,
repoConnectionId(repo),
args.projectRef,
...localGitOptionArgs(store, repo)
)
}
)
}
+150
View File
@@ -0,0 +1,150 @@
import { ipcMain } from 'electron'
import type { GitLabIssueUpdate, GitLabWorkItem } from '../../shared/gitlab-types'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { Store } from '../persistence'
import {
normalizeGitLabIssueAssignee,
normalizeGitLabIssueListState,
normalizeGitLabPositiveInteger
} from '../gitlab/gitlab-preload-args'
import {
addIssueComment,
createIssue,
getIssue,
listAssignableUsers,
listIssues,
listLabels,
updateIssue
} from '../gitlab/client'
import type { GitLabRepoSelectorArgs } from './gitlab-repo-access'
import { assertRegisteredRepo, localGitOptionArgs, repoConnectionId } from './gitlab-repo-access'
export function registerGitLabIssueHandlers(store: Store): void {
ipcMain.handle(
'gitlab:issue',
async (_event, args: GitLabRepoSelectorArgs & { number: number }) => {
const repo = assertRegisteredRepo(args, store)
return getIssue(
repo.path,
args.number,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:listIssues',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'closed' | 'all'
assignee?: string
limit?: number
}
) => {
const repo = assertRegisteredRepo(args, store)
const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100)
const state = normalizeGitLabIssueListState(args.state)
const assignee = normalizeGitLabIssueAssignee(args.assignee)
const result = await listIssues(
repo.path,
limit,
repo.issueSourcePreference,
state,
assignee,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
// Why: Tasks page expects GitLabWorkItem[] so it can share row
// rendering with MRs. Map IssueInfo → WorkItem here so the renderer
// doesn't need a separate code path.
const workItems: GitLabWorkItem[] = result.items.map((issue) => ({
id: `gitlab-issue-${repo.id}-${issue.number}`,
type: 'issue' as const,
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.url,
labels: issue.labels,
updatedAt: issue.updatedAt ?? '',
author: issue.author ?? null,
repoId: repo.id
}))
return { items: workItems, ...(result.error ? { error: result.error } : {}) }
}
)
ipcMain.handle(
'gitlab:createIssue',
async (_event, args: GitLabRepoSelectorArgs & { title: string; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return createIssue(
repo.path,
args.title,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:updateIssue',
async (
_event,
args: GitLabRepoSelectorArgs & { number: number; updates: GitLabIssueUpdate }
) => {
const repo = assertRegisteredRepo(args, store)
return updateIssue(
repo.path,
args.number,
args.updates,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:addIssueComment',
async (_event, args: GitLabRepoSelectorArgs & { number: number; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return addIssueComment(
repo.path,
args.number,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle('gitlab:listLabels', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
return listLabels(
repo.path,
repo.issueSourcePreference,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
})
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
return listAssignableUsers(
repo.path,
repo.issueSourcePreference,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
})
}
@@ -0,0 +1,172 @@
import { ipcMain } from 'electron'
import type { GitLabMRInlineCommentInput, GitLabMRUpdate } from '../../shared/gitlab-types'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { Store } from '../persistence'
import {
addMRComment,
addMRInlineComment,
closeMR,
mergeMR,
reopenMR,
resolveMRDiscussion,
updateMR,
updateMRReviewers
} from '../gitlab/client'
import type { ProjectRef } from '../gitlab/gl-utils'
import type { GitLabRepoSelectorArgs } from './gitlab-repo-access'
import { assertRegisteredRepo, localGitOptionArgs, repoConnectionId } from './gitlab-repo-access'
export function registerGitLabMergeRequestMutationHandlers(store: Store): void {
ipcMain.handle(
'gitlab:closeMR',
async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return closeMR(
repo.path,
args.iid,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:reopenMR',
async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return reopenMR(
repo.path,
args.iid,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:mergeMR',
async (
_event,
args: GitLabRepoSelectorArgs & { iid: number; method?: 'merge' | 'squash' | 'rebase' }
) => {
const repo = assertRegisteredRepo(args, store)
return mergeMR(
repo.path,
args.iid,
args.method ?? 'merge',
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:updateMR',
async (_event, args: GitLabRepoSelectorArgs & { iid: number; updates: GitLabMRUpdate }) => {
const repo = assertRegisteredRepo(args, store)
return updateMR(
repo.path,
args.iid,
args.updates,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:updateMRReviewers',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
iid: number
reviewerIds: number[]
projectRef?: ProjectRef | null
}
) => {
const repo = assertRegisteredRepo(args, store)
return updateMRReviewers(
repo.path,
args.iid,
args.reviewerIds,
repo.issueSourcePreference,
repoConnectionId(repo),
args.projectRef,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:addMRComment',
async (_event, args: GitLabRepoSelectorArgs & { iid: number; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return addMRComment(
repo.path,
args.iid,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:addMRInlineComment',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
iid: number
input: GitLabMRInlineCommentInput
projectRef?: ProjectRef | null
}
) => {
const repo = assertRegisteredRepo(args, store)
return addMRInlineComment(
repo.path,
args.iid,
args.input,
repo.issueSourcePreference,
repoConnectionId(repo),
args.projectRef,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:resolveMRDiscussion',
async (
_event,
args: GitLabRepoSelectorArgs & { iid: number; discussionId: string; resolved: boolean }
) => {
const repo = assertRegisteredRepo(args, store)
return resolveMRDiscussion(
repo.path,
args.iid,
args.discussionId,
args.resolved,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
}
@@ -0,0 +1,76 @@
import { ipcMain } from 'electron'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { Store } from '../persistence'
import {
normalizeGitLabMRListState,
normalizeGitLabPositiveInteger,
normalizeGitLabSearchQuery
} from '../gitlab/gitlab-preload-args'
import { getMergeRequest, getMergeRequestForBranch, listMergeRequests } from '../gitlab/client'
import type { GitLabRepoSelectorArgs } from './gitlab-repo-access'
import {
assertRegisteredRepo,
hostedReviewOptionArgs,
localGitOptionArgs,
repoConnectionId
} from './gitlab-repo-access'
export function registerGitLabMergeRequestQueryHandlers(store: Store): void {
ipcMain.handle(
'gitlab:mrForBranch',
async (
_event,
args: GitLabRepoSelectorArgs & { branch: string; linkedMRIid?: number | null }
) => {
const repo = assertRegisteredRepo(args, store)
return getMergeRequestForBranch(
repo.path,
args.branch,
args.linkedMRIid ?? null,
repoConnectionId(repo),
...hostedReviewOptionArgs(store, repo)
)
}
)
ipcMain.handle('gitlab:mr', async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return getMergeRequest(
repo.path,
args.iid,
repoConnectionId(repo),
...hostedReviewOptionArgs(store, repo)
)
})
ipcMain.handle(
'gitlab:listMRs',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
query?: string
}
) => {
const repo = assertRegisteredRepo(args, store)
const state = normalizeGitLabMRListState(args.state)
const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000)
const perPage = normalizeGitLabPositiveInteger(args.perPage, 20, 100)
return listMergeRequests(
repo.path,
state,
page,
perPage,
repo.issueSourcePreference,
normalizeGitLabSearchQuery(args.query),
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
}
)
}
+65
View File
@@ -0,0 +1,65 @@
import { resolve } from 'node:path'
import type { Repo } from '../../shared/repo-types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { Store } from '../persistence'
import type { LocalGitExecOptions } from '../gitlab/gitlab-project-ref-resolution'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options'
export type GitLabRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
function findRegisteredGitLabRepo(args: GitLabRepoSelectorArgs, store: Store): Repo | undefined {
const sourceRepoId =
args.sourceContext?.provider === 'gitlab' ? args.sourceContext.repoId?.trim() : null
const repoId = args.repoId?.trim() || sourceRepoId || null
if (repoId) {
const repo = store.getRepo(repoId)
if (repo) {
return repo
}
}
const resolvedRepoPath = resolve(args.repoPath)
return store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath)
}
// Why: mirror github.ts assertRegisteredRepo — main-process handlers
// must never operate on a path the user hasn't explicitly registered as
// a repo (filesystem-auth boundary). Source context adds a host check so a
// task fetched from one machine cannot mutate a same-path repo on another.
export function assertRegisteredRepo(args: GitLabRepoSelectorArgs, store: Store): Repo {
const repo = findRegisteredGitLabRepo(args, store)
if (!repo) {
throw new Error('Access denied: unknown repository path')
}
if (
args.sourceContext?.provider === 'gitlab' &&
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
) {
throw new Error('Access denied: GitLab source host does not match repository host')
}
return repo
}
export function repoConnectionId(repo: Repo): string | null {
return repo.connectionId ?? null
}
export function localGitOptionArgs(store: Store, repo: Repo): [] | [LocalGitExecOptions] {
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
return localGitOptions.wslDistro ? [{ wslDistro: localGitOptions.wslDistro }] : []
}
export function hostedReviewOptionArgs(
store: Store,
repo: Repo
): [] | [HostedReviewExecutionOptions] {
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
return localGitOptions.wslDistro
? [{ localGitExecOptions: { wslDistro: localGitOptions.wslDistro } }]
: []
}
+100
View File
@@ -0,0 +1,100 @@
import { ipcMain } from 'electron'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { Store } from '../persistence'
import {
normalizeGitLabMRListState,
normalizeGitLabPositiveInteger,
normalizeGitLabSearchQuery
} from '../gitlab/gitlab-preload-args'
import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents'
import { getWorkItemByProjectRef, listWorkItems } from '../gitlab/client'
import { getWorkItemDetails } from '../gitlab/work-item-details'
import type { ProjectRef } from '../gitlab/gl-utils'
import type { GitLabRepoSelectorArgs } from './gitlab-repo-access'
import { assertRegisteredRepo, localGitOptionArgs, repoConnectionId } from './gitlab-repo-access'
export function registerGitLabWorkItemHandlers(store: Store): void {
// Why: combined MR + issue list — Tasks screen and any future picker
// that wants a unified view. Centralizes the merge / sort logic so
// callers don't have to re-implement it.
ipcMain.handle(
'gitlab:listWorkItems',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
query?: string
}
) => {
const repo = assertRegisteredRepo(args, store)
return listWorkItems(
repo.path,
normalizeGitLabMRListState(args.state),
normalizeGitLabPositiveInteger(args.page, 1, 10_000),
normalizeGitLabPositiveInteger(args.perPage, 20, 100),
repo.issueSourcePreference,
normalizeGitLabSearchQuery(args.query),
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
}
)
// Why: aggregated dialog payload — body + discussions + pipeline jobs.
// Powers GitLabItemDialog's tabs.
ipcMain.handle(
'gitlab:workItemDetails',
async (_event, args: GitLabRepoSelectorArgs & { iid: number; type: 'issue' | 'mr' }) => {
const repo = assertRegisteredRepo(args, store)
return getWorkItemDetails(
repo.path,
args.iid,
args.type,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
// Why: paste-URL flow in the picker. The user pastes a GitLab URL that
// may target a project different from the local checkout's remote, so
// the call carries the parsed project path explicitly rather than
// resolving from cwd.
ipcMain.handle(
'gitlab:workItemByPath',
async (
_event,
args: GitLabRepoSelectorArgs & {
host: string
path: string
iid: number
type: 'issue' | 'mr'
}
) => {
const repo = assertRegisteredRepo(args, store)
const projectRef: ProjectRef = { host: args.host, path: args.path }
const result = await getWorkItemByProjectRef(
repo.path,
projectRef,
args.iid,
args.type,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
// Why: only persist a recent entry when the lookup actually
// produced an item. A 404 / auth failure shouldn't pollute the
// user's recents list with project paths they can't read.
if (result) {
recordGitLabProjectRecent(store, args.host, args.path)
}
return result
}
)
}
+18 -567
View File
@@ -1,115 +1,24 @@
/* eslint-disable max-lines -- Why: parallel to ipc/github.ts — keeping all
GitLab IPC handlers co-located keeps the repo-path validation pattern
reviewable as one surface. */
import { ipcMain } from 'electron'
import { resolve } from 'node:path'
import { toGitLabJobLogExcerptResult } from '../../shared/gitlab-job-log-excerpt'
import type {
GitLabIssueUpdate,
GitLabMRInlineCommentInput,
GitLabMRUpdate,
GitLabWorkItem
} from '../../shared/gitlab-types'
import type { Repo } from '../../shared/repo-types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { Store } from '../persistence'
import {
normalizeGitLabIssueAssignee,
normalizeGitLabIssueListState,
normalizeGitLabMRListState,
normalizeGitLabPositiveInteger,
normalizeGitLabSearchQuery
} from '../gitlab/gitlab-preload-args'
import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents'
import {
addIssueComment,
addMRInlineComment,
addMRComment,
closeMR,
createIssue,
diagnoseAuth,
getAuthenticatedViewer,
getJobTrace,
getIssue,
getMergeRequest,
getMergeRequestForBranch,
getProjectSlug,
getRateLimit,
getWorkItemByProjectRef,
listAssignableUsers,
listIssues,
listLabels,
listMergeRequests,
listTodos,
listWorkItems,
mergeMR,
reopenMR,
resolveMRDiscussion,
retryJob,
updateIssue,
updateMR,
updateMRReviewers
listTodos
} from '../gitlab/client'
import { getWorkItemDetails } from '../gitlab/work-item-details'
import type { ProjectRef } from '../gitlab/gl-utils'
import type { LocalGitExecOptions } from '../gitlab/gitlab-project-ref-resolution'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options'
type GitLabRepoSelectorArgs = {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}
function findRegisteredGitLabRepo(args: GitLabRepoSelectorArgs, store: Store): Repo | undefined {
const sourceRepoId =
args.sourceContext?.provider === 'gitlab' ? args.sourceContext.repoId?.trim() : null
const repoId = args.repoId?.trim() || sourceRepoId || null
if (repoId) {
const repo = store.getRepo(repoId)
if (repo) {
return repo
}
}
const resolvedRepoPath = resolve(args.repoPath)
return store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath)
}
// Why: mirror github.ts assertRegisteredRepo — main-process handlers
// must never operate on a path the user hasn't explicitly registered as
// a repo (filesystem-auth boundary). Source context adds a host check so a
// task fetched from one machine cannot mutate a same-path repo on another.
function assertRegisteredRepo(args: GitLabRepoSelectorArgs, store: Store): Repo {
const repo = findRegisteredGitLabRepo(args, store)
if (!repo) {
throw new Error('Access denied: unknown repository path')
}
if (
args.sourceContext?.provider === 'gitlab' &&
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
) {
throw new Error('Access denied: GitLab source host does not match repository host')
}
return repo
}
function repoConnectionId(repo: Repo): string | null {
return repo.connectionId ?? null
}
function localGitOptionArgs(store: Store, repo: Repo): [] | [LocalGitExecOptions] {
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
return localGitOptions.wslDistro ? [{ wslDistro: localGitOptions.wslDistro }] : []
}
function hostedReviewOptionArgs(store: Store, repo: Repo): [] | [HostedReviewExecutionOptions] {
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
return localGitOptions.wslDistro
? [{ localGitExecOptions: { wslDistro: localGitOptions.wslDistro } }]
: []
}
import { registerGitLabCiJobHandlers } from './gitlab-ci-job-handlers'
import { registerGitLabIssueHandlers } from './gitlab-issue-handlers'
import { registerGitLabMergeRequestMutationHandlers } from './gitlab-merge-request-mutation-handlers'
import { registerGitLabMergeRequestQueryHandlers } from './gitlab-merge-request-query-handlers'
import type { GitLabRepoSelectorArgs } from './gitlab-repo-access'
import {
assertRegisteredRepo,
hostedReviewOptionArgs,
localGitOptionArgs,
repoConnectionId
} from './gitlab-repo-access'
import { registerGitLabWorkItemHandlers } from './gitlab-work-item-handlers'
export function registerGitLabHandlers(store: Store): void {
ipcMain.handle('gitlab:viewer', async () => {
@@ -129,434 +38,11 @@ export function registerGitLabHandlers(store: Store): void {
return getProjectSlug(repo.path, repoConnectionId(repo), ...hostedReviewOptionArgs(store, repo))
})
ipcMain.handle(
'gitlab:mrForBranch',
async (
_event,
args: GitLabRepoSelectorArgs & { branch: string; linkedMRIid?: number | null }
) => {
const repo = assertRegisteredRepo(args, store)
return getMergeRequestForBranch(
repo.path,
args.branch,
args.linkedMRIid ?? null,
repoConnectionId(repo),
...hostedReviewOptionArgs(store, repo)
)
}
)
ipcMain.handle('gitlab:mr', async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return getMergeRequest(
repo.path,
args.iid,
repoConnectionId(repo),
...hostedReviewOptionArgs(store, repo)
)
})
ipcMain.handle(
'gitlab:listMRs',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
query?: string
}
) => {
const repo = assertRegisteredRepo(args, store)
const state = normalizeGitLabMRListState(args.state)
const page = normalizeGitLabPositiveInteger(args.page, 1, 10_000)
const perPage = normalizeGitLabPositiveInteger(args.perPage, 20, 100)
return listMergeRequests(
repo.path,
state,
page,
perPage,
repo.issueSourcePreference,
normalizeGitLabSearchQuery(args.query),
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:issue',
async (_event, args: GitLabRepoSelectorArgs & { number: number }) => {
const repo = assertRegisteredRepo(args, store)
return getIssue(
repo.path,
args.number,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:listIssues',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'closed' | 'all'
assignee?: string
limit?: number
}
) => {
const repo = assertRegisteredRepo(args, store)
const limit = normalizeGitLabPositiveInteger(args.limit, 20, 100)
const state = normalizeGitLabIssueListState(args.state)
const assignee = normalizeGitLabIssueAssignee(args.assignee)
const result = await listIssues(
repo.path,
limit,
repo.issueSourcePreference,
state,
assignee,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
// Why: Tasks page expects GitLabWorkItem[] so it can share row
// rendering with MRs. Map IssueInfo → WorkItem here so the renderer
// doesn't need a separate code path.
const workItems: GitLabWorkItem[] = result.items.map((issue) => ({
id: `gitlab-issue-${repo.id}-${issue.number}`,
type: 'issue' as const,
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.url,
labels: issue.labels,
updatedAt: issue.updatedAt ?? '',
author: issue.author ?? null,
repoId: repo.id
}))
return { items: workItems, ...(result.error ? { error: result.error } : {}) }
}
)
ipcMain.handle(
'gitlab:createIssue',
async (_event, args: GitLabRepoSelectorArgs & { title: string; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return createIssue(
repo.path,
args.title,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:updateIssue',
async (
_event,
args: GitLabRepoSelectorArgs & { number: number; updates: GitLabIssueUpdate }
) => {
const repo = assertRegisteredRepo(args, store)
return updateIssue(
repo.path,
args.number,
args.updates,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:addIssueComment',
async (_event, args: GitLabRepoSelectorArgs & { number: number; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return addIssueComment(
repo.path,
args.number,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle('gitlab:listLabels', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
return listLabels(
repo.path,
repo.issueSourcePreference,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
})
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
return listAssignableUsers(
repo.path,
repo.issueSourcePreference,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
})
// Why: combined MR + issue list — Tasks screen and any future picker
// that wants a unified view. Centralizes the merge / sort logic so
// callers don't have to re-implement it.
ipcMain.handle(
'gitlab:listWorkItems',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
query?: string
}
) => {
const repo = assertRegisteredRepo(args, store)
return listWorkItems(
repo.path,
normalizeGitLabMRListState(args.state),
normalizeGitLabPositiveInteger(args.page, 1, 10_000),
normalizeGitLabPositiveInteger(args.perPage, 20, 100),
repo.issueSourcePreference,
normalizeGitLabSearchQuery(args.query),
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
}
)
// Why: aggregated dialog payload — body + discussions + pipeline jobs.
// Powers GitLabItemDialog's tabs.
ipcMain.handle(
'gitlab:workItemDetails',
async (_event, args: GitLabRepoSelectorArgs & { iid: number; type: 'issue' | 'mr' }) => {
const repo = assertRegisteredRepo(args, store)
return getWorkItemDetails(
repo.path,
args.iid,
args.type,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:closeMR',
async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return closeMR(
repo.path,
args.iid,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:reopenMR',
async (_event, args: GitLabRepoSelectorArgs & { iid: number }) => {
const repo = assertRegisteredRepo(args, store)
return reopenMR(
repo.path,
args.iid,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:mergeMR',
async (
_event,
args: GitLabRepoSelectorArgs & { iid: number; method?: 'merge' | 'squash' | 'rebase' }
) => {
const repo = assertRegisteredRepo(args, store)
return mergeMR(
repo.path,
args.iid,
args.method ?? 'merge',
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:updateMR',
async (_event, args: GitLabRepoSelectorArgs & { iid: number; updates: GitLabMRUpdate }) => {
const repo = assertRegisteredRepo(args, store)
return updateMR(
repo.path,
args.iid,
args.updates,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:updateMRReviewers',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
iid: number
reviewerIds: number[]
projectRef?: ProjectRef | null
}
) => {
const repo = assertRegisteredRepo(args, store)
return updateMRReviewers(
repo.path,
args.iid,
args.reviewerIds,
repo.issueSourcePreference,
repoConnectionId(repo),
args.projectRef,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:addMRComment',
async (_event, args: GitLabRepoSelectorArgs & { iid: number; body: string }) => {
const repo = assertRegisteredRepo(args, store)
return addMRComment(
repo.path,
args.iid,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:addMRInlineComment',
async (
_event,
args: {
repoPath: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
iid: number
input: GitLabMRInlineCommentInput
projectRef?: ProjectRef | null
}
) => {
const repo = assertRegisteredRepo(args, store)
return addMRInlineComment(
repo.path,
args.iid,
args.input,
repo.issueSourcePreference,
repoConnectionId(repo),
args.projectRef,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:resolveMRDiscussion',
async (
_event,
args: GitLabRepoSelectorArgs & { iid: number; discussionId: string; resolved: boolean }
) => {
const repo = assertRegisteredRepo(args, store)
return resolveMRDiscussion(
repo.path,
args.iid,
args.discussionId,
args.resolved,
repo.issueSourcePreference,
repoConnectionId(repo),
undefined,
...localGitOptionArgs(store, repo)
)
}
)
ipcMain.handle(
'gitlab:jobTrace',
async (
_event,
args: GitLabRepoSelectorArgs & {
jobId: number
projectRef?: ProjectRef | null
logExcerpt?: boolean
}
) => {
const repo = assertRegisteredRepo(args, store)
const result = await getJobTrace(
repo.path,
args.jobId,
repo.issueSourcePreference,
repoConnectionId(repo),
args.projectRef,
...localGitOptionArgs(store, repo)
)
return args.logExcerpt ? toGitLabJobLogExcerptResult(result) : result
}
)
ipcMain.handle(
'gitlab:retryJob',
async (
_event,
args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: ProjectRef | null }
) => {
const repo = assertRegisteredRepo(args, store)
return retryJob(
repo.path,
args.jobId,
repo.issueSourcePreference,
repoConnectionId(repo),
args.projectRef,
...localGitOptionArgs(store, repo)
)
}
)
registerGitLabMergeRequestQueryHandlers(store)
registerGitLabIssueHandlers(store)
registerGitLabWorkItemHandlers(store)
registerGitLabMergeRequestMutationHandlers(store)
registerGitLabCiJobHandlers(store)
// Why: My Todos surface — cross-project, user-scoped. The repoPath is
// only used for the registered-repo guard; `glab api todos` doesn't
@@ -565,39 +51,4 @@ export function registerGitLabHandlers(store: Store): void {
const repo = assertRegisteredRepo(args, store)
return listTodos(repo.path, repoConnectionId(repo), ...localGitOptionArgs(store, repo))
})
// Why: paste-URL flow in the picker. The user pastes a GitLab URL that
// may target a project different from the local checkout's remote, so
// the call carries the parsed project path explicitly rather than
// resolving from cwd.
ipcMain.handle(
'gitlab:workItemByPath',
async (
_event,
args: GitLabRepoSelectorArgs & {
host: string
path: string
iid: number
type: 'issue' | 'mr'
}
) => {
const repo = assertRegisteredRepo(args, store)
const projectRef: ProjectRef = { host: args.host, path: args.path }
const result = await getWorkItemByProjectRef(
repo.path,
projectRef,
args.iid,
args.type,
repoConnectionId(repo),
...localGitOptionArgs(store, repo)
)
// Why: only persist a recent entry when the lookup actually
// produced an item. A 404 / auth failure shouldn't pollute the
// user's recents list with project paths they can't read.
if (result) {
recordGitLabProjectRecent(store, args.host, args.path)
}
return result
}
)
}
@@ -0,0 +1,99 @@
import { ipcMain } from 'electron'
import {
getCustomView,
listCustomViewIssues,
listCustomViewProjects,
listCustomViews
} from '../linear/projects'
import {
normalizeConcreteWorkspaceId,
normalizeCustomViewModel,
normalizeWorkspaceSelection
} from './linear-ipc-args'
import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits'
import type { LinearCustomViewModel } from '../../shared/linear/project-types'
import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
export function registerLinearCustomViewHandlers(): void {
ipcMain.handle(
'linear:listCustomViews',
async (
_event,
args?: {
model?: LinearCustomViewModel
limit?: number
workspaceId?: LinearWorkspaceSelection
force?: boolean
}
) => {
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listCustomViews(
normalizeCustomViewModel(args?.model),
limit,
normalizeWorkspaceSelection(args?.workspaceId),
args?.force === true
)
}
)
ipcMain.handle(
'linear:getCustomView',
async (
_event,
args: {
viewId: string
model?: LinearCustomViewModel
workspaceId?: string
force?: boolean
}
) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
return getCustomView(
args.viewId.trim(),
normalizeCustomViewModel(args.model),
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
ipcMain.handle(
'linear:listCustomViewIssues',
async (
_event,
args: { viewId: string; limit?: number; workspaceId?: string; force?: boolean }
) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
const limit = clampLinearIssueListLimit(args?.limit)
return listCustomViewIssues(
args.viewId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
ipcMain.handle(
'linear:listCustomViewProjects',
async (
_event,
args: { viewId: string; limit?: number; workspaceId?: string; force?: boolean }
) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listCustomViewProjects(
args.viewId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
}
+49
View File
@@ -0,0 +1,49 @@
import type { LinearCustomViewModel } from '../../shared/linear/project-types'
import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
export function normalizeWorkspaceId(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
export function normalizeWorkspaceSelection(value: unknown): LinearWorkspaceSelection | undefined {
const workspaceId = normalizeWorkspaceId(value)
return workspaceId as LinearWorkspaceSelection | undefined
}
export function normalizeConcreteWorkspaceId(value: unknown): string {
const workspaceId = normalizeWorkspaceId(value)
if (!workspaceId || workspaceId === 'all') {
throw new Error('Concrete Linear workspace ID is required')
}
return workspaceId
}
export function normalizeCustomViewModel(value: unknown): LinearCustomViewModel {
if (value !== 'issue' && value !== 'project') {
throw new Error('Custom view model is required')
}
return value
}
export function normalizeIdList(value: unknown, fieldName: string): string[] | undefined {
if (value === undefined) {
return undefined
}
if (
!Array.isArray(value) ||
!value.every((id): id is string => typeof id === 'string' && Boolean(id.trim()))
) {
throw new Error(`Invalid ${fieldName}`)
}
return value.map((id) => id.trim())
}
export function normalizeOptionalDate(value: unknown, fieldName: string): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined
}
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
throw new Error(`Invalid ${fieldName}`)
}
return value.trim()
}
+200
View File
@@ -0,0 +1,200 @@
import { ipcMain } from 'electron'
import {
getIssue,
searchIssues,
listIssues,
createIssue,
updateIssue,
addIssueComment,
getIssueComments
} from '../linear/issues'
import { normalizeWorkspaceId, normalizeWorkspaceSelection } from './linear-ipc-args'
import type { LinearListFilter } from '../linear/issues'
import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits'
import { optionalParsedLinearIssueAttributeFilter } from '../../shared/linear/issue-attribute-filter'
import type { LinearIssueUpdate } from '../../shared/issue-mutation-types'
import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
const VALID_FILTERS = new Set<LinearListFilter>(['assigned', 'created', 'all', 'completed'])
export function registerLinearIssueHandlers(): void {
ipcMain.handle(
'linear:searchIssues',
async (
_event,
args: { query: string; limit?: number; workspaceId?: LinearWorkspaceSelection }
) => {
if (typeof args?.query !== 'string') {
return []
}
const limit = Math.min(Math.max(1, args.limit ?? 20), 50)
return searchIssues(args.query, limit, normalizeWorkspaceSelection(args.workspaceId))
}
)
ipcMain.handle(
'linear:listIssues',
async (
_event,
args?: {
filter?: LinearListFilter
limit?: number
workspaceId?: LinearWorkspaceSelection
attributeFilter?: unknown
}
) => {
const filter = VALID_FILTERS.has(args?.filter as LinearListFilter)
? (args!.filter as LinearListFilter)
: undefined
const limit = clampLinearIssueListLimit(args?.limit)
// Why: reject malformed filters at the trust boundary instead of
// normalizing them to empty (which would silently broaden results).
const attributeFilter =
args && 'attributeFilter' in args && args.attributeFilter !== undefined
? optionalParsedLinearIssueAttributeFilter(args.attributeFilter)
: undefined
return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId), {
attributeFilter
})
}
)
ipcMain.handle(
'linear:createIssue',
async (
_event,
args: {
teamId: string
title: string
description?: string
workspaceId?: string
parentIssueId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return { ok: false, error: 'Team ID is required' }
}
if (typeof args?.title !== 'string' || !args.title.trim()) {
return { ok: false, error: 'Title is required' }
}
if (
args.priority !== undefined &&
(!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4)
) {
return { ok: false, error: 'Invalid priority' }
}
if (
args.labelIds !== undefined &&
(!Array.isArray(args.labelIds) ||
!args.labelIds.every((id) => typeof id === 'string' && id.trim()))
) {
return { ok: false, error: 'Invalid label IDs' }
}
return createIssue(
args.teamId.trim(),
args.title.trim(),
args.description?.trim() || undefined,
normalizeWorkspaceId(args.workspaceId),
{
parentId: typeof args.parentIssueId === 'string' ? args.parentIssueId.trim() : undefined,
projectId: typeof args.projectId === 'string' ? args.projectId.trim() : null,
stateId: typeof args.stateId === 'string' ? args.stateId.trim() : undefined,
priority: typeof args.priority === 'number' ? args.priority : undefined,
assigneeId: typeof args.assigneeId === 'string' ? args.assigneeId.trim() : null,
labelIds: Array.isArray(args.labelIds) ? args.labelIds.map((id) => id.trim()) : undefined
}
)
}
)
ipcMain.handle('linear:getIssue', async (_event, args: { id: string; workspaceId?: string }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
return null
}
return getIssue(args.id.trim(), normalizeWorkspaceId(args.workspaceId))
})
ipcMain.handle(
'linear:updateIssue',
async (_event, args: { id: string; updates: LinearIssueUpdate; workspaceId?: string }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
return { ok: false, error: 'Issue ID is required' }
}
// Why: IPC args are untyped at runtime — validate the updates object and
// individual fields to prevent the Linear SDK from receiving unexpected
// primitives that would produce confusing API errors.
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: 'Updates object is required' }
}
const u = args.updates
if (u.stateId !== undefined && (typeof u.stateId !== 'string' || !u.stateId.trim())) {
return { ok: false, error: 'Invalid state ID' }
}
if (u.title !== undefined && (typeof u.title !== 'string' || !u.title.trim())) {
return { ok: false, error: 'Title is required' }
}
if (u.description !== undefined && typeof u.description !== 'string') {
return { ok: false, error: 'Description must be a string' }
}
if (
u.priority !== undefined &&
(!Number.isInteger(u.priority) || u.priority < 0 || u.priority > 4)
) {
return { ok: false, error: 'Priority must be an integer 0-4' }
}
if (
u.estimate !== undefined &&
u.estimate !== null &&
(!Number.isInteger(u.estimate) || u.estimate < 0)
) {
return { ok: false, error: 'Estimate must be a non-negative integer' }
}
if (
u.labelIds !== undefined &&
(!Array.isArray(u.labelIds) || !u.labelIds.every((id: unknown) => typeof id === 'string'))
) {
return { ok: false, error: 'Label IDs must be an array of strings' }
}
if (
u.projectId !== undefined &&
u.projectId !== null &&
(typeof u.projectId !== 'string' || !u.projectId.trim())
) {
return { ok: false, error: 'Invalid project ID' }
}
return updateIssue(args.id.trim(), args.updates, normalizeWorkspaceId(args.workspaceId))
}
)
ipcMain.handle(
'linear:addIssueComment',
async (_event, args: { issueId: string; body: string; workspaceId?: string }) => {
if (typeof args?.issueId !== 'string' || !args.issueId.trim()) {
return { ok: false, error: 'Issue ID is required' }
}
if (!args.body?.trim()) {
return { ok: false, error: 'Comment body is required' }
}
return addIssueComment(
args.issueId.trim(),
args.body.trim(),
normalizeWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:issueComments',
async (_event, args: { issueId: string; workspaceId?: string }) => {
if (typeof args?.issueId !== 'string' || !args.issueId.trim()) {
return []
}
return getIssueComments(args.issueId.trim(), normalizeWorkspaceId(args.workspaceId))
}
)
}
+133
View File
@@ -0,0 +1,133 @@
import { ipcMain } from 'electron'
import { createProject, getProject, listProjectIssues, listProjects } from '../linear/projects'
import {
normalizeConcreteWorkspaceId,
normalizeIdList,
normalizeOptionalDate,
normalizeWorkspaceId,
normalizeWorkspaceSelection
} from './linear-ipc-args'
import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits'
import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
export function registerLinearProjectHandlers(): void {
ipcMain.handle(
'linear:listProjects',
async (
_event,
args?: {
query?: string
limit?: number
workspaceId?: LinearWorkspaceSelection
force?: boolean
}
) => {
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listProjects(
args?.query,
limit,
normalizeWorkspaceSelection(args?.workspaceId),
args?.force === true
)
}
)
ipcMain.handle(
'linear:createProject',
async (
_event,
args: {
name: string
description?: string
content?: string
teamIds?: string[]
leadId?: string | null
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
workspaceId?: string
}
) => {
if (typeof args?.name !== 'string' || !args.name.trim()) {
return { ok: false, error: 'Project name is required' }
}
let teamIds: string[]
try {
teamIds = normalizeIdList(args.teamIds, 'team IDs') ?? []
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : 'Invalid team IDs' }
}
if (teamIds.length === 0) {
return { ok: false, error: 'At least one team is required' }
}
if (
args.priority !== undefined &&
(!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4)
) {
return { ok: false, error: 'Invalid priority' }
}
let memberIds: string[] | undefined
let labelIds: string[] | undefined
let startDate: string | undefined
let targetDate: string | undefined
try {
memberIds = normalizeIdList(args.memberIds, 'member IDs')
labelIds = normalizeIdList(args.labelIds, 'label IDs')
startDate = normalizeOptionalDate(args.startDate, 'start date')
targetDate = normalizeOptionalDate(args.targetDate, 'target date')
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : 'Invalid project' }
}
return createProject(
{
name: args.name.trim(),
description: args.description?.trim() || undefined,
content: args.content?.trim() || undefined,
teamIds,
leadId: normalizeWorkspaceId(args.leadId),
memberIds,
labelIds,
priority: typeof args.priority === 'number' ? args.priority : undefined,
startDate,
targetDate
},
normalizeWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:getProject',
async (_event, args: { id: string; workspaceId?: string; force?: boolean }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
throw new Error('Project ID is required')
}
return getProject(
args.id.trim(),
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
ipcMain.handle(
'linear:listProjectIssues',
async (
_event,
args: { projectId: string; limit?: number; workspaceId?: string; force?: boolean }
) => {
if (typeof args?.projectId !== 'string' || !args.projectId.trim()) {
throw new Error('Project ID is required')
}
const limit = clampLinearIssueListLimit(args?.limit)
return listProjectIssues(
args.projectId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
}
+43
View File
@@ -0,0 +1,43 @@
import { ipcMain } from 'electron'
import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams'
import { normalizeWorkspaceId, normalizeWorkspaceSelection } from './linear-ipc-args'
import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
export function registerLinearTeamHandlers(): void {
ipcMain.handle(
'linear:listTeams',
async (_event, args?: { workspaceId?: LinearWorkspaceSelection }) => {
return listTeams(normalizeWorkspaceSelection(args?.workspaceId))
}
)
ipcMain.handle(
'linear:teamStates',
async (_event, args: { teamId: string; workspaceId?: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamStates(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId))
}
)
ipcMain.handle(
'linear:teamLabels',
async (_event, args: { teamId: string; workspaceId?: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamLabels(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId))
}
)
ipcMain.handle(
'linear:teamMembers',
async (_event, args: { teamId: string; workspaceId?: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamMembers(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId))
}
)
}
+9 -495
View File
@@ -1,83 +1,11 @@
/* eslint-disable max-lines -- Why: Linear IPC validates one namespace in one
registration boundary so local and SSH runtime schemas can stay mirrored. */
import { ipcMain } from 'electron'
import { connect, disconnect, getStatus, selectWorkspace, testConnection } from '../linear/client'
import { _resetPreflightCache } from './preflight'
import {
getIssue,
searchIssues,
listIssues,
createIssue,
updateIssue,
addIssueComment,
getIssueComments
} from '../linear/issues'
import {
createProject,
getCustomView,
getProject,
listCustomViewIssues,
listCustomViewProjects,
listCustomViews,
listProjectIssues,
listProjects
} from '../linear/projects'
import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams'
import type { LinearListFilter } from '../linear/issues'
import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits'
import { optionalParsedLinearIssueAttributeFilter } from '../../shared/linear/issue-attribute-filter'
import type { LinearIssueUpdate } from '../../shared/issue-mutation-types'
import type { LinearCustomViewModel } from '../../shared/linear/project-types'
import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
const VALID_FILTERS = new Set<LinearListFilter>(['assigned', 'created', 'all', 'completed'])
function normalizeWorkspaceId(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function normalizeWorkspaceSelection(value: unknown): LinearWorkspaceSelection | undefined {
const workspaceId = normalizeWorkspaceId(value)
return workspaceId as LinearWorkspaceSelection | undefined
}
function normalizeConcreteWorkspaceId(value: unknown): string {
const workspaceId = normalizeWorkspaceId(value)
if (!workspaceId || workspaceId === 'all') {
throw new Error('Concrete Linear workspace ID is required')
}
return workspaceId
}
function normalizeCustomViewModel(value: unknown): LinearCustomViewModel {
if (value !== 'issue' && value !== 'project') {
throw new Error('Custom view model is required')
}
return value
}
function normalizeIdList(value: unknown, fieldName: string): string[] | undefined {
if (value === undefined) {
return undefined
}
if (
!Array.isArray(value) ||
!value.every((id): id is string => typeof id === 'string' && Boolean(id.trim()))
) {
throw new Error(`Invalid ${fieldName}`)
}
return value.map((id) => id.trim())
}
function normalizeOptionalDate(value: unknown, fieldName: string): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined
}
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
throw new Error(`Invalid ${fieldName}`)
}
return value.trim()
}
import { normalizeWorkspaceId, normalizeWorkspaceSelection } from './linear-ipc-args'
import { registerLinearIssueHandlers } from './linear-issue-handlers'
import { registerLinearProjectHandlers } from './linear-project-handlers'
import { registerLinearCustomViewHandlers } from './linear-custom-view-handlers'
import { registerLinearTeamHandlers } from './linear-team-handlers'
export function registerLinearHandlers(): void {
ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => {
@@ -112,422 +40,8 @@ export function registerLinearHandlers(): void {
return testConnection(normalizeWorkspaceId(args?.workspaceId))
})
ipcMain.handle(
'linear:searchIssues',
async (
_event,
args: { query: string; limit?: number; workspaceId?: LinearWorkspaceSelection }
) => {
if (typeof args?.query !== 'string') {
return []
}
const limit = Math.min(Math.max(1, args.limit ?? 20), 50)
return searchIssues(args.query, limit, normalizeWorkspaceSelection(args.workspaceId))
}
)
ipcMain.handle(
'linear:listIssues',
async (
_event,
args?: {
filter?: LinearListFilter
limit?: number
workspaceId?: LinearWorkspaceSelection
attributeFilter?: unknown
}
) => {
const filter = VALID_FILTERS.has(args?.filter as LinearListFilter)
? (args!.filter as LinearListFilter)
: undefined
const limit = clampLinearIssueListLimit(args?.limit)
// Why: reject malformed filters at the trust boundary instead of
// normalizing them to empty (which would silently broaden results).
const attributeFilter =
args && 'attributeFilter' in args && args.attributeFilter !== undefined
? optionalParsedLinearIssueAttributeFilter(args.attributeFilter)
: undefined
return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId), {
attributeFilter
})
}
)
ipcMain.handle(
'linear:createIssue',
async (
_event,
args: {
teamId: string
title: string
description?: string
workspaceId?: string
parentIssueId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return { ok: false, error: 'Team ID is required' }
}
if (typeof args?.title !== 'string' || !args.title.trim()) {
return { ok: false, error: 'Title is required' }
}
if (
args.priority !== undefined &&
(!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4)
) {
return { ok: false, error: 'Invalid priority' }
}
if (
args.labelIds !== undefined &&
(!Array.isArray(args.labelIds) ||
!args.labelIds.every((id) => typeof id === 'string' && id.trim()))
) {
return { ok: false, error: 'Invalid label IDs' }
}
return createIssue(
args.teamId.trim(),
args.title.trim(),
args.description?.trim() || undefined,
normalizeWorkspaceId(args.workspaceId),
{
parentId: typeof args.parentIssueId === 'string' ? args.parentIssueId.trim() : undefined,
projectId: typeof args.projectId === 'string' ? args.projectId.trim() : null,
stateId: typeof args.stateId === 'string' ? args.stateId.trim() : undefined,
priority: typeof args.priority === 'number' ? args.priority : undefined,
assigneeId: typeof args.assigneeId === 'string' ? args.assigneeId.trim() : null,
labelIds: Array.isArray(args.labelIds) ? args.labelIds.map((id) => id.trim()) : undefined
}
)
}
)
ipcMain.handle('linear:getIssue', async (_event, args: { id: string; workspaceId?: string }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
return null
}
return getIssue(args.id.trim(), normalizeWorkspaceId(args.workspaceId))
})
ipcMain.handle(
'linear:updateIssue',
async (_event, args: { id: string; updates: LinearIssueUpdate; workspaceId?: string }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
return { ok: false, error: 'Issue ID is required' }
}
// Why: IPC args are untyped at runtime — validate the updates object and
// individual fields to prevent the Linear SDK from receiving unexpected
// primitives that would produce confusing API errors.
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: 'Updates object is required' }
}
const u = args.updates
if (u.stateId !== undefined && (typeof u.stateId !== 'string' || !u.stateId.trim())) {
return { ok: false, error: 'Invalid state ID' }
}
if (u.title !== undefined && (typeof u.title !== 'string' || !u.title.trim())) {
return { ok: false, error: 'Title is required' }
}
if (u.description !== undefined && typeof u.description !== 'string') {
return { ok: false, error: 'Description must be a string' }
}
if (
u.priority !== undefined &&
(!Number.isInteger(u.priority) || u.priority < 0 || u.priority > 4)
) {
return { ok: false, error: 'Priority must be an integer 0-4' }
}
if (
u.estimate !== undefined &&
u.estimate !== null &&
(!Number.isInteger(u.estimate) || u.estimate < 0)
) {
return { ok: false, error: 'Estimate must be a non-negative integer' }
}
if (
u.labelIds !== undefined &&
(!Array.isArray(u.labelIds) || !u.labelIds.every((id: unknown) => typeof id === 'string'))
) {
return { ok: false, error: 'Label IDs must be an array of strings' }
}
if (
u.projectId !== undefined &&
u.projectId !== null &&
(typeof u.projectId !== 'string' || !u.projectId.trim())
) {
return { ok: false, error: 'Invalid project ID' }
}
return updateIssue(args.id.trim(), args.updates, normalizeWorkspaceId(args.workspaceId))
}
)
ipcMain.handle(
'linear:addIssueComment',
async (_event, args: { issueId: string; body: string; workspaceId?: string }) => {
if (typeof args?.issueId !== 'string' || !args.issueId.trim()) {
return { ok: false, error: 'Issue ID is required' }
}
if (!args.body?.trim()) {
return { ok: false, error: 'Comment body is required' }
}
return addIssueComment(
args.issueId.trim(),
args.body.trim(),
normalizeWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:issueComments',
async (_event, args: { issueId: string; workspaceId?: string }) => {
if (typeof args?.issueId !== 'string' || !args.issueId.trim()) {
return []
}
return getIssueComments(args.issueId.trim(), normalizeWorkspaceId(args.workspaceId))
}
)
ipcMain.handle(
'linear:listTeams',
async (_event, args?: { workspaceId?: LinearWorkspaceSelection }) => {
return listTeams(normalizeWorkspaceSelection(args?.workspaceId))
}
)
ipcMain.handle(
'linear:listProjects',
async (
_event,
args?: {
query?: string
limit?: number
workspaceId?: LinearWorkspaceSelection
force?: boolean
}
) => {
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listProjects(
args?.query,
limit,
normalizeWorkspaceSelection(args?.workspaceId),
args?.force === true
)
}
)
ipcMain.handle(
'linear:createProject',
async (
_event,
args: {
name: string
description?: string
content?: string
teamIds?: string[]
leadId?: string | null
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
workspaceId?: string
}
) => {
if (typeof args?.name !== 'string' || !args.name.trim()) {
return { ok: false, error: 'Project name is required' }
}
let teamIds: string[]
try {
teamIds = normalizeIdList(args.teamIds, 'team IDs') ?? []
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : 'Invalid team IDs' }
}
if (teamIds.length === 0) {
return { ok: false, error: 'At least one team is required' }
}
if (
args.priority !== undefined &&
(!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4)
) {
return { ok: false, error: 'Invalid priority' }
}
let memberIds: string[] | undefined
let labelIds: string[] | undefined
let startDate: string | undefined
let targetDate: string | undefined
try {
memberIds = normalizeIdList(args.memberIds, 'member IDs')
labelIds = normalizeIdList(args.labelIds, 'label IDs')
startDate = normalizeOptionalDate(args.startDate, 'start date')
targetDate = normalizeOptionalDate(args.targetDate, 'target date')
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : 'Invalid project' }
}
return createProject(
{
name: args.name.trim(),
description: args.description?.trim() || undefined,
content: args.content?.trim() || undefined,
teamIds,
leadId: normalizeWorkspaceId(args.leadId),
memberIds,
labelIds,
priority: typeof args.priority === 'number' ? args.priority : undefined,
startDate,
targetDate
},
normalizeWorkspaceId(args.workspaceId)
)
}
)
ipcMain.handle(
'linear:getProject',
async (_event, args: { id: string; workspaceId?: string; force?: boolean }) => {
if (typeof args?.id !== 'string' || !args.id.trim()) {
throw new Error('Project ID is required')
}
return getProject(
args.id.trim(),
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
ipcMain.handle(
'linear:listProjectIssues',
async (
_event,
args: { projectId: string; limit?: number; workspaceId?: string; force?: boolean }
) => {
if (typeof args?.projectId !== 'string' || !args.projectId.trim()) {
throw new Error('Project ID is required')
}
const limit = clampLinearIssueListLimit(args?.limit)
return listProjectIssues(
args.projectId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
ipcMain.handle(
'linear:listCustomViews',
async (
_event,
args?: {
model?: LinearCustomViewModel
limit?: number
workspaceId?: LinearWorkspaceSelection
force?: boolean
}
) => {
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listCustomViews(
normalizeCustomViewModel(args?.model),
limit,
normalizeWorkspaceSelection(args?.workspaceId),
args?.force === true
)
}
)
ipcMain.handle(
'linear:getCustomView',
async (
_event,
args: {
viewId: string
model?: LinearCustomViewModel
workspaceId?: string
force?: boolean
}
) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
return getCustomView(
args.viewId.trim(),
normalizeCustomViewModel(args.model),
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
ipcMain.handle(
'linear:listCustomViewIssues',
async (
_event,
args: { viewId: string; limit?: number; workspaceId?: string; force?: boolean }
) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
const limit = clampLinearIssueListLimit(args?.limit)
return listCustomViewIssues(
args.viewId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
ipcMain.handle(
'linear:listCustomViewProjects',
async (
_event,
args: { viewId: string; limit?: number; workspaceId?: string; force?: boolean }
) => {
if (typeof args?.viewId !== 'string' || !args.viewId.trim()) {
throw new Error('Custom view ID is required')
}
const limit = Math.min(Math.max(1, args?.limit ?? 20), 50)
return listCustomViewProjects(
args.viewId.trim(),
limit,
normalizeConcreteWorkspaceId(args.workspaceId),
args.force === true
)
}
)
ipcMain.handle(
'linear:teamStates',
async (_event, args: { teamId: string; workspaceId?: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamStates(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId))
}
)
ipcMain.handle(
'linear:teamLabels',
async (_event, args: { teamId: string; workspaceId?: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamLabels(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId))
}
)
ipcMain.handle(
'linear:teamMembers',
async (_event, args: { teamId: string; workspaceId?: string }) => {
if (typeof args?.teamId !== 'string' || !args.teamId.trim()) {
return []
}
return getTeamMembers(args.teamId.trim(), normalizeWorkspaceId(args.workspaceId))
}
)
registerLinearIssueHandlers()
registerLinearProjectHandlers()
registerLinearCustomViewHandlers()
registerLinearTeamHandlers()
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './client'
import type { JiraClientForSite } from './authenticated-request'
const { jiraRequestBinaryMock } = vi.hoisted(() => ({
jiraRequestBinaryMock: vi.fn()
}))
vi.mock('./client', () => ({
vi.mock('./authenticated-request', () => ({
jiraRequestBinary: (...args: unknown[]) => jiraRequestBinaryMock(...args),
apiBasePath: (site: { authType?: string }) =>
site.authType === 'server' ? '/rest/api/2' : '/rest/api/3',
+2 -2
View File
@@ -1,5 +1,5 @@
import type { JiraClientForSite } from './client'
import { JiraApiError, apiBasePath, jiraRequestBinary } from './client'
import type { JiraClientForSite } from './authenticated-request'
import { JiraApiError, apiBasePath, jiraRequestBinary } from './authenticated-request'
import type { JiraAdfMediaAttrs, JiraAdfMediaResolver } from './adf-markdown'
import { escapeMarkdownAlt, unresolvedMediaPlaceholder } from './adf-markdown'
import { escapeMarkdownLinkDestination } from './adf-media-destination'
+192
View File
@@ -0,0 +1,192 @@
import { net, session } from 'electron'
import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
import { withSpan } from '../observability/tracer'
import type { JiraAuthType, JiraSite } from '../../shared/jira-types'
// Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser
// User-Agent, failing them with "XSRF check failed" even under API-token auth.
// Electron's net.fetch sends a Chrome UA, so issue search/create/update/comment
// all 403'd while GET calls (connect, /myself) passed. A non-browser UA is the
// reliable fix; X-Atlassian-Token: no-check is not honored for this case.
const JIRA_API_USER_AGENT = 'Orca'
export type JiraClientForSite = {
site: JiraSite
authorization: string
}
// Self-hosted Jira Server/Data Center only exposes REST v2; Cloud endpoints
// in this codebase are written against v3. Callers build paths with this
// prefix so one code path serves both deployments.
export function apiBasePath(site: JiraSite): string {
return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3'
}
export class JiraApiError extends Error {
status: number | null
constructor(message: string, status: number | null = null) {
super(message)
this.status = status
}
}
export function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string {
// Self-hosted with no username = a personal access token (Bearer); Basic auth
// with a PAT in the password slot is what produces the 401s users report.
// Self-hosted WITH a username is classic username+password Basic auth, which
// older Server/DC instances (predating PATs) require. Cloud is always Basic.
if (authType === 'server' && !email) {
return `Bearer ${apiToken}`
}
return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`
}
function describeErrorCause(error: unknown): string | undefined {
if (!error || typeof error !== 'object' || !('cause' in error)) {
return undefined
}
const cause = (error as { cause?: unknown }).cause
if (cause instanceof Error) {
return `${cause.name}: ${cause.message}`
}
return cause === undefined ? undefined : String(cause)
}
async function jiraFetch(url: string, init: RequestInit): Promise<Response> {
return withSpan(
'jira.request',
async (span) => {
span.setAttribute('jira.siteUrl', new URL(url).origin)
await ensureElectronProxyFromEnvironment({
proxySession: session.defaultSession,
probeUrl: url
}).catch((error) => {
span.addEvent('jira.proxySetupFailed', {
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error)
})
})
try {
// Why: Electron's network stack follows Chromium proxy/session state,
// avoiding undici's stale keep-alive sockets after VPN path changes.
return await net.fetch(url, init)
} catch (error) {
span.setAttribute(
'jira.transportErrorName',
error instanceof Error ? error.name : typeof error
)
span.setAttribute(
'jira.transportErrorMessage',
error instanceof Error ? error.message : String(error)
)
const cause = describeErrorCause(error)
if (cause) {
span.setAttribute('jira.transportErrorCause', cause)
}
throw error
}
},
{ kind: 'client' }
)
}
export async function requestWithCredentials(
siteUrl: string,
email: string,
apiToken: string,
path: string,
init?: RequestInit,
authType?: JiraAuthType
): Promise<unknown> {
const headers = new Headers(init?.headers)
headers.set('Accept', 'application/json')
headers.set('Content-Type', 'application/json')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', authHeader(email, apiToken, authType))
const response = await jiraFetch(`${siteUrl}${path}`, {
...init,
headers
})
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
}
if (response.status === 204) {
return null
}
return response.json()
}
async function readJiraError(response: Response): Promise<string> {
try {
const data = (await response.json()) as {
errorMessages?: string[]
errors?: Record<string, string>
message?: string
}
const messages = [
...(Array.isArray(data.errorMessages) ? data.errorMessages : []),
...Object.values(data.errors ?? {}),
...(data.message ? [data.message] : [])
].filter(Boolean)
if (messages.length > 0) {
return messages.join('; ')
}
} catch {
// Fall through to status text.
}
return response.statusText || `Jira request failed (${response.status})`
}
export async function jiraRequest<T>(
client: JiraClientForSite,
path: string,
init?: RequestInit
): Promise<T> {
const headers = new Headers(init?.headers)
headers.set('Accept', 'application/json')
headers.set('Content-Type', 'application/json')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', client.authorization)
const response = await jiraFetch(`${client.site.siteUrl}${path}`, {
...init,
headers
})
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
}
if (response.status === 204) {
return null as T
}
return (await response.json()) as T
}
export async function jiraRequestBinary(
client: JiraClientForSite,
pathOrUrl: string
): Promise<{ data: ArrayBuffer; contentType: string }> {
const siteUrl = new URL(client.site.siteUrl)
const requestUrl = /^https?:\/\//i.test(pathOrUrl)
? new URL(pathOrUrl)
: new URL(`${client.site.siteUrl}${pathOrUrl}`)
if (requestUrl.origin !== siteUrl.origin) {
// Why: attachment metadata is provider-controlled; never forward Jira
// credentials if a malformed response points at another origin.
throw new JiraApiError('Jira attachment URL must use the configured site origin.', null)
}
const headers = new Headers()
// Why: attachment content is binary; forcing JSON Accept/Content-Type can
// break downloads and confuses some Atlassian edge responses.
headers.set('Accept', '*/*')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', client.authorization)
const response = await jiraFetch(requestUrl.toString(), { headers })
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
return {
data: await response.arrayBuffer(),
contentType
}
}
+8 -1
View File
@@ -111,7 +111,14 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) {
return { ...actual, homedir: () => tempHome }
})
return import('./client')
// One import call per reset so the split modules share a single graph (and
// thus one copy of the request queue / credential caches) per test.
const [client, queue, api] = await Promise.all([
import('./client'),
import('./request-queue'),
import('./authenticated-request')
])
return { ...client, ...queue, ...api }
}
beforeEach(() => {
+20 -513
View File
@@ -1,18 +1,4 @@
/* eslint-disable max-lines -- Why: Jira credential storage and authenticated
request plumbing share one boundary so encrypted token lifecycle and
multi-site selection cannot drift between task operations. */
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { net, safeStorage, session } from 'electron'
import {
CredentialDecryptionError,
credentialFileHasContent,
readStoredCredentialToken
} from '../integration-credential-file'
import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
import { withSpan } from '../observability/tracer'
import { CredentialDecryptionError } from '../integration-credential-file'
import type {
JiraAuthType,
JiraConnectArgs,
@@ -22,504 +8,25 @@ import type {
JiraViewer
} from '../../shared/jira-types'
import { clearAttachmentImagesForSite } from './attachment-image-cache'
// Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser
// User-Agent, failing them with "XSRF check failed" even under API-token auth.
// Electron's net.fetch sends a Chrome UA, so issue search/create/update/comment
// all 403'd while GET calls (connect, /myself) passed. A non-browser UA is the
// reliable fix; X-Atlassian-Token: no-check is not honored for this case.
const JIRA_API_USER_AGENT = 'Orca'
const MAX_CONCURRENT = 4
let running = 0
type QueuedJiraRequest = {
resolve: () => void
reject: (error: Error) => void
signal?: AbortSignal
onAbort: () => void
}
const queue: QueuedJiraRequest[] = []
function createJiraRequestAbortError(): Error {
const error = new Error('Jira request aborted')
error.name = 'AbortError'
return error
}
export function acquire(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(createJiraRequestAbortError())
}
if (running < MAX_CONCURRENT) {
running += 1
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const entry: QueuedJiraRequest = {
resolve,
reject,
signal,
onAbort: () => {
const index = queue.indexOf(entry)
if (index === -1) {
return
}
queue.splice(index, 1)
reject(createJiraRequestAbortError())
}
}
signal?.addEventListener('abort', entry.onAbort, { once: true })
queue.push(entry)
})
}
export function release(): void {
running -= 1
let next = queue.shift()
while (next) {
next.signal?.removeEventListener('abort', next.onAbort)
if (!next.signal?.aborted) {
running += 1
next.resolve()
return
}
next.reject(createJiraRequestAbortError())
next = queue.shift()
}
}
type JiraSiteFile = {
version: 1
activeSiteId: string | null
selectedSiteId: JiraSiteSelection | null
sites: JiraSite[]
}
export type JiraClientForSite = {
site: JiraSite
authorization: string
}
// Self-hosted Jira Server/Data Center only exposes REST v2; Cloud endpoints
// in this codebase are written against v3. Callers build paths with this
// prefix so one code path serves both deployments.
export function apiBasePath(site: JiraSite): string {
return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3'
}
export class JiraApiError extends Error {
status: number | null
constructor(message: string, status: number | null = null) {
super(message)
this.status = status
}
}
let cachedSiteFile: JiraSiteFile | null = null
let siteFileLoaded = false
const cachedTokens = new Map<string, string>()
// Why: decrypt failures are recorded per site so getStatus can explain
// failing reads without re-touching the keychain on every status poll.
const credentialErrors = new Map<string, string>()
function getOrcaDir(): string {
return join(homedir(), '.orca')
}
function getSiteFilePath(): string {
return join(getOrcaDir(), 'jira-sites.json')
}
function getTokenDir(): string {
return join(getOrcaDir(), 'jira-tokens')
}
function getTokenPath(siteId: string): string {
return join(getTokenDir(), `${Buffer.from(siteId).toString('base64url')}.enc`)
}
function ensureOrcaDir(): void {
const dir = getOrcaDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function ensureTokenDir(): void {
const dir = getTokenDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function emptySiteFile(): JiraSiteFile {
return {
version: 1,
activeSiteId: null,
selectedSiteId: null,
sites: []
}
}
function hasStoredToken(siteId: string): boolean {
return cachedTokens.has(siteId) || credentialFileHasContent(getTokenPath(siteId))
}
function normalizeSite(input: unknown): JiraSite | null {
if (!input || typeof input !== 'object') {
return null
}
const record = input as Record<string, unknown>
if (
typeof record.id !== 'string' ||
typeof record.siteUrl !== 'string' ||
typeof record.email !== 'string' ||
typeof record.displayName !== 'string' ||
typeof record.accountId !== 'string'
) {
return null
}
return {
id: record.id,
siteUrl: record.siteUrl,
email: record.email,
displayName: record.displayName,
accountId: record.accountId,
// Sites saved before self-hosted support have no authType; they are Cloud.
authType: record.authType === 'server' ? 'server' : 'cloud'
}
}
function readSiteFileFromDisk(): JiraSiteFile {
const path = getSiteFilePath()
if (!existsSync(path)) {
return emptySiteFile()
}
try {
const parsed = JSON.parse(readFileSync(path, { encoding: 'utf-8' })) as Partial<JiraSiteFile>
const sites = Array.isArray(parsed.sites)
? parsed.sites
.map((site) => normalizeSite(site))
.filter((site): site is JiraSite => site !== null)
.filter((site) => hasStoredToken(site.id))
: []
const activeSiteId =
typeof parsed.activeSiteId === 'string' &&
sites.some((site) => site.id === parsed.activeSiteId)
? parsed.activeSiteId
: (sites[0]?.id ?? null)
const selectedSiteId =
parsed.selectedSiteId === 'all' ||
(typeof parsed.selectedSiteId === 'string' &&
sites.some((site) => site.id === parsed.selectedSiteId))
? parsed.selectedSiteId
: activeSiteId
return { version: 1, activeSiteId, selectedSiteId, sites }
} catch {
return emptySiteFile()
}
}
function getSiteFile(): JiraSiteFile {
if (!siteFileLoaded || !cachedSiteFile) {
cachedSiteFile = readSiteFileFromDisk()
siteFileLoaded = true
}
return cachedSiteFile
}
function writeSiteFile(file: JiraSiteFile): void {
ensureOrcaDir()
const sites = file.sites.filter((site) => hasStoredToken(site.id))
const activeSiteId =
file.activeSiteId && sites.some((site) => site.id === file.activeSiteId)
? file.activeSiteId
: (sites[0]?.id ?? null)
const selectedSiteId =
file.selectedSiteId === 'all'
? 'all'
: file.selectedSiteId && sites.some((site) => site.id === file.selectedSiteId)
? file.selectedSiteId
: activeSiteId
cachedSiteFile = {
version: 1,
activeSiteId,
selectedSiteId,
sites
}
siteFileLoaded = true
writeFileSync(getSiteFilePath(), JSON.stringify(cachedSiteFile, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
}
function writeEncryptedToken(path: string, apiToken: string): void {
if (safeStorage.isEncryptionAvailable()) {
writeFileSync(path, safeStorage.encryptString(apiToken), { mode: 0o600 })
return
}
console.warn('[jira] safeStorage encryption unavailable — storing token in plaintext')
writeFileSync(path, apiToken, { encoding: 'utf-8', mode: 0o600 })
}
function readToken(siteId: string): string | null {
const cached = cachedTokens.get(siteId)
if (cached !== undefined) {
return cached
}
const path = getTokenPath(siteId)
if (!existsSync(path)) {
return null
}
try {
const raw = readFileSync(path)
const token = readStoredCredentialToken('Jira', raw)
if (token) {
cachedTokens.set(siteId, token)
}
credentialErrors.delete(siteId)
return token
} catch (error) {
if (error instanceof CredentialDecryptionError) {
credentialErrors.set(siteId, error.message)
throw error
}
return null
}
}
function saveToken(siteId: string, apiToken: string): void {
ensureOrcaDir()
ensureTokenDir()
writeEncryptedToken(getTokenPath(siteId), apiToken)
cachedTokens.set(siteId, apiToken)
credentialErrors.delete(siteId)
}
function deleteToken(siteId: string): void {
cachedTokens.delete(siteId)
credentialErrors.delete(siteId)
try {
unlinkSync(getTokenPath(siteId))
} catch {
// Token may not exist — safe to ignore.
}
}
export function normalizeJiraSiteUrl(siteUrl: string): string {
const trimmed = siteUrl.trim()
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
const url = new URL(withProtocol)
url.pathname = url.pathname.replace(/\/+$/, '')
url.search = ''
url.hash = ''
return url.toString().replace(/\/$/, '')
}
function getSiteId(siteUrl: string, email: string): string {
return createHash('sha256')
.update(`${siteUrl}\n${email.toLowerCase()}`)
.digest('base64url')
.slice(0, 24)
}
function toViewer(data: Record<string, unknown>, fallbackEmail: string): JiraViewer {
const avatarUrls = data.avatarUrls as Record<string, unknown> | undefined
// Server/DC /myself has no accountId; its stable identifiers are name/key.
const accountId =
typeof data.accountId === 'string'
? data.accountId
: typeof data.name === 'string'
? data.name
: typeof data.key === 'string'
? data.key
: ''
return {
accountId,
displayName: typeof data.displayName === 'string' ? data.displayName : fallbackEmail,
email: typeof data.emailAddress === 'string' ? data.emailAddress : fallbackEmail,
avatarUrl:
typeof avatarUrls?.['48x48'] === 'string'
? avatarUrls['48x48']
: typeof avatarUrls?.['32x32'] === 'string'
? avatarUrls['32x32']
: undefined
}
}
function siteToViewer(site: JiraSite | null): JiraViewer | null {
if (!site) {
return null
}
return {
accountId: site.accountId,
displayName: site.displayName,
email: site.email
}
}
function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string {
// Self-hosted with no username = a personal access token (Bearer); Basic auth
// with a PAT in the password slot is what produces the 401s users report.
// Self-hosted WITH a username is classic username+password Basic auth, which
// older Server/DC instances (predating PATs) require. Cloud is always Basic.
if (authType === 'server' && !email) {
return `Bearer ${apiToken}`
}
return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`
}
function describeErrorCause(error: unknown): string | undefined {
if (!error || typeof error !== 'object' || !('cause' in error)) {
return undefined
}
const cause = (error as { cause?: unknown }).cause
if (cause instanceof Error) {
return `${cause.name}: ${cause.message}`
}
return cause === undefined ? undefined : String(cause)
}
async function jiraFetch(url: string, init: RequestInit): Promise<Response> {
return withSpan(
'jira.request',
async (span) => {
span.setAttribute('jira.siteUrl', new URL(url).origin)
await ensureElectronProxyFromEnvironment({
proxySession: session.defaultSession,
probeUrl: url
}).catch((error) => {
span.addEvent('jira.proxySetupFailed', {
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error)
})
})
try {
// Why: Electron's network stack follows Chromium proxy/session state,
// avoiding undici's stale keep-alive sockets after VPN path changes.
return await net.fetch(url, init)
} catch (error) {
span.setAttribute(
'jira.transportErrorName',
error instanceof Error ? error.name : typeof error
)
span.setAttribute(
'jira.transportErrorMessage',
error instanceof Error ? error.message : String(error)
)
const cause = describeErrorCause(error)
if (cause) {
span.setAttribute('jira.transportErrorCause', cause)
}
throw error
}
},
{ kind: 'client' }
)
}
async function requestWithCredentials(
siteUrl: string,
email: string,
apiToken: string,
path: string,
init?: RequestInit,
authType?: JiraAuthType
): Promise<unknown> {
const headers = new Headers(init?.headers)
headers.set('Accept', 'application/json')
headers.set('Content-Type', 'application/json')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', authHeader(email, apiToken, authType))
const response = await jiraFetch(`${siteUrl}${path}`, {
...init,
headers
})
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
}
if (response.status === 204) {
return null
}
return response.json()
}
async function readJiraError(response: Response): Promise<string> {
try {
const data = (await response.json()) as {
errorMessages?: string[]
errors?: Record<string, string>
message?: string
}
const messages = [
...(Array.isArray(data.errorMessages) ? data.errorMessages : []),
...Object.values(data.errors ?? {}),
...(data.message ? [data.message] : [])
].filter(Boolean)
if (messages.length > 0) {
return messages.join('; ')
}
} catch {
// Fall through to status text.
}
return response.statusText || `Jira request failed (${response.status})`
}
export async function jiraRequest<T>(
client: JiraClientForSite,
path: string,
init?: RequestInit
): Promise<T> {
const headers = new Headers(init?.headers)
headers.set('Accept', 'application/json')
headers.set('Content-Type', 'application/json')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', client.authorization)
const response = await jiraFetch(`${client.site.siteUrl}${path}`, {
...init,
headers
})
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
}
if (response.status === 204) {
return null as T
}
return (await response.json()) as T
}
export async function jiraRequestBinary(
client: JiraClientForSite,
pathOrUrl: string
): Promise<{ data: ArrayBuffer; contentType: string }> {
const siteUrl = new URL(client.site.siteUrl)
const requestUrl = /^https?:\/\//i.test(pathOrUrl)
? new URL(pathOrUrl)
: new URL(`${client.site.siteUrl}${pathOrUrl}`)
if (requestUrl.origin !== siteUrl.origin) {
// Why: attachment metadata is provider-controlled; never forward Jira
// credentials if a malformed response points at another origin.
throw new JiraApiError('Jira attachment URL must use the configured site origin.', null)
}
const headers = new Headers()
// Why: attachment content is binary; forcing JSON Accept/Content-Type can
// break downloads and confuses some Atlassian edge responses.
headers.set('Accept', '*/*')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', client.authorization)
const response = await jiraFetch(requestUrl.toString(), { headers })
if (!response.ok) {
throw new JiraApiError(await readJiraError(response), response.status)
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
return {
data: await response.arrayBuffer(),
contentType
}
}
import { acquire, release } from './request-queue'
import {
credentialErrors,
deleteToken,
getSiteFile,
hasStoredToken,
readToken,
saveToken,
writeSiteFile
} from './site-credential-store'
import {
apiBasePath,
authHeader,
JiraApiError,
jiraRequest,
requestWithCredentials,
type JiraClientForSite
} from './authenticated-request'
import { getSiteId, normalizeJiraSiteUrl, siteToViewer, toViewer } from './site-identity'
export function getClients(selection?: JiraSiteSelection | null): JiraClientForSite[] {
const file = getSiteFile()
+10 -7
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './client'
import type { JiraClientForSite } from './authenticated-request'
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
import { getJiraSummaryLookupErrorCode } from '../../shared/jira-summary-lookup'
@@ -21,14 +21,11 @@ const {
releaseMock: vi.fn()
}))
vi.mock('./client', () => ({
acquire: (...args: unknown[]) => acquireMock(...args),
release: (...args: unknown[]) => releaseMock(...args),
vi.mock('./request-queue', () => ({ acquire: acquireMock, release: releaseMock }))
vi.mock('./authenticated-request', () => ({
apiBasePath: (site: { authType?: string }) =>
site.authType === 'server' ? '/rest/api/2' : '/rest/api/3',
clearToken: (...args: unknown[]) => clearTokenMock(...args),
getClients: (...args: unknown[]) => getClientsMock(...args),
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args),
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args),
jiraRequestBinary: (...args: unknown[]) => jiraRequestBinaryMock(...args),
JiraApiError: class JiraApiError extends Error {
@@ -40,6 +37,12 @@ vi.mock('./client', () => ({
}
}))
vi.mock('./client', () => ({
clearToken: (...args: unknown[]) => clearTokenMock(...args),
getClients: (...args: unknown[]) => getClientsMock(...args),
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args)
}))
function makeEntry(id = 'site-1'): JiraClientForSite {
return {
site: {
+3 -10
View File
@@ -21,16 +21,9 @@ import type {
JiraTransition,
JiraUser
} from '../../shared/jira-types'
import {
acquire,
apiBasePath,
clearToken,
getClients,
isAuthError,
jiraRequest,
release,
type JiraClientForSite
} from './client'
import { acquire, release } from './request-queue'
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
import { clearToken, getClients, isAuthError } from './client'
import {
adfToMarkdownText,
collectAdfMediaAttrs,
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './client'
import type { JiraClientForSite } from './authenticated-request'
import { getJiraSummaryLookupErrorCode } from '../../shared/jira-summary-lookup'
const { acquireMock, getClientsMock, jiraRequestMock, releaseMock } = vi.hoisted(() => ({
@@ -9,18 +9,24 @@ const { acquireMock, getClientsMock, jiraRequestMock, releaseMock } = vi.hoisted
releaseMock: vi.fn()
}))
vi.mock('./client', () => ({
vi.mock('./request-queue', () => ({
acquire: (...args: unknown[]) => acquireMock(...args),
release: (...args: unknown[]) => releaseMock(...args),
release: (...args: unknown[]) => releaseMock(...args)
}))
vi.mock('./authenticated-request', () => ({
apiBasePath: () => '/rest/api/3',
clearToken: vi.fn(),
getClients: (...args: unknown[]) => getClientsMock(...args),
isAuthError: vi.fn().mockReturnValue(false),
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args),
jiraRequestBinary: vi.fn(),
JiraApiError: class JiraApiError extends Error {}
}))
vi.mock('./client', () => ({
clearToken: vi.fn(),
getClients: (...args: unknown[]) => getClientsMock(...args),
isAuthError: vi.fn().mockReturnValue(false)
}))
function makeEntry(): JiraClientForSite {
return {
site: {
+12 -6
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './client'
import type { JiraClientForSite } from './authenticated-request'
const { acquireMock, clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } =
vi.hoisted(() => ({
@@ -10,18 +10,24 @@ const { acquireMock, clearTokenMock, getClientsMock, isAuthErrorMock, jiraReques
jiraRequestMock: vi.fn()
}))
vi.mock('./client', () => ({
vi.mock('./request-queue', () => ({
acquire: (...args: unknown[]) => acquireMock(...args),
release: vi.fn(),
release: vi.fn()
}))
vi.mock('./authenticated-request', () => ({
apiBasePath: () => '/rest/api/3',
clearToken: (...args: unknown[]) => clearTokenMock(...args),
getClients: (...args: unknown[]) => getClientsMock(...args),
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args),
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args),
jiraRequestBinary: vi.fn(),
JiraApiError: class JiraApiError extends Error {}
}))
vi.mock('./client', () => ({
clearToken: (...args: unknown[]) => clearTokenMock(...args),
getClients: (...args: unknown[]) => getClientsMock(...args),
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args)
}))
function makeEntry(id: string): JiraClientForSite {
return {
site: {
+57
View File
@@ -0,0 +1,57 @@
const MAX_CONCURRENT = 4
let running = 0
type QueuedJiraRequest = {
resolve: () => void
reject: (error: Error) => void
signal?: AbortSignal
onAbort: () => void
}
const queue: QueuedJiraRequest[] = []
function createJiraRequestAbortError(): Error {
const error = new Error('Jira request aborted')
error.name = 'AbortError'
return error
}
export function acquire(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(createJiraRequestAbortError())
}
if (running < MAX_CONCURRENT) {
running += 1
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const entry: QueuedJiraRequest = {
resolve,
reject,
signal,
onAbort: () => {
const index = queue.indexOf(entry)
if (index === -1) {
return
}
queue.splice(index, 1)
reject(createJiraRequestAbortError())
}
}
signal?.addEventListener('abort', entry.onAbort, { once: true })
queue.push(entry)
})
}
export function release(): void {
running -= 1
let next = queue.shift()
while (next) {
next.signal?.removeEventListener('abort', next.onAbort)
if (!next.signal?.aborted) {
running += 1
next.resolve()
return
}
next.reject(createJiraRequestAbortError())
next = queue.shift()
}
}
+210
View File
@@ -0,0 +1,210 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { safeStorage } from 'electron'
import {
CredentialDecryptionError,
credentialFileHasContent,
readStoredCredentialToken
} from '../integration-credential-file'
import type { JiraSite, JiraSiteSelection } from '../../shared/jira-types'
export type JiraSiteFile = {
version: 1
activeSiteId: string | null
selectedSiteId: JiraSiteSelection | null
sites: JiraSite[]
}
let cachedSiteFile: JiraSiteFile | null = null
let siteFileLoaded = false
const cachedTokens = new Map<string, string>()
// Why: decrypt failures are recorded per site so getStatus can explain
// failing reads without re-touching the keychain on every status poll.
export const credentialErrors = new Map<string, string>()
function getOrcaDir(): string {
return join(homedir(), '.orca')
}
function getSiteFilePath(): string {
return join(getOrcaDir(), 'jira-sites.json')
}
function getTokenDir(): string {
return join(getOrcaDir(), 'jira-tokens')
}
function getTokenPath(siteId: string): string {
return join(getTokenDir(), `${Buffer.from(siteId).toString('base64url')}.enc`)
}
function ensureOrcaDir(): void {
const dir = getOrcaDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function ensureTokenDir(): void {
const dir = getTokenDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function emptySiteFile(): JiraSiteFile {
return {
version: 1,
activeSiteId: null,
selectedSiteId: null,
sites: []
}
}
export function hasStoredToken(siteId: string): boolean {
return cachedTokens.has(siteId) || credentialFileHasContent(getTokenPath(siteId))
}
function normalizeSite(input: unknown): JiraSite | null {
if (!input || typeof input !== 'object') {
return null
}
const record = input as Record<string, unknown>
if (
typeof record.id !== 'string' ||
typeof record.siteUrl !== 'string' ||
typeof record.email !== 'string' ||
typeof record.displayName !== 'string' ||
typeof record.accountId !== 'string'
) {
return null
}
return {
id: record.id,
siteUrl: record.siteUrl,
email: record.email,
displayName: record.displayName,
accountId: record.accountId,
// Sites saved before self-hosted support have no authType; they are Cloud.
authType: record.authType === 'server' ? 'server' : 'cloud'
}
}
function readSiteFileFromDisk(): JiraSiteFile {
const path = getSiteFilePath()
if (!existsSync(path)) {
return emptySiteFile()
}
try {
const parsed = JSON.parse(readFileSync(path, { encoding: 'utf-8' })) as Partial<JiraSiteFile>
const sites = Array.isArray(parsed.sites)
? parsed.sites
.map((site) => normalizeSite(site))
.filter((site): site is JiraSite => site !== null)
.filter((site) => hasStoredToken(site.id))
: []
const activeSiteId =
typeof parsed.activeSiteId === 'string' &&
sites.some((site) => site.id === parsed.activeSiteId)
? parsed.activeSiteId
: (sites[0]?.id ?? null)
const selectedSiteId =
parsed.selectedSiteId === 'all' ||
(typeof parsed.selectedSiteId === 'string' &&
sites.some((site) => site.id === parsed.selectedSiteId))
? parsed.selectedSiteId
: activeSiteId
return { version: 1, activeSiteId, selectedSiteId, sites }
} catch {
return emptySiteFile()
}
}
export function getSiteFile(): JiraSiteFile {
if (!siteFileLoaded || !cachedSiteFile) {
cachedSiteFile = readSiteFileFromDisk()
siteFileLoaded = true
}
return cachedSiteFile
}
export function writeSiteFile(file: JiraSiteFile): void {
ensureOrcaDir()
const sites = file.sites.filter((site) => hasStoredToken(site.id))
const activeSiteId =
file.activeSiteId && sites.some((site) => site.id === file.activeSiteId)
? file.activeSiteId
: (sites[0]?.id ?? null)
const selectedSiteId =
file.selectedSiteId === 'all'
? 'all'
: file.selectedSiteId && sites.some((site) => site.id === file.selectedSiteId)
? file.selectedSiteId
: activeSiteId
cachedSiteFile = {
version: 1,
activeSiteId,
selectedSiteId,
sites
}
siteFileLoaded = true
writeFileSync(getSiteFilePath(), JSON.stringify(cachedSiteFile, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
}
function writeEncryptedToken(path: string, apiToken: string): void {
if (safeStorage.isEncryptionAvailable()) {
writeFileSync(path, safeStorage.encryptString(apiToken), { mode: 0o600 })
return
}
console.warn('[jira] safeStorage encryption unavailable — storing token in plaintext')
writeFileSync(path, apiToken, { encoding: 'utf-8', mode: 0o600 })
}
export function readToken(siteId: string): string | null {
const cached = cachedTokens.get(siteId)
if (cached !== undefined) {
return cached
}
const path = getTokenPath(siteId)
if (!existsSync(path)) {
return null
}
try {
const raw = readFileSync(path)
const token = readStoredCredentialToken('Jira', raw)
if (token) {
cachedTokens.set(siteId, token)
}
credentialErrors.delete(siteId)
return token
} catch (error) {
if (error instanceof CredentialDecryptionError) {
credentialErrors.set(siteId, error.message)
throw error
}
return null
}
}
export function saveToken(siteId: string, apiToken: string): void {
ensureOrcaDir()
ensureTokenDir()
writeEncryptedToken(getTokenPath(siteId), apiToken)
cachedTokens.set(siteId, apiToken)
credentialErrors.delete(siteId)
}
export function deleteToken(siteId: string): void {
cachedTokens.delete(siteId)
credentialErrors.delete(siteId)
try {
unlinkSync(getTokenPath(siteId))
} catch {
// Token may not exist — safe to ignore.
}
}
+54
View File
@@ -0,0 +1,54 @@
import { createHash } from 'node:crypto'
import type { JiraSite, JiraViewer } from '../../shared/jira-types'
export function normalizeJiraSiteUrl(siteUrl: string): string {
const trimmed = siteUrl.trim()
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
const url = new URL(withProtocol)
url.pathname = url.pathname.replace(/\/+$/, '')
url.search = ''
url.hash = ''
return url.toString().replace(/\/$/, '')
}
export function getSiteId(siteUrl: string, email: string): string {
return createHash('sha256')
.update(`${siteUrl}\n${email.toLowerCase()}`)
.digest('base64url')
.slice(0, 24)
}
export function toViewer(data: Record<string, unknown>, fallbackEmail: string): JiraViewer {
const avatarUrls = data.avatarUrls as Record<string, unknown> | undefined
// Server/DC /myself has no accountId; its stable identifiers are name/key.
const accountId =
typeof data.accountId === 'string'
? data.accountId
: typeof data.name === 'string'
? data.name
: typeof data.key === 'string'
? data.key
: ''
return {
accountId,
displayName: typeof data.displayName === 'string' ? data.displayName : fallbackEmail,
email: typeof data.emailAddress === 'string' ? data.emailAddress : fallbackEmail,
avatarUrl:
typeof avatarUrls?.['48x48'] === 'string'
? avatarUrls['48x48']
: typeof avatarUrls?.['32x32'] === 'string'
? avatarUrls['32x32']
: undefined
}
}
export function siteToViewer(site: JiraSite | null): JiraViewer | null {
if (!site) {
return null
}
return {
accountId: site.accountId,
displayName: site.displayName,
email: site.email
}
}
+25 -492
View File
@@ -1,17 +1,29 @@
/* eslint-disable max-lines -- Why: Linear credential storage and client
selection share one module so keychain-safe status reads and token mutation
stay in one consistency boundary. */
import { safeStorage } from 'electron'
import type { LinearClient } from '@linear/sdk'
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { loadLinearSdk } from './linear-sdk'
import { LEGACY_WORKSPACE_ID } from './linear-credential-paths'
import {
CredentialDecryptionError,
credentialFileHasContent,
readStoredCredentialToken
} from '../integration-credential-file'
clearLegacyViewerOnDisk,
forgetLegacyViewer,
getLegacyViewer
} from './linear-legacy-viewer-store'
import { workspaceFromLinearData } from './linear-workspace-record'
import {
getCredentialError,
getLegacyWorkspace,
getWorkspaceFile,
getWorkspaceState,
resolveWorkspaceId,
upsertWorkspace,
writeWorkspaceFile
} from './linear-workspace-registry'
import {
clearToken,
clearTokenFile,
loadToken,
replaceLegacyWorkspace,
saveWorkspaceToken
} from './linear-token-store'
import { CredentialDecryptionError } from '../integration-credential-file'
import type {
LinearConnectionStatus,
LinearViewer,
@@ -19,45 +31,6 @@ import type {
LinearWorkspaceSelection
} from '../../shared/linear/workspace-types'
// ── Concurrency limiter — max 4 parallel Linear API calls ────────────
const MAX_CONCURRENT = 4
let running = 0
const queue: (() => void)[] = []
export function acquire(): Promise<void> {
if (running < MAX_CONCURRENT) {
running++
return Promise.resolve()
}
return new Promise((resolve) =>
queue.push(() => {
running++
resolve()
})
)
}
export function release(): void {
running--
const next = queue.shift()
if (next) {
next()
}
}
// ── Token + workspace storage ────────────────────────────────────────
// Why: tokens remain encrypted via safeStorage, while workspace metadata stays
// plaintext so status checks can render connected accounts without decrypting
// and triggering OS keychain prompts after app updates.
const LEGACY_WORKSPACE_ID = 'legacy'
type LinearWorkspaceFile = {
version: 1
activeWorkspaceId: string | null
selectedWorkspaceId: LinearWorkspaceSelection | null
workspaces: LinearWorkspace[]
}
export type LinearClientForWorkspace = {
workspace: LinearWorkspace
client: LinearClient
@@ -66,445 +39,6 @@ export type LinearClientForWorkspace = {
export const LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS = 60 * 60
let cachedTokens = new Map<string, string>()
// Why: decrypt failures are recorded per workspace so getStatus can explain
// failing reads without re-touching the keychain on every status poll.
const credentialErrors = new Map<string, string>()
let cachedLegacyViewer: LinearViewer | null = null
let legacyViewerLoadedFromDisk = false
let cachedWorkspaceFile: LinearWorkspaceFile | null = null
let workspaceFileLoadedFromDisk = false
function getOrcaDir(): string {
return join(homedir(), '.orca')
}
function getLegacyTokenPath(): string {
return join(getOrcaDir(), 'linear-token.enc')
}
function getLegacyViewerPath(): string {
return join(getOrcaDir(), 'linear-viewer.json')
}
function getWorkspaceFilePath(): string {
return join(getOrcaDir(), 'linear-workspaces.json')
}
function getWorkspaceTokenDir(): string {
return join(getOrcaDir(), 'linear-tokens')
}
function getWorkspaceTokenPath(workspaceId: string): string {
if (workspaceId === LEGACY_WORKSPACE_ID) {
return getLegacyTokenPath()
}
return join(getWorkspaceTokenDir(), `${Buffer.from(workspaceId).toString('base64url')}.enc`)
}
function ensureOrcaDir(): void {
const dir = getOrcaDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function ensureWorkspaceTokenDir(): void {
const dir = getWorkspaceTokenDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
function readLegacyViewerFromDisk(): LinearViewer | null {
const path = getLegacyViewerPath()
if (!existsSync(path)) {
return null
}
try {
const raw = readFileSync(path, { encoding: 'utf-8' })
const parsed = JSON.parse(raw) as Partial<LinearViewer>
if (typeof parsed?.displayName !== 'string' || typeof parsed?.organizationName !== 'string') {
return null
}
return {
displayName: parsed.displayName,
email: typeof parsed.email === 'string' ? parsed.email : null,
organizationId: typeof parsed.organizationId === 'string' ? parsed.organizationId : undefined,
organizationName: parsed.organizationName,
organizationUrlKey:
typeof parsed.organizationUrlKey === 'string' ? parsed.organizationUrlKey : undefined
}
} catch {
return null
}
}
function getLegacyViewer(): LinearViewer | null {
if (!legacyViewerLoadedFromDisk) {
cachedLegacyViewer = readLegacyViewerFromDisk()
legacyViewerLoadedFromDisk = true
}
return cachedLegacyViewer
}
function normalizeWorkspace(input: unknown): LinearWorkspace | null {
if (!input || typeof input !== 'object') {
return null
}
const record = input as Record<string, unknown>
if (typeof record.id !== 'string' || typeof record.organizationName !== 'string') {
return null
}
if (typeof record.displayName !== 'string') {
return null
}
const organizationId =
typeof record.organizationId === 'string' && record.organizationId
? record.organizationId
: record.id
return {
id: record.id,
organizationId,
organizationName: record.organizationName,
organizationUrlKey:
typeof record.organizationUrlKey === 'string' ? record.organizationUrlKey : undefined,
displayName: record.displayName,
email: typeof record.email === 'string' ? record.email : null,
credentialRevision:
typeof record.credentialRevision === 'number' && Number.isFinite(record.credentialRevision)
? record.credentialRevision
: undefined
}
}
function emptyWorkspaceFile(): LinearWorkspaceFile {
return {
version: 1,
activeWorkspaceId: null,
selectedWorkspaceId: null,
workspaces: []
}
}
function readWorkspaceFileFromDisk(): LinearWorkspaceFile {
const path = getWorkspaceFilePath()
if (!existsSync(path)) {
return emptyWorkspaceFile()
}
try {
const raw = readFileSync(path, { encoding: 'utf-8' })
const parsed = JSON.parse(raw) as Partial<LinearWorkspaceFile>
const workspaces = Array.isArray(parsed.workspaces)
? parsed.workspaces
.map((workspace) => normalizeWorkspace(workspace))
.filter((workspace): workspace is LinearWorkspace => workspace !== null)
.filter((workspace) => hasStoredToken(workspace.id))
: []
const activeWorkspaceId =
typeof parsed.activeWorkspaceId === 'string' &&
workspaces.some((workspace) => workspace.id === parsed.activeWorkspaceId)
? parsed.activeWorkspaceId
: (workspaces[0]?.id ?? null)
const selectedWorkspaceId =
parsed.selectedWorkspaceId === 'all' ||
(typeof parsed.selectedWorkspaceId === 'string' &&
workspaces.some((workspace) => workspace.id === parsed.selectedWorkspaceId))
? parsed.selectedWorkspaceId
: activeWorkspaceId
return {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
}
} catch {
return emptyWorkspaceFile()
}
}
function getWorkspaceFile(): LinearWorkspaceFile {
if (!workspaceFileLoadedFromDisk || !cachedWorkspaceFile) {
cachedWorkspaceFile = readWorkspaceFileFromDisk()
workspaceFileLoadedFromDisk = true
}
return cachedWorkspaceFile
}
function writeWorkspaceFile(file: LinearWorkspaceFile): void {
ensureOrcaDir()
const persistedWorkspaces = file.workspaces.filter(
(workspace) => workspace.id !== LEGACY_WORKSPACE_ID
)
const selectableIds = new Set(persistedWorkspaces.map((workspace) => workspace.id))
if (hasStoredToken(LEGACY_WORKSPACE_ID)) {
selectableIds.add(LEGACY_WORKSPACE_ID)
}
const activeWorkspaceId =
file.activeWorkspaceId && selectableIds.has(file.activeWorkspaceId)
? file.activeWorkspaceId
: (persistedWorkspaces[0]?.id ??
(selectableIds.has(LEGACY_WORKSPACE_ID) ? LEGACY_WORKSPACE_ID : null))
const selectedWorkspaceId =
file.selectedWorkspaceId === 'all'
? 'all'
: file.selectedWorkspaceId && selectableIds.has(file.selectedWorkspaceId)
? file.selectedWorkspaceId
: activeWorkspaceId
cachedWorkspaceFile = {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces: persistedWorkspaces
}
workspaceFileLoadedFromDisk = true
writeFileSync(getWorkspaceFilePath(), JSON.stringify(cachedWorkspaceFile, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
}
function getLegacyWorkspace(): LinearWorkspace | null {
if (!hasStoredToken(LEGACY_WORKSPACE_ID)) {
return null
}
const viewer = getLegacyViewer()
return {
id: LEGACY_WORKSPACE_ID,
organizationId: viewer?.organizationId ?? LEGACY_WORKSPACE_ID,
organizationName: viewer?.organizationName ?? 'Saved Linear workspace',
organizationUrlKey: viewer?.organizationUrlKey,
displayName: viewer?.displayName ?? 'Linear API key',
email: viewer?.email ?? null,
isLegacy: true
}
}
function getWorkspaceState(): LinearWorkspaceFile {
const file = getWorkspaceFile()
const legacyWorkspace = getLegacyWorkspace()
const workspaces = [
...(legacyWorkspace ? [legacyWorkspace] : []),
...file.workspaces.filter((workspace) => hasStoredToken(workspace.id))
]
const activeWorkspaceId =
file.activeWorkspaceId &&
workspaces.some((workspace) => workspace.id === file.activeWorkspaceId)
? file.activeWorkspaceId
: (workspaces[0]?.id ?? null)
const selectedWorkspaceId =
file.selectedWorkspaceId === 'all'
? 'all'
: file.selectedWorkspaceId &&
workspaces.some((workspace) => workspace.id === file.selectedWorkspaceId)
? file.selectedWorkspaceId
: activeWorkspaceId
return {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
}
}
function clearLegacyViewerOnDisk(): void {
try {
unlinkSync(getLegacyViewerPath())
} catch {
// File may not exist — safe to ignore.
}
}
function writeEncryptedToken(path: string, apiKey: string): void {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(apiKey)
writeFileSync(path, encrypted, { mode: 0o600 })
return
}
console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext')
writeFileSync(path, apiKey, { encoding: 'utf-8', mode: 0o600 })
}
function saveWorkspaceToken(workspaceId: string, apiKey: string): void {
ensureOrcaDir()
if (workspaceId !== LEGACY_WORKSPACE_ID) {
ensureWorkspaceTokenDir()
}
const tokenPath = getWorkspaceTokenPath(workspaceId)
writeEncryptedToken(tokenPath, apiKey)
cachedTokens.set(workspaceId, apiKey)
credentialErrors.delete(workspaceId)
}
// Backward-compatible export for the legacy single-workspace storage path.
export function saveToken(apiKey: string): void {
saveWorkspaceToken(LEGACY_WORKSPACE_ID, apiKey)
}
export function loadToken(options: { force?: boolean; workspaceId?: string } = {}): string | null {
const workspaceId = options.workspaceId ?? resolveWorkspaceId()
if (!workspaceId) {
return null
}
const cached = cachedTokens.get(workspaceId)
if (cached !== undefined) {
return cached
}
if (!options.force) {
return null
}
const tokenPath = getWorkspaceTokenPath(workspaceId)
if (!existsSync(tokenPath)) {
return null
}
try {
const raw = readFileSync(tokenPath)
const token = readStoredCredentialToken('Linear', raw)
if (token) {
cachedTokens.set(workspaceId, token)
}
credentialErrors.delete(workspaceId)
return token
} catch (error) {
if (error instanceof CredentialDecryptionError) {
credentialErrors.set(workspaceId, error.message)
throw error
}
return null
}
}
export function hasStoredToken(workspaceId?: string): boolean {
if (!workspaceId) {
return getWorkspaceState().workspaces.length > 0
}
if (cachedTokens.has(workspaceId)) {
return true
}
return credentialFileHasContent(getWorkspaceTokenPath(workspaceId))
}
function clearTokenFile(workspaceId: string): void {
cachedTokens.delete(workspaceId)
credentialErrors.delete(workspaceId)
try {
unlinkSync(getWorkspaceTokenPath(workspaceId))
} catch {
// File may not exist — safe to ignore.
}
}
export function clearToken(workspaceId?: string): void {
if (!workspaceId) {
const state = getWorkspaceState()
for (const workspace of state.workspaces) {
clearTokenFile(workspace.id)
}
cachedTokens = new Map()
credentialErrors.clear()
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = false
cachedWorkspaceFile = emptyWorkspaceFile()
workspaceFileLoadedFromDisk = true
clearLegacyViewerOnDisk()
writeWorkspaceFile(emptyWorkspaceFile())
return
}
clearTokenFile(workspaceId)
if (workspaceId === LEGACY_WORKSPACE_ID) {
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = false
clearLegacyViewerOnDisk()
return
}
const file = getWorkspaceFile()
const workspaces = file.workspaces.filter((workspace) => workspace.id !== workspaceId)
const activeWorkspaceId =
file.activeWorkspaceId === workspaceId ? (workspaces[0]?.id ?? null) : file.activeWorkspaceId
const selectedWorkspaceId =
file.selectedWorkspaceId === workspaceId ? activeWorkspaceId : file.selectedWorkspaceId
writeWorkspaceFile({
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
})
}
function workspaceFromLinearData(
me: { displayName: string; email?: string | null },
org: { id: string; name: string; urlKey?: string | null }
): LinearWorkspace {
return {
id: org.id,
organizationId: org.id,
organizationName: org.name,
organizationUrlKey: org.urlKey ?? undefined,
displayName: me.displayName,
email: me.email ?? null
}
}
function upsertWorkspace(workspace: LinearWorkspace, options: { select?: boolean } = {}): void {
const file = getWorkspaceFile()
const current = file.workspaces.find((entry) => entry.id === workspace.id)
const credentialRevision = (current?.credentialRevision ?? 0) + 1
const workspaceWithRevision = { ...workspace, credentialRevision }
const withoutCurrent = file.workspaces.filter((entry) => entry.id !== workspace.id)
const workspaces = [...withoutCurrent, workspaceWithRevision].sort((a, b) =>
a.organizationName.localeCompare(b.organizationName)
)
const selectedWorkspaceId = options.select
? workspace.id
: file.selectedWorkspaceId && file.selectedWorkspaceId !== LEGACY_WORKSPACE_ID
? file.selectedWorkspaceId
: workspace.id
writeWorkspaceFile({
version: 1,
activeWorkspaceId: workspace.id,
selectedWorkspaceId,
workspaces
})
}
function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void {
saveWorkspaceToken(workspace.id, token)
clearTokenFile(LEGACY_WORKSPACE_ID)
clearLegacyViewerOnDisk()
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = true
upsertWorkspace(workspace, { select: true })
}
function resolveWorkspaceId(workspaceId?: string | null): string | null {
if (workspaceId && workspaceId !== 'all') {
return workspaceId
}
const state = getWorkspaceState()
if (
state.selectedWorkspaceId &&
state.selectedWorkspaceId !== 'all' &&
state.workspaces.some((workspace) => workspace.id === state.selectedWorkspaceId)
) {
return state.selectedWorkspaceId
}
if (
state.activeWorkspaceId &&
state.workspaces.some((workspace) => workspace.id === state.activeWorkspaceId)
) {
return state.activeWorkspaceId
}
return state.workspaces[0]?.id ?? null
}
// ── Client factory ───────────────────────────────────────────────────
// Why: issues/teams modules call this for real Linear actions — at that point
// decrypting the token and surfacing a keychain prompt is expected.
@@ -594,8 +128,7 @@ export async function connect(
) {
clearTokenFile(LEGACY_WORKSPACE_ID)
clearLegacyViewerOnDisk()
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = true
forgetLegacyViewer()
}
upsertWorkspace(workspace, { select: true })
return { ok: true, viewer: workspace, workspace }
@@ -641,7 +174,7 @@ export function getStatus(): LinearConnectionStatus {
null
const credentialError = state.workspaces
.map((workspace) => credentialErrors.get(workspace.id))
.map((workspace) => getCredentialError(workspace.id))
.find((message) => message !== undefined)
return {
+9 -3
View File
@@ -6,13 +6,19 @@ const getStatus = vi.fn()
const isAuthError = vi.fn()
const clearToken = vi.fn()
vi.mock('./client', () => ({
vi.mock('./linear-request-concurrency', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
release: vi.fn()
}))
vi.mock('./linear-token-store', () => ({
clearToken: (...args: unknown[]) => clearToken(...args)
}))
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClients(...args),
getStatus: (...args: unknown[]) => getStatus(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args),
clearToken: (...args: unknown[]) => clearToken(...args),
// The signed public-file-url client reuses the same underlying raw client, so
// body reads route through the entry's rawRequest spy in these tests.
getPublicFileUrlClient: (entry: LinearClientForWorkspace) => entry.client
+2 -3
View File
@@ -1,14 +1,13 @@
import type { LinearSearchIssueSummary, LinearSearchResult } from '../../shared/linear/agent-access'
import { clampLinearSearchLimit } from '../../shared/linear/agent-access'
import type { LinearWorkspace } from '../../shared/linear/workspace-types'
import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import {
acquire,
clearToken,
getClients,
getPublicFileUrlClient,
getStatus,
isAuthError,
release,
type LinearClientForWorkspace
} from './client'
import {
+9 -3
View File
@@ -7,13 +7,19 @@ const getStatus = vi.fn()
const isAuthError = vi.fn()
const clearToken = vi.fn()
vi.mock('./client', () => ({
vi.mock('./linear-request-concurrency', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
release: vi.fn()
}))
vi.mock('./linear-token-store', () => ({
clearToken: (...args: unknown[]) => clearToken(...args)
}))
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClients(...args),
getStatus: (...args: unknown[]) => getStatus(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args),
clearToken: (...args: unknown[]) => clearToken(...args),
// The signed public-file-url client reuses the same underlying raw client, so
// body reads route through the entry's rawRequest spy in these tests.
getPublicFileUrlClient: (entry: LinearClientForWorkspace) => entry.client
+10 -4
View File
@@ -4,14 +4,20 @@ import type { LinearClientForWorkspace } from './client'
const rawRequest = vi.fn()
const getClients = vi.fn()
vi.mock('./client', () => ({
vi.mock('./linear-request-concurrency', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: vi.fn().mockReturnValue(false),
release: vi.fn()
}))
vi.mock('./linear-token-store', () => ({
clearToken: vi.fn()
}))
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: vi.fn().mockReturnValue(false)
}))
function entry(): LinearClientForWorkspace {
return {
workspace: {
+3 -1
View File
@@ -5,7 +5,9 @@ import type {
LinearIssueRelationWriteResult
} from '../../shared/linear/issue-relation-write'
import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear/issue-read-limits'
import { acquire, clearToken, getClients, isAuthError, release } from './client'
import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError } from './client'
import { linearError } from './issue-context-errors'
import {
INVERSE_RELATIONS_QUERY,
+10 -4
View File
@@ -7,14 +7,20 @@ const getClients = vi.fn()
const clearToken = vi.fn()
const isAuthError = vi.fn()
vi.mock('./client', () => ({
vi.mock('./linear-request-concurrency', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args),
release: vi.fn()
}))
vi.mock('./linear-token-store', () => ({
clearToken: (...args: unknown[]) => clearToken(...args)
}))
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args)
}))
function makeEntry(options?: {
workspaceId?: string
organizationName?: string
+3 -8
View File
@@ -18,14 +18,9 @@ import {
isEmptyLinearIssueAttributeFilter,
type LinearIssueAttributeFilter
} from '../../shared/linear/issue-attribute-filter'
import {
acquire,
release,
getClients,
isAuthError,
clearToken,
type LinearClientForWorkspace
} from './client'
import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError, type LinearClientForWorkspace } from './client'
import { buildLinearListIssueFilter } from './issue-list-filter'
import { mapLinearIssue } from './mappers'
@@ -0,0 +1,46 @@
import { existsSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
export const LEGACY_WORKSPACE_ID = 'legacy'
function getOrcaDir(): string {
return join(homedir(), '.orca')
}
function getLegacyTokenPath(): string {
return join(getOrcaDir(), 'linear-token.enc')
}
export function getLegacyViewerPath(): string {
return join(getOrcaDir(), 'linear-viewer.json')
}
export function getWorkspaceFilePath(): string {
return join(getOrcaDir(), 'linear-workspaces.json')
}
function getWorkspaceTokenDir(): string {
return join(getOrcaDir(), 'linear-tokens')
}
export function getWorkspaceTokenPath(workspaceId: string): string {
if (workspaceId === LEGACY_WORKSPACE_ID) {
return getLegacyTokenPath()
}
return join(getWorkspaceTokenDir(), `${Buffer.from(workspaceId).toString('base64url')}.enc`)
}
export function ensureOrcaDir(): void {
const dir = getOrcaDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
export function ensureWorkspaceTokenDir(): void {
const dir = getWorkspaceTokenDir()
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
@@ -0,0 +1,58 @@
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
import { getLegacyViewerPath } from './linear-credential-paths'
import type { LinearViewer } from '../../shared/linear/workspace-types'
let cachedLegacyViewer: LinearViewer | null = null
let legacyViewerLoadedFromDisk = false
function readLegacyViewerFromDisk(): LinearViewer | null {
const path = getLegacyViewerPath()
if (!existsSync(path)) {
return null
}
try {
const raw = readFileSync(path, { encoding: 'utf-8' })
const parsed = JSON.parse(raw) as Partial<LinearViewer>
if (typeof parsed?.displayName !== 'string' || typeof parsed?.organizationName !== 'string') {
return null
}
return {
displayName: parsed.displayName,
email: typeof parsed.email === 'string' ? parsed.email : null,
organizationId: typeof parsed.organizationId === 'string' ? parsed.organizationId : undefined,
organizationName: parsed.organizationName,
organizationUrlKey:
typeof parsed.organizationUrlKey === 'string' ? parsed.organizationUrlKey : undefined
}
} catch {
return null
}
}
export function getLegacyViewer(): LinearViewer | null {
if (!legacyViewerLoadedFromDisk) {
cachedLegacyViewer = readLegacyViewerFromDisk()
legacyViewerLoadedFromDisk = true
}
return cachedLegacyViewer
}
export function clearLegacyViewerOnDisk(): void {
try {
unlinkSync(getLegacyViewerPath())
} catch {
// File may not exist — safe to ignore.
}
}
// Why: the viewer file is gone for good, so keep the cache "loaded" and empty
// rather than re-reading a file we just deleted.
export function forgetLegacyViewer(): void {
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = true
}
export function resetLegacyViewerCache(): void {
cachedLegacyViewer = null
legacyViewerLoadedFromDisk = false
}
@@ -0,0 +1,25 @@
// ── Concurrency limiter — max 4 parallel Linear API calls ────────────
const MAX_CONCURRENT = 4
let running = 0
const queue: (() => void)[] = []
export function acquire(): Promise<void> {
if (running < MAX_CONCURRENT) {
running++
return Promise.resolve()
}
return new Promise((resolve) =>
queue.push(() => {
running++
resolve()
})
)
}
export function release(): void {
running--
const next = queue.shift()
if (next) {
next()
}
}
+145
View File
@@ -0,0 +1,145 @@
import { safeStorage } from 'electron'
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import {
LEGACY_WORKSPACE_ID,
ensureOrcaDir,
ensureWorkspaceTokenDir,
getWorkspaceTokenPath
} from './linear-credential-paths'
import {
clearLegacyViewerOnDisk,
forgetLegacyViewer,
resetLegacyViewerCache
} from './linear-legacy-viewer-store'
import { emptyWorkspaceFile } from './linear-workspace-record'
import {
cacheToken,
clearCredentialError,
forgetCachedToken,
getCachedToken,
getWorkspaceFile,
getWorkspaceState,
recordCredentialError,
resetCredentialCaches,
resetWorkspaceFileCacheToEmpty,
resolveWorkspaceId,
upsertWorkspace,
writeWorkspaceFile
} from './linear-workspace-registry'
import {
CredentialDecryptionError,
readStoredCredentialToken
} from '../integration-credential-file'
import type { LinearWorkspace } from '../../shared/linear/workspace-types'
function writeEncryptedToken(path: string, apiKey: string): void {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(apiKey)
writeFileSync(path, encrypted, { mode: 0o600 })
return
}
console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext')
writeFileSync(path, apiKey, { encoding: 'utf-8', mode: 0o600 })
}
export function saveWorkspaceToken(workspaceId: string, apiKey: string): void {
ensureOrcaDir()
if (workspaceId !== LEGACY_WORKSPACE_ID) {
ensureWorkspaceTokenDir()
}
const tokenPath = getWorkspaceTokenPath(workspaceId)
writeEncryptedToken(tokenPath, apiKey)
cacheToken(workspaceId, apiKey)
clearCredentialError(workspaceId)
}
// Backward-compatible export for the legacy single-workspace storage path.
export function saveToken(apiKey: string): void {
saveWorkspaceToken(LEGACY_WORKSPACE_ID, apiKey)
}
export function loadToken(options: { force?: boolean; workspaceId?: string } = {}): string | null {
const workspaceId = options.workspaceId ?? resolveWorkspaceId()
if (!workspaceId) {
return null
}
const cached = getCachedToken(workspaceId)
if (cached !== undefined) {
return cached
}
if (!options.force) {
return null
}
const tokenPath = getWorkspaceTokenPath(workspaceId)
if (!existsSync(tokenPath)) {
return null
}
try {
const raw = readFileSync(tokenPath)
const token = readStoredCredentialToken('Linear', raw)
if (token) {
cacheToken(workspaceId, token)
}
clearCredentialError(workspaceId)
return token
} catch (error) {
if (error instanceof CredentialDecryptionError) {
recordCredentialError(workspaceId, error.message)
throw error
}
return null
}
}
export function clearTokenFile(workspaceId: string): void {
forgetCachedToken(workspaceId)
try {
unlinkSync(getWorkspaceTokenPath(workspaceId))
} catch {
// File may not exist — safe to ignore.
}
}
export function clearToken(workspaceId?: string): void {
if (!workspaceId) {
const state = getWorkspaceState()
for (const workspace of state.workspaces) {
clearTokenFile(workspace.id)
}
resetCredentialCaches()
resetLegacyViewerCache()
resetWorkspaceFileCacheToEmpty()
clearLegacyViewerOnDisk()
writeWorkspaceFile(emptyWorkspaceFile())
return
}
clearTokenFile(workspaceId)
if (workspaceId === LEGACY_WORKSPACE_ID) {
resetLegacyViewerCache()
clearLegacyViewerOnDisk()
return
}
const file = getWorkspaceFile()
const workspaces = file.workspaces.filter((workspace) => workspace.id !== workspaceId)
const activeWorkspaceId =
file.activeWorkspaceId === workspaceId ? (workspaces[0]?.id ?? null) : file.activeWorkspaceId
const selectedWorkspaceId =
file.selectedWorkspaceId === workspaceId ? activeWorkspaceId : file.selectedWorkspaceId
writeWorkspaceFile({
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
})
}
export function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void {
saveWorkspaceToken(workspace.id, token)
clearTokenFile(LEGACY_WORKSPACE_ID)
clearLegacyViewerOnDisk()
forgetLegacyViewer()
upsertWorkspace(workspace, { select: true })
}
@@ -0,0 +1,63 @@
import type { LinearWorkspace, LinearWorkspaceSelection } from '../../shared/linear/workspace-types'
export type LinearWorkspaceFile = {
version: 1
activeWorkspaceId: string | null
selectedWorkspaceId: LinearWorkspaceSelection | null
workspaces: LinearWorkspace[]
}
export function normalizeWorkspace(input: unknown): LinearWorkspace | null {
if (!input || typeof input !== 'object') {
return null
}
const record = input as Record<string, unknown>
if (typeof record.id !== 'string' || typeof record.organizationName !== 'string') {
return null
}
if (typeof record.displayName !== 'string') {
return null
}
const organizationId =
typeof record.organizationId === 'string' && record.organizationId
? record.organizationId
: record.id
return {
id: record.id,
organizationId,
organizationName: record.organizationName,
organizationUrlKey:
typeof record.organizationUrlKey === 'string' ? record.organizationUrlKey : undefined,
displayName: record.displayName,
email: typeof record.email === 'string' ? record.email : null,
credentialRevision:
typeof record.credentialRevision === 'number' && Number.isFinite(record.credentialRevision)
? record.credentialRevision
: undefined
}
}
export function emptyWorkspaceFile(): LinearWorkspaceFile {
return {
version: 1,
activeWorkspaceId: null,
selectedWorkspaceId: null,
workspaces: []
}
}
export function workspaceFromLinearData(
me: { displayName: string; email?: string | null },
org: { id: string; name: string; urlKey?: string | null }
): LinearWorkspace {
return {
id: org.id,
organizationId: org.id,
organizationName: org.name,
organizationUrlKey: org.urlKey ?? undefined,
displayName: me.displayName,
email: me.email ?? null
}
}
@@ -0,0 +1,240 @@
// ── Token + workspace storage ────────────────────────────────────────
// Why: tokens remain encrypted via safeStorage, while workspace metadata stays
// plaintext so status checks can render connected accounts without decrypting
// and triggering OS keychain prompts after app updates.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import {
LEGACY_WORKSPACE_ID,
ensureOrcaDir,
getWorkspaceFilePath,
getWorkspaceTokenPath
} from './linear-credential-paths'
import { getLegacyViewer } from './linear-legacy-viewer-store'
import {
emptyWorkspaceFile,
normalizeWorkspace,
type LinearWorkspaceFile
} from './linear-workspace-record'
import { credentialFileHasContent } from '../integration-credential-file'
import type { LinearWorkspace } from '../../shared/linear/workspace-types'
let cachedTokens = new Map<string, string>()
// Why: decrypt failures are recorded per workspace so getStatus can explain
// failing reads without re-touching the keychain on every status poll.
const credentialErrors = new Map<string, string>()
let cachedWorkspaceFile: LinearWorkspaceFile | null = null
let workspaceFileLoadedFromDisk = false
export function getCachedToken(workspaceId: string): string | undefined {
return cachedTokens.get(workspaceId)
}
export function cacheToken(workspaceId: string, token: string): void {
cachedTokens.set(workspaceId, token)
}
export function forgetCachedToken(workspaceId: string): void {
cachedTokens.delete(workspaceId)
credentialErrors.delete(workspaceId)
}
export function resetCredentialCaches(): void {
cachedTokens = new Map()
credentialErrors.clear()
}
export function recordCredentialError(workspaceId: string, message: string): void {
credentialErrors.set(workspaceId, message)
}
export function clearCredentialError(workspaceId: string): void {
credentialErrors.delete(workspaceId)
}
export function getCredentialError(workspaceId: string): string | undefined {
return credentialErrors.get(workspaceId)
}
export function resetWorkspaceFileCacheToEmpty(): void {
cachedWorkspaceFile = emptyWorkspaceFile()
workspaceFileLoadedFromDisk = true
}
function readWorkspaceFileFromDisk(): LinearWorkspaceFile {
const path = getWorkspaceFilePath()
if (!existsSync(path)) {
return emptyWorkspaceFile()
}
try {
const raw = readFileSync(path, { encoding: 'utf-8' })
const parsed = JSON.parse(raw) as Partial<LinearWorkspaceFile>
const workspaces = Array.isArray(parsed.workspaces)
? parsed.workspaces
.map((workspace) => normalizeWorkspace(workspace))
.filter((workspace): workspace is LinearWorkspace => workspace !== null)
.filter((workspace) => hasStoredToken(workspace.id))
: []
const activeWorkspaceId =
typeof parsed.activeWorkspaceId === 'string' &&
workspaces.some((workspace) => workspace.id === parsed.activeWorkspaceId)
? parsed.activeWorkspaceId
: (workspaces[0]?.id ?? null)
const selectedWorkspaceId =
parsed.selectedWorkspaceId === 'all' ||
(typeof parsed.selectedWorkspaceId === 'string' &&
workspaces.some((workspace) => workspace.id === parsed.selectedWorkspaceId))
? parsed.selectedWorkspaceId
: activeWorkspaceId
return {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
}
} catch {
return emptyWorkspaceFile()
}
}
export function getWorkspaceFile(): LinearWorkspaceFile {
if (!workspaceFileLoadedFromDisk || !cachedWorkspaceFile) {
cachedWorkspaceFile = readWorkspaceFileFromDisk()
workspaceFileLoadedFromDisk = true
}
return cachedWorkspaceFile
}
export function writeWorkspaceFile(file: LinearWorkspaceFile): void {
ensureOrcaDir()
const persistedWorkspaces = file.workspaces.filter(
(workspace) => workspace.id !== LEGACY_WORKSPACE_ID
)
const selectableIds = new Set(persistedWorkspaces.map((workspace) => workspace.id))
if (hasStoredToken(LEGACY_WORKSPACE_ID)) {
selectableIds.add(LEGACY_WORKSPACE_ID)
}
const activeWorkspaceId =
file.activeWorkspaceId && selectableIds.has(file.activeWorkspaceId)
? file.activeWorkspaceId
: (persistedWorkspaces[0]?.id ??
(selectableIds.has(LEGACY_WORKSPACE_ID) ? LEGACY_WORKSPACE_ID : null))
const selectedWorkspaceId =
file.selectedWorkspaceId === 'all'
? 'all'
: file.selectedWorkspaceId && selectableIds.has(file.selectedWorkspaceId)
? file.selectedWorkspaceId
: activeWorkspaceId
cachedWorkspaceFile = {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces: persistedWorkspaces
}
workspaceFileLoadedFromDisk = true
writeFileSync(getWorkspaceFilePath(), JSON.stringify(cachedWorkspaceFile, null, 2), {
encoding: 'utf-8',
mode: 0o600
})
}
export function getLegacyWorkspace(): LinearWorkspace | null {
if (!hasStoredToken(LEGACY_WORKSPACE_ID)) {
return null
}
const viewer = getLegacyViewer()
return {
id: LEGACY_WORKSPACE_ID,
organizationId: viewer?.organizationId ?? LEGACY_WORKSPACE_ID,
organizationName: viewer?.organizationName ?? 'Saved Linear workspace',
organizationUrlKey: viewer?.organizationUrlKey,
displayName: viewer?.displayName ?? 'Linear API key',
email: viewer?.email ?? null,
isLegacy: true
}
}
export function getWorkspaceState(): LinearWorkspaceFile {
const file = getWorkspaceFile()
const legacyWorkspace = getLegacyWorkspace()
const workspaces = [
...(legacyWorkspace ? [legacyWorkspace] : []),
...file.workspaces.filter((workspace) => hasStoredToken(workspace.id))
]
const activeWorkspaceId =
file.activeWorkspaceId &&
workspaces.some((workspace) => workspace.id === file.activeWorkspaceId)
? file.activeWorkspaceId
: (workspaces[0]?.id ?? null)
const selectedWorkspaceId =
file.selectedWorkspaceId === 'all'
? 'all'
: file.selectedWorkspaceId &&
workspaces.some((workspace) => workspace.id === file.selectedWorkspaceId)
? file.selectedWorkspaceId
: activeWorkspaceId
return {
version: 1,
activeWorkspaceId,
selectedWorkspaceId,
workspaces
}
}
export function hasStoredToken(workspaceId?: string): boolean {
if (!workspaceId) {
return getWorkspaceState().workspaces.length > 0
}
if (cachedTokens.has(workspaceId)) {
return true
}
return credentialFileHasContent(getWorkspaceTokenPath(workspaceId))
}
export function upsertWorkspace(
workspace: LinearWorkspace,
options: { select?: boolean } = {}
): void {
const file = getWorkspaceFile()
const current = file.workspaces.find((entry) => entry.id === workspace.id)
const credentialRevision = (current?.credentialRevision ?? 0) + 1
const workspaceWithRevision = { ...workspace, credentialRevision }
const withoutCurrent = file.workspaces.filter((entry) => entry.id !== workspace.id)
const workspaces = [...withoutCurrent, workspaceWithRevision].sort((a, b) =>
a.organizationName.localeCompare(b.organizationName)
)
const selectedWorkspaceId = options.select
? workspace.id
: file.selectedWorkspaceId && file.selectedWorkspaceId !== LEGACY_WORKSPACE_ID
? file.selectedWorkspaceId
: workspace.id
writeWorkspaceFile({
version: 1,
activeWorkspaceId: workspace.id,
selectedWorkspaceId,
workspaces
})
}
export function resolveWorkspaceId(workspaceId?: string | null): string | null {
if (workspaceId && workspaceId !== 'all') {
return workspaceId
}
const state = getWorkspaceState()
if (
state.selectedWorkspaceId &&
state.selectedWorkspaceId !== 'all' &&
state.workspaces.some((workspace) => workspace.id === state.selectedWorkspaceId)
) {
return state.selectedWorkspaceId
}
if (
state.activeWorkspaceId &&
state.workspaces.some((workspace) => workspace.id === state.activeWorkspaceId)
) {
return state.activeWorkspaceId
}
return state.workspaces[0]?.id ?? null
}
+9 -3
View File
@@ -24,10 +24,16 @@ const clientEntry = (
client: { client: { rawRequest: request } }
})
vi.mock('./client', () => ({
vi.mock('./linear-request-concurrency', () => ({
acquire,
release,
clearToken,
release
}))
vi.mock('./linear-token-store', () => ({
clearToken
}))
vi.mock('./client', () => ({
getClients,
getStatus,
isAuthError: () => false
+10 -4
View File
@@ -7,14 +7,20 @@ const getClients = vi.fn()
const clearToken = vi.fn()
const isAuthError = vi.fn()
vi.mock('./client', () => ({
vi.mock('./linear-request-concurrency', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args),
release: vi.fn()
}))
vi.mock('./linear-token-store', () => ({
clearToken: (...args: unknown[]) => clearToken(...args)
}))
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args)
}))
function makeEntry(): LinearClientForWorkspace {
return {
workspace: {
+3 -8
View File
@@ -18,14 +18,9 @@ import {
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
clampLinearIssueListLimit
} from '../../shared/linear/issue-read-limits'
import {
acquire,
clearToken,
getClients,
isAuthError,
release,
type LinearClientForWorkspace
} from './client'
import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError, type LinearClientForWorkspace } from './client'
type LinearRawVariables = Record<string, unknown>
+10 -4
View File
@@ -8,14 +8,20 @@ const isAuthError = vi.fn()
const acquire = vi.fn().mockResolvedValue(undefined)
const release = vi.fn()
vi.mock('./client', () => ({
vi.mock('./linear-request-concurrency', () => ({
acquire,
release,
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args),
release
}))
vi.mock('./linear-token-store', () => ({
clearToken: (...args: unknown[]) => clearToken(...args)
}))
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClients(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args)
}))
type TeamNode = {
id: string
name: string
+3 -1
View File
@@ -7,7 +7,9 @@ import type {
LinearWorkspaceError,
LinearWorkspaceSelection
} from '../../shared/linear/workspace-types'
import { acquire, release, getClients, isAuthError, clearToken } from './client'
import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError } from './client'
import {
fetchAllTeamLabels,
fetchAllTeamMembers,
+1 -1
View File
@@ -889,7 +889,7 @@ import type {
UpdateIssueTypeBySlugArgs,
UpdateProjectItemFieldArgs,
UpdatePullRequestBySlugArgs
} from '../../shared/github/project-types'
} from '../../shared/github/project-request-types'
import {
getBaseRefDefault,
getDefaultRemote,
+13 -11
View File
@@ -1,31 +1,33 @@
import type {
GetProjectViewTableResult,
GitHubProjectCommentMutationResult,
GitHubProjectMutationResult,
ListAccessibleProjectsResult,
ListAssignableUsersBySlugResult,
ListIssueTypesBySlugResult,
ListLabelsBySlugResult,
ListProjectViewsResult,
ProjectWorkItemDetailsBySlugResult,
ResolveProjectRefResult
} from '../../shared/github/project-result-types'
import type {
AddIssueCommentBySlugArgs,
ClearProjectItemFieldArgs,
DeleteIssueCommentBySlugArgs,
GetProjectViewTableArgs,
GetProjectViewTableResult,
GitHubProjectCommentMutationResult,
GitHubProjectMutationResult,
ListAccessibleProjectsArgs,
ListAccessibleProjectsResult,
ListAssignableUsersBySlugArgs,
ListAssignableUsersBySlugResult,
ListIssueTypesBySlugArgs,
ListIssueTypesBySlugResult,
ListLabelsBySlugArgs,
ListLabelsBySlugResult,
ListProjectViewsArgs,
ListProjectViewsResult,
ProjectWorkItemDetailsBySlugArgs,
ProjectWorkItemDetailsBySlugResult,
ResolveProjectRefArgs,
ResolveProjectRefResult,
UpdateIssueBySlugArgs,
UpdateIssueCommentBySlugArgs,
UpdateIssueTypeBySlugArgs,
UpdateProjectItemFieldArgs,
UpdatePullRequestBySlugArgs
} from '../../shared/github/project-types'
} from '../../shared/github/project-request-types'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { GitHubCommentResult } from '../../shared/github/comment-types'
import type {
+13 -11
View File
@@ -164,34 +164,36 @@ import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup'
import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports'
import type { GhAuthDiagnostic } from '../shared/github/auth-types'
import type { TaskSourceContext } from '../shared/task-source-context'
import type {
GetProjectViewTableResult,
GitHubProjectCommentMutationResult,
GitHubProjectMutationResult,
ListAccessibleProjectsResult,
ListAssignableUsersBySlugResult,
ListIssueTypesBySlugResult,
ListLabelsBySlugResult,
ListProjectViewsResult,
ProjectWorkItemDetailsBySlugResult,
ResolveProjectRefResult
} from '../shared/github/project-result-types'
import type {
AddIssueCommentBySlugArgs,
ClearProjectItemFieldArgs,
DeleteIssueCommentBySlugArgs,
GetProjectViewTableArgs,
GetProjectViewTableResult,
GitHubProjectCommentMutationResult,
GitHubProjectMutationResult,
ListAccessibleProjectsArgs,
ListAccessibleProjectsResult,
ListAssignableUsersBySlugArgs,
ListAssignableUsersBySlugResult,
ListIssueTypesBySlugArgs,
ListIssueTypesBySlugResult,
ListLabelsBySlugArgs,
ListLabelsBySlugResult,
ListProjectViewsArgs,
ListProjectViewsResult,
ProjectWorkItemDetailsBySlugArgs,
ProjectWorkItemDetailsBySlugResult,
ResolveProjectRefArgs,
ResolveProjectRefResult,
UpdateIssueBySlugArgs,
UpdateIssueCommentBySlugArgs,
UpdateIssueTypeBySlugArgs,
UpdatePullRequestBySlugArgs,
UpdateProjectItemFieldArgs
} from '../shared/github/project-types'
} from '../shared/github/project-request-types'
import {
richMarkdownContextMenuCommandChannel,
richMarkdownContextMenuTargetChannel,
@@ -12,7 +12,7 @@ import { useEffect, useState } from 'react'
import { Copy, ExternalLink, RotateCw } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import type { GitHubProjectViewError } from '../../../../shared/github/project-types'
import type { GitHubProjectViewError } from '../../../../shared/github/project-result-types'
import type { GhAuthDiagnostic } from '../../../../shared/github/auth-types'
import { translate } from '@/i18n/i18n'
@@ -22,9 +22,9 @@ import type {
GitHubProjectFieldMutationValue,
GitHubProjectLabel,
GitHubProjectRow,
GitHubProjectUser,
ListIssueTypesBySlugResult
GitHubProjectUser
} from '../../../../shared/github/project-types'
import type { ListIssueTypesBySlugResult } from '../../../../shared/github/project-result-types'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { translate } from '@/i18n/i18n'
@@ -18,12 +18,14 @@ import type {
GitHubProjectOwnerType,
GitHubProjectSettings,
GitHubProjectSummary,
GitHubProjectViewSummary
} from '../../../../shared/github/project-types'
import type {
GitHubProjectViewError,
GitHubProjectViewSummary,
ListAccessibleProjectsResult,
ListProjectViewsResult,
ResolveProjectRefResult
} from '../../../../shared/github/project-types'
} from '../../../../shared/github/project-result-types'
import {
GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR,
hasBoundedGitHubProjectRefInputText,
@@ -32,15 +32,17 @@ import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
import { projectViewCacheKey } from '@/store/slices/github'
import type {
GetProjectViewTableResult,
GitHubIssueType,
GitHubProjectFieldMutationValue,
GitHubProjectRow,
GitHubProjectTable,
GitHubProjectViewError,
GitHubProjectViewSummary,
ListProjectViewsResult
GitHubProjectViewSummary
} from '../../../../shared/github/project-types'
import type {
GetProjectViewTableResult,
GitHubProjectViewError,
ListProjectViewsResult
} from '../../../../shared/github/project-result-types'
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker'
import ProjectViewList from './ProjectViewList'
@@ -17,7 +17,7 @@ import type { GlobalSettings } from '../../../../../shared/global-settings-types
import type {
GitHubProjectCommentMutationResult,
GitHubProjectMutationResult
} from '../../../../../shared/github/project-types'
} from '../../../../../shared/github/project-result-types'
import { translate } from '@/i18n/i18n'
function getRuntimeTarget(settings: Parameters<typeof getActiveRuntimeTarget>[0]) {
@@ -4,7 +4,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types'
import type {
ListAssignableUsersBySlugResult,
ListLabelsBySlugResult
} from '../../../shared/github/project-types'
} from '../../../shared/github/project-result-types'
import { clearMetadataRequestStore, createMetadataRequestStore } from './metadata-request-cache'
import { githubRepoIdentityKey } from '../../../shared/github/repository-identity-key'
import { githubProjectHost } from '../../../shared/github/project-identity'
+7 -5
View File
@@ -30,14 +30,16 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { IssueSourcePreference, Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type {
GetProjectViewTableArgs,
GetProjectViewTableResult,
GitHubProjectFieldMutationValue,
GitHubProjectMutationResult,
GitHubProjectRow,
GitHubProjectTable,
GitHubProjectViewError
GitHubProjectTable
} from '../../../../shared/github/project-types'
import type {
GetProjectViewTableResult,
GitHubProjectMutationResult,
GitHubProjectViewError
} from '../../../../shared/github/project-result-types'
import type { GetProjectViewTableArgs } from '../../../../shared/github/project-request-types'
import {
isGitHubWorkItemsSshRemoteRequiredError,
sortWorkItemsByNumber,
+132
View File
@@ -0,0 +1,132 @@
// Why: the request half of the Project IPC contract, consumed mainly by main
// and preload; kept apart from the ProjectV2 data model in `./project-types`
// so renderer components importing domain shapes don't pull in arg payloads.
import type { GitHubProjectFieldMutationValue, GitHubProjectOwnerType } from './project-types'
import type { GitHubIssueUpdate } from '../issue-mutation-types'
// ─── IPC arg shapes (shared between main, preload, renderer) ──────────
export type GetProjectViewTableArgs = {
owner: string
ownerType: GitHubProjectOwnerType
projectNumber: number
/** GitHub host (e.g. GHES); absent means github.com. */
host?: string
/** View selection precedence: viewId > viewNumber > viewName > first
* TABLE_LAYOUT view. */
viewId?: string
viewNumber?: number
viewName?: string
/** Ephemeral GitHub-search-syntax query that replaces the view's filter for
* this fetch only. The view's stored filter on GitHub is not modified.
* `undefined` uses the view's saved filter; `''` explicitly clears it for
* this fetch and gets a distinct renderer cache key. */
queryOverride?: string
}
export type ProjectWorkItemDetailsBySlugArgs = {
owner: string
repo: string
host?: string
number: number
type: 'issue' | 'pr'
}
export type UpdateProjectItemFieldArgs = {
projectId: string
host?: string
itemId: string
fieldId: string
value: GitHubProjectFieldMutationValue
}
export type ClearProjectItemFieldArgs = {
projectId: string
host?: string
itemId: string
fieldId: string
}
export type UpdateIssueBySlugArgs = {
owner: string
repo: string
host?: string
number: number
updates: GitHubIssueUpdate & { body?: string }
}
export type UpdatePullRequestBySlugArgs = {
owner: string
repo: string
host?: string
number: number
updates: { title?: string; body?: string; state?: 'open' | 'closed' }
}
export type AddIssueCommentBySlugArgs = {
owner: string
repo: string
host?: string
number: number
body: string
}
export type UpdateIssueCommentBySlugArgs = {
owner: string
repo: string
host?: string
commentId: number
body: string
}
export type DeleteIssueCommentBySlugArgs = {
owner: string
repo: string
host?: string
commentId: number
}
export type ListLabelsBySlugArgs = {
owner: string
repo: string
host?: string
}
export type ListAssignableUsersBySlugArgs = {
owner: string
repo: string
host?: string
seedLogins?: string[]
}
export type ListIssueTypesBySlugArgs = {
owner: string
repo: string
host?: string
}
export type UpdateIssueTypeBySlugArgs = {
owner: string
repo: string
host?: string
number: number
/** null clears the issue type. */
issueTypeId: string | null
}
export type ResolveProjectRefArgs = {
input: string
host?: string
}
export type ListProjectViewsArgs = {
owner: string
ownerType: GitHubProjectOwnerType
projectNumber: number
host?: string
}
export type ListAccessibleProjectsArgs = {
/** GitHub host (e.g. GHES); absent means github.com. */
host?: string
}
+103
View File
@@ -0,0 +1,103 @@
// Why: the ok/error envelopes returned by Project IPC calls all share one
// classified error, and they pull in cross-module work-item/comment/user types
// that the ProjectV2 data model in `./project-types` never needs.
import type { PRComment } from './comment-types'
import type {
GitHubIssueType,
GitHubProjectOwnerType,
GitHubProjectSummary,
GitHubProjectTable,
GitHubProjectViewSummary
} from './project-types'
import type { GitHubAssignableUser } from './pull-request-types'
import type { GitHubWorkItemDetails } from './work-item-types'
// ─── Classified errors ─────────────────────────────────────────────────
export type GitHubProjectViewErrorType =
| 'auth_required'
| 'scope_missing'
| 'not_found'
| 'unsupported_layout'
| 'too_large'
| 'schema_drift'
| 'validation_error'
| 'network_error'
| 'rate_limited'
| 'unknown'
export type GitHubProjectViewError = {
type: GitHubProjectViewErrorType
message: string
/** Populated when the error is classifiable from a GraphQL response. Never
* includes tokens or full command stdout. */
details?: { path?: (string | number)[]; code?: string }
}
export type GetProjectViewTableResult =
| { ok: true; data: GitHubProjectTable }
| {
ok: false
error: GitHubProjectViewError
/** Populated for the `too_large` case and best-effort for
* `unsupported_layout` when a cheap count-only query succeeds. */
totalCount?: number
}
export type ListAccessibleProjectsResult =
| {
ok: true
projects: GitHubProjectSummary[]
/** Why: per-org discovery can partially fail (a single org 504s while
* the rest succeed). The picker renders a banner listing the affected
* org logins so the user knows their list is incomplete and can paste
* a URL to reach missing projects. Empty when discovery was clean. */
partialFailures?: { owner: string; message: string }[]
}
| { ok: false; error: GitHubProjectViewError }
export type ResolveProjectRefResult =
| {
ok: true
owner: string
ownerType: GitHubProjectOwnerType
number: number
title: string
host?: string
// Why: when the input is a /views/{n} URL, surface the parsed view
// number so the picker can skip the view-pick step and commit the
// selection directly. Absent for owner/number shorthand and project
// URLs without a /views/ segment.
viewNumber?: number
}
| { ok: false; error: GitHubProjectViewError }
export type ListProjectViewsResult =
| { ok: true; views: GitHubProjectViewSummary[] }
| { ok: false; error: GitHubProjectViewError }
export type ProjectWorkItemDetailsBySlugResult =
| { ok: true; details: GitHubWorkItemDetails }
| { ok: false; error: GitHubProjectViewError }
// ─── Mutations ─────────────────────────────────────────────────────────
export type GitHubProjectMutationResult =
| { ok: true }
| { ok: false; error: GitHubProjectViewError }
export type GitHubProjectCommentMutationResult =
| { ok: true; comment: PRComment }
| { ok: false; error: GitHubProjectViewError }
export type ListLabelsBySlugResult =
| { ok: true; labels: string[] }
| { ok: false; error: GitHubProjectViewError }
export type ListAssignableUsersBySlugResult =
| { ok: true; users: GitHubAssignableUser[] }
| { ok: false; error: GitHubProjectViewError }
export type ListIssueTypesBySlugResult =
| { ok: true; types: GitHubIssueType[] }
| { ok: false; error: GitHubProjectViewError }
-222
View File
@@ -1,13 +1,8 @@
/* eslint-disable max-lines -- Why: this module is the single source of truth for ProjectV2 shapes (settings, IPC payloads, view/field/value types) shared across main, preload, and renderer; splitting risks circular type imports. */
// Why: ProjectV2 shapes are distinct enough from the issue/PR work-item types
// that we keep them in a dedicated module. Preload and main-process callers
// import from here directly — do not re-export through `./types.ts` just to
// match the existing import block; routing through the issue types module
// would obscure ownership of the Project surface.
import type { PRComment } from './comment-types'
import type { GitHubAssignableUser } from './pull-request-types'
import type { GitHubWorkItemDetails } from './work-item-types'
import type { GitHubIssueUpdate } from '../issue-mutation-types'
export type GitHubProjectViewLayout = 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
export type GitHubProjectOwnerType = 'organization' | 'user'
@@ -234,84 +229,6 @@ export type GitHubProjectSettings = {
} | null
}
// ─── Classified errors ─────────────────────────────────────────────────
export type GitHubProjectViewErrorType =
| 'auth_required'
| 'scope_missing'
| 'not_found'
| 'unsupported_layout'
| 'too_large'
| 'schema_drift'
| 'validation_error'
| 'network_error'
| 'rate_limited'
| 'unknown'
export type GitHubProjectViewError = {
type: GitHubProjectViewErrorType
message: string
/** Populated when the error is classifiable from a GraphQL response. Never
* includes tokens or full command stdout. */
details?: { path?: (string | number)[]; code?: string }
}
export type GetProjectViewTableResult =
| { ok: true; data: GitHubProjectTable }
| {
ok: false
error: GitHubProjectViewError
/** Populated for the `too_large` case and best-effort for
* `unsupported_layout` when a cheap count-only query succeeds. */
totalCount?: number
}
export type ListAccessibleProjectsResult =
| {
ok: true
projects: GitHubProjectSummary[]
/** Why: per-org discovery can partially fail (a single org 504s while
* the rest succeed). The picker renders a banner listing the affected
* org logins so the user knows their list is incomplete and can paste
* a URL to reach missing projects. Empty when discovery was clean. */
partialFailures?: { owner: string; message: string }[]
}
| { ok: false; error: GitHubProjectViewError }
export type ResolveProjectRefResult =
| {
ok: true
owner: string
ownerType: GitHubProjectOwnerType
number: number
title: string
host?: string
// Why: when the input is a /views/{n} URL, surface the parsed view
// number so the picker can skip the view-pick step and commit the
// selection directly. Absent for owner/number shorthand and project
// URLs without a /views/ segment.
viewNumber?: number
}
| { ok: false; error: GitHubProjectViewError }
export type ListProjectViewsResult =
| { ok: true; views: GitHubProjectViewSummary[] }
| { ok: false; error: GitHubProjectViewError }
export type ProjectWorkItemDetailsBySlugResult =
| { ok: true; details: GitHubWorkItemDetails }
| { ok: false; error: GitHubProjectViewError }
// ─── Mutations ─────────────────────────────────────────────────────────
export type GitHubProjectMutationResult =
| { ok: true }
| { ok: false; error: GitHubProjectViewError }
export type GitHubProjectCommentMutationResult =
| { ok: true; comment: PRComment }
| { ok: false; error: GitHubProjectViewError }
export type GitHubProjectFieldMutationValue =
| { kind: 'single-select'; optionId: string }
| { kind: 'iteration'; iterationId: string }
@@ -319,142 +236,3 @@ export type GitHubProjectFieldMutationValue =
| { kind: 'number'; number: number }
/** YYYY-MM-DD. */
| { kind: 'date'; date: string }
export type ListLabelsBySlugResult =
| { ok: true; labels: string[] }
| { ok: false; error: GitHubProjectViewError }
export type ListAssignableUsersBySlugResult =
| { ok: true; users: GitHubAssignableUser[] }
| { ok: false; error: GitHubProjectViewError }
export type ListIssueTypesBySlugResult =
| { ok: true; types: GitHubIssueType[] }
| { ok: false; error: GitHubProjectViewError }
// ─── IPC arg shapes (shared between main, preload, renderer) ──────────
export type GetProjectViewTableArgs = {
owner: string
ownerType: GitHubProjectOwnerType
projectNumber: number
/** GitHub host (e.g. GHES); absent means github.com. */
host?: string
/** View selection precedence: viewId > viewNumber > viewName > first
* TABLE_LAYOUT view. */
viewId?: string
viewNumber?: number
viewName?: string
/** Ephemeral GitHub-search-syntax query that replaces the view's filter for
* this fetch only. The view's stored filter on GitHub is not modified.
* `undefined` uses the view's saved filter; `''` explicitly clears it for
* this fetch and gets a distinct renderer cache key. */
queryOverride?: string
}
export type ProjectWorkItemDetailsBySlugArgs = {
owner: string
repo: string
host?: string
number: number
type: 'issue' | 'pr'
}
export type UpdateProjectItemFieldArgs = {
projectId: string
host?: string
itemId: string
fieldId: string
value: GitHubProjectFieldMutationValue
}
export type ClearProjectItemFieldArgs = {
projectId: string
host?: string
itemId: string
fieldId: string
}
export type UpdateIssueBySlugArgs = {
owner: string
repo: string
host?: string
number: number
updates: GitHubIssueUpdate & { body?: string }
}
export type UpdatePullRequestBySlugArgs = {
owner: string
repo: string
host?: string
number: number
updates: { title?: string; body?: string; state?: 'open' | 'closed' }
}
export type AddIssueCommentBySlugArgs = {
owner: string
repo: string
host?: string
number: number
body: string
}
export type UpdateIssueCommentBySlugArgs = {
owner: string
repo: string
host?: string
commentId: number
body: string
}
export type DeleteIssueCommentBySlugArgs = {
owner: string
repo: string
host?: string
commentId: number
}
export type ListLabelsBySlugArgs = {
owner: string
repo: string
host?: string
}
export type ListAssignableUsersBySlugArgs = {
owner: string
repo: string
host?: string
seedLogins?: string[]
}
export type ListIssueTypesBySlugArgs = {
owner: string
repo: string
host?: string
}
export type UpdateIssueTypeBySlugArgs = {
owner: string
repo: string
host?: string
number: number
/** null clears the issue type. */
issueTypeId: string | null
}
export type ResolveProjectRefArgs = {
input: string
host?: string
}
export type ListProjectViewsArgs = {
owner: string
ownerType: GitHubProjectOwnerType
projectNumber: number
host?: string
}
export type ListAccessibleProjectsArgs = {
/** GitHub host (e.g. GHES); absent means github.com. */
host?: string
}