Files
orca/src/main/ipc/gitlab.ts
T
NeilandOrca 0927b9c156 fix(gitlab): load pipeline job traces in the Checks side panel (#7732) (#12266)
* test(repro): demonstrate #7732 GitLab pipeline job details never load in Checks panel

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): load pipeline job traces in the Checks side panel (#7732)

Expanding a GitLab pipeline job in the Checks panel always showed
"No inline details are available for this check.": the mapper dropped the
numeric job id, `PRCheckDetail` had nowhere to carry it, and every consumer
called the GitHub check-runs API, which returns null for a GitLab job.

- carry `gitlabJobId` on `PRCheckDetail` and add the `gitlab-job:` branch to
  all three identity ladders (panel rows, editor tabs, fix-prompt keys) so
  same-stage jobs with no web_url stop colliding
- add a runtime-routed trace client so SSH/remote workspaces work, not just
  local IPC, and thread the MR's `projectRef` for fork pipelines
- bound the trace in main via the existing `sliceCheckLogTail` (now shared,
  not GitHub-only) so a multi-megabyte CI log never crosses the 1 MB
  transport frame cap; strip ANSI/section markers up to the CR only, which
  keeps each section's visible header and command echo
- render the excerpt inline instead of "Log tail available in full details."
- feed GitLab traces to "Fix with AI", which previously sent bare check names
- skip the fetch for jobs that cannot have a trace (created/manual/skipped)
  so GitLab's 404 does not replace the benign empty state, and re-arm a
  failed load when the job's state changes since the panel has no retry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): treat a missing job log as an empty log, not an error (#7732)

Round-1 review follow-up.

- a job canceled before it started (or whose log was erased/expired) is
  `completed`/`cancelled`, so the panel fetched its trace, GitLab answered 404,
  and `classifyGlabError`'s issue-edit copy ("Issue not found — it may have been
  deleted.") landed verbatim on the auto-expanded check row; main now maps that
  404 to an empty trace so the row keeps its benign empty state
- keep a missing project a real error (GitLab masks unauthorized projects as
  404) and add `classifyJobLogError` so 403/unknown failures stop borrowing
  issue-edit wording on a job-log read
- broaden the empty-log copy in all five catalogs: it now covers erased and
  expired logs, not only jobs that never ran
- e2e: derive the repro screenshot dir from `process.cwd()` (or an env
  override) instead of a hardcoded POSIX path to a throwaway worktree
- bound the raw trace before the ANSI/section passes so a multi-megabyte log
  is not scanned in full on the main-process event loop
- drop the redundant `if (repo)` in `handleFixChecksWithAI` and the now-dead
  "Log tail available in full details." catalog entry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): address review — project ref on reload, retry re-arm, IPC timeout

- Carry the MR's GitLab project ref on the check-details tab so reloading a
  fork/cross-project job tab fetches the trace from the pipeline's own project.
- Re-arm the sidebar retry when a details load resolves to null, not only when
  it throws; a detail-less row otherwise never retried after the job moved on.
- Bound the local `gl.jobTrace` IPC call with the same 30s timeout the runtime
  RPC path uses — glab runs without a subprocess timeout in main.
- Document that the trace 404 -> empty-log mapping is deliberately broad.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:48:37 -07:00

604 lines
17 KiB
TypeScript

/* 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,
Repo
} from '../../shared/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
} 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 } }]
: []
}
export function registerGitLabHandlers(store: Store): void {
ipcMain.handle('gitlab:viewer', async () => {
return getAuthenticatedViewer()
})
ipcMain.handle('gitlab:diagnoseAuth', async () => diagnoseAuth())
ipcMain.handle(
'gitlab:rateLimit',
async (_event, args?: { force?: boolean; host?: string | null }) =>
getRateLimit({ force: Boolean(args?.force), host: args?.host ?? null })
)
ipcMain.handle('gitlab:projectSlug', async (_event, args: GitLabRepoSelectorArgs) => {
const repo = assertRegisteredRepo(args, store)
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)
)
}
)
// Why: My Todos surface — cross-project, user-scoped. The repoPath is
// only used for the registered-repo guard; `glab api todos` doesn't
// care about cwd because the endpoint is user-scoped.
ipcMain.handle('gitlab:todos', async (_event, args: GitLabRepoSelectorArgs) => {
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
}
)
}