Add GitLab and Bitbucket hosted review support (#1839)

* feat(gitlab): add foundational glab runner, types, and issue operations

First slice of GitLab support, mirroring src/main/github/ structurally
without refactoring the working GitHub path.

- runner: add glabExecFileAsync parallel to ghExecFileAsync (same WSL
  routing and retry policy; HTTP-status / network classification is
  provider-agnostic so the existing helpers are reused).
- types: GitLabProjectRef carries host alongside path so self-hosted
  instances and nested groups round-trip through the IPC layer. Mirror
  shapes for MR/issue/work-item/comment/file/assignable-user.
- gitlab/gl-utils: concurrency limiter, error classification, project-ref
  resolution honoring upstream/origin preference, and known-host
  discovery via `glab auth status` so non-gitlab.com remotes are
  recognized after the user authenticates.
- gitlab/mappers: pipeline-job → check-status mapping, MR state
  resolution (including draft inferred from `Draft:`/`WIP:` title
  prefix), and pipeline rollup.
- gitlab/issues: full issue CRUD via `glab api` against URL-encoded
  project paths, with the same upstream/origin preference semantics as
  the GitHub side.

63 unit tests passing across gl-utils / mappers / issues. Both
typecheck:node and typecheck:web clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): preflight glab auth check and URL parser

- preflight: probe `glab --version` + `glab auth status` alongside the
  existing gh checks. PreflightStatus.glab is optional so renderer call
  sites that only render git/gh keep typechecking; consumers gating on
  GitLab affordances opt in via `glab?.authenticated`.
- gitlab-links: parse GitLab issue and merge-request URLs honoring (a)
  arbitrary self-hosted hosts via the project-internal `/-/` separator
  rather than locking to gitlab.com, (b) nested group paths, and (c)
  GitLab's `!42` MR convention alongside `#42`.

26 unit tests added (5 new preflight cases, 21 URL-parser cases). Full
typecheck (node + cli + web) clean. Pre-existing runtime/orchestration
test failures unrelated to this branch — Node 25 vs the project's
pinned Node 24 engine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): MR list/get + paginated `glab api -i` helper

Lean mirror of github/client.ts focused on the workspace-from-MR
keystone. Adopts GitLab-native filter semantics (Open / Merged /
Closed / All) instead of porting GitHub's search-DSL — that path is
covered by the upcoming My Todos surface.

- gl-utils: glabApiWithHeaders + parseGlabApiResponse for strict
  pagination via X-Total / X-Total-Pages on `glab api -i` output.
  CRLF / LF tolerant; status line never leaks into the headers map.
- types: MRListState, GitLabPagedResult<T>, ListMergeRequestsResult.
- mappers: mapMRToWorkItem + mapIssueToWorkItem produce the unified
  GitLabWorkItem shape the picker consumes. isCrossRepository derived
  from source_project_id !== target_project_id; deterministic id
  fallback when the per-MR detail endpoint omits global id.
- client: getAuthenticatedViewer, getMergeRequest (with head pipeline
  rolled up), getMergeRequestForBranch (mirrors github/getPRForBranch
  semantics including refs/heads/ stripping and detached-HEAD guard),
  listMergeRequests (paginated), getWorkItemByProjectRef (paste-URL
  flow). Re-exports issues + projectRef helpers so callers don't have
  to know the gl-utils module split.

35 new tests (98 total in src/main/gitlab/), full typecheck clean.
Tests split into client.test.ts + client-mr.test.ts to stay under the
oxlint max-lines budget — matches github/client*.test.ts pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): worktrees:resolveMrBase IPC + linkedGitLab* persistence

The workspace-from-MR keystone. Mirror of worktrees:resolvePrBase
shape and semantics — caller passes mrIid (with optional source_branch
/ isCrossRepository hints), handler returns either a remote/branch
ref (same-project MRs) or a SHA fetched from
refs/merge-requests/<iid>/head (fork MRs).

- types: linkedGitLabMR / linkedGitLabIssue on Worktree + WorktreeMeta.
  Marked optional so existing test fixtures and persisted older
  worktrees that pre-date these fields keep typechecking and loading
  without a migration.
- persistence: getDefaultWorktreeMeta initializes both fields to null.
- worktree-logic: mergeWorktree carries them through from meta.
- worktrees IPC: resolveMrBase mirrors resolvePrBase. Resolves the
  GitLab project via getProjectRef + known-host discovery, fetches the
  MR work-item to derive source_branch + isCrossRepository when those
  hints aren't provided, and uses GitLab's refs/merge-requests/<iid>/head
  for fork MRs (parallel of GitHub's refs/pull/<N>/head).
- tests: 6 fixture updates for the new optional fields. Full
  typecheck (node + cli + web) clean; 165 tests passing across
  src/main/gitlab/, preflight, worktree-logic, and gitlab-links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): IPC channels + preload bindings (gl.*)

Wire the GitLab backend to the renderer. Lean v1 surface — issues
CRUD, MR list/get/getForBranch, viewer, project slug, paste-URL
work-item lookup. Skips workItemDetails / listWorkItems-combined /
listTodos until the matching backend pieces land.

- main/ipc/gitlab.ts: thirteen handlers under the `gitlab:*` channel
  prefix with the same assertRegisteredRepo guard the gh handlers use.
  listIssues unwraps the structured result envelope to bare items[]
  to match window.api.gh.listIssues' shape; consumers that need the
  classified error can graduate to the envelope later.
- main/ipc/register-core-handlers.ts: register alongside gh.
- preload/api-types.ts: typed `gl: { ... }` block parallel to the
  existing `gh: { ... }`. Imports the new GitLab types so renderer
  code consuming the preload gets full inference.
- preload/index.ts: runtime `gl: { ... }` exposes wired to ipcRenderer.

Full typecheck (node + cli + web) clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): workspace-from-MR via paste-URL (keystone end-to-end)

The first user-visible GitLab moment. Pasting a GitLab issue or MR URL
into the workspace name field now resolves through the full pipeline
to a created workspace with the right base ref and linkedGitLab*
persisted. The dedicated GitLab tab + state-filter chips remain a
follow-up; everything below it is wired.

- shared/lib/new-workspace.ts: LinkedWorkItemSummary.type accepts
  `'mr'` alongside `'issue' | 'pr'`. Renderer code that switches on
  type explicitly handles each kind.
- ui store slice: NewWorkspaceDraft mirrors the new linked slots so
  drafts persist GitLab selections across navigation. Optional fields
  for backward compatibility with drafts saved before this branch.
- useComposerState:
  - linkedGitLabIssue / linkedGitLabMR state, draft persistence,
    repo-switch reset, applyWorktreeMeta wiring.
  - applyLinkedGitLabWorkItem mirrors applyLinkedWorkItem; reuses
    getLinkedWorkItemSuggestedName by structurally projecting the
    GitLab item onto the helper's input shape.
  - handleSmartGitLabItemSelect parallels handleSmartGitHubItemSelect:
    for picked MRs, calls window.api.worktrees.resolveMrBase to
    resolve the base ref (refs/merge-requests/<iid>/head for fork
    MRs) and threads it through handleBaseBranchMrSelect.
  - "was MR !N" reset hint when a repo switch wipes a GitLab
    selection — `!N` matches gitlab.com's MR-reference convention.
- preload: window.api.worktrees.resolveMrBase + window.api.gl.* are
  already in. ComposerCardProps grows onSmartGitLabItemSelect (+
  optional onBaseBranchMrSelect).
- SmartWorkspaceNameField:
  - Paste-URL detection: parseGitLabIssueOrMRLink (host-agnostic via
    `/-/` separator) → window.api.gl.workItemByPath → row in the
    dropdown → click → forwarded to onGitLabItemSelect.
  - SmartWorkspaceNameSelection union, RowEntry union, RowIcon,
    RowLabel, SelectionIcon all carry the gitlab-mr / gitlab-issue
    kinds. MR rows show `!N` prefix; issue rows show `#N`.
  - Tab UI not added in this commit — paste-URL works in 'smart'
    mode, the dedicated tab + Open/Merged/Closed/All chips lands
    in a follow-up.
- NewWorkspaceComposerCard: forwards onSmartGitLabItemSelect to the
  picker.

Full typecheck (node + cli + web) clean. 165 unit tests passing in
affected files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): GitLab tab in SmartWorkspaceNameField with state filter

The discoverable demo path. The picker now has a "GitLab" tab — when
selected it lists the project's MRs filtered by state via
`gitlab:listMRs`, with an Open / Merged / Closed / All chip strip
that mirrors gitlab.com's MR-page tab strip. Paste-URL detection in
'smart' mode is unchanged; the new tab simply makes the surface
discoverable without requiring a URL.

- SmartNameMode gains 'gitlab'; Gitlab icon (lucide) added to the
  MODES array between GitHub and Branch.
- MrStateFilter / MR_STATE_FILTERS centralizes the four chip values
  so the labels stay GitLab-native (Open vs the GraphQL 'opened').
- listMRs effect: fires when mode === 'gitlab' and no GitLab URL is
  in the input, with the current state filter and a page-1 fetch
  bounded by RESULT_LIMIT.
- Paste-URL effect now coexists with the list effect: it owns
  gitlabItems while a URL is in the input, the list effect owns it
  otherwise. Switching tabs no longer clears the list.
- Chip strip rendered above the popover's CommandList only when
  mode === 'gitlab'. Buttons use the same Button component the rest
  of the picker uses for visual consistency.

Full typecheck (node + cli + web) clean. 165 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): GitLab source on Tasks screen

The Tasks screen now offers GitLab as a third source alongside GitHub
and Linear. Selecting it surfaces MRs and issues for the primary
selected repo with a state filter (Open / Merged / Closed / All) that
mirrors gitlab.com's MR-page tab strip. Skips cross-repo aggregation,
search DSL, and Projects mode for v1 — those layers are GitHub-API-
shaped and would need a parallel store slice that is not worth porting
ahead of the actual demand for them.

- shared/types: GlobalSettings.defaultTaskSource accepts 'gitlab'.
- TaskPage:
  - TaskSource union grows a 'gitlab' member; SOURCE_OPTIONS adds the
    Gitlab icon between GitHub and Linear so the toolbar order matches
    SmartWorkspaceNameField for cross-surface consistency.
  - GITLAB_TASK_FILTERS centralizes the four chip values.
  - Per-source state slim (matches Linear's pattern) — gitlabFilter,
    gitlabItems, gitlabLoading, gitlabError, gitlabRefreshNonce.
  - Data-fetch effect runs Promise.all over `window.api.gl.listMRs`
    and `window.api.gl.listIssues` for the primary repo, merges and
    sorts by updatedAt desc. 'merged' filter skips the issue fetch
    (GitLab issues are 'opened' / 'closed' only).
  - Filter bar block parallel to Linear's, with chips + a refresh
    icon-button.
  - List block: 5-column grid (ID / Title / Type+State / Updated /
    Open-link). Row click opens the web URL — the GitLabItemDialog
    is a follow-up commit, but the row affordance is enough for the
    Tasks-screen demo.
  - GitLab MRs render as `!N`; issues render as `#N` to match
    gitlab.com's reference convention.

Full typecheck (node + cli + web) clean. 165 unit tests still
passing in affected files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): GitLabItemDialog (minimal) + Tasks screen wiring

Clicking a GitLab row on the Tasks screen now opens a side-sheet
preview with the item's title, state, author, and description body
rendered as markdown. "Open in browser" footer button stays as the
escape hatch; opening from the row is dialog-first now (matching the
GitHub side's row-click-to-dialog pattern). Files / comments /
pipeline tabs are deferred — they mirror substantial GitHub-side
surface area (work-item-details ~550 lines, GitHubItemDialog 2680
lines) and are not blocking the demo.

- types: MRInfo and GitLabIssueInfo gain optional description /
  author / authorAvatarUrl. Optional because list endpoints strip
  them; populated on detail-endpoint reads (`getMR` / `getIssue`).
- mappers: mapMRInfo and mapGitLabIssueInfo now pass description /
  author / avatar through when present. Skipped (rather than
  defaulted to '') so callers can distinguish "no body authored"
  from "this came from a list".
- GitLabItemDialog: new ~200-line side sheet. Fetches the detail
  payload via `window.api.gl.mr` / `gl.issue` on open; renders
  CommentMarkdown for the description (reused from the GitHub
  side); falls back to "No description." when the body is blank.
  State badge tones picked locally — GitLab's MR state space is
  wider than GitHub's so coupling them buys nothing.
- TaskPage: GitLab row now uses a div role=button with keyboard
  handling so the inner Open-in-browser <button> nests cleanly
  (HTML disallows nested <button>s, React would warn). Row click
  sets gitlabDialogItem; the small ExternalLink icon stops
  propagation so it still opens the URL.

Full typecheck (node + cli + web) clean. 165 unit tests passing in
affected files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): My Todos cross-project view on Tasks screen

The GitLab tab now has a Project | My Todos sub-toggle. "My Todos"
fetches gitlab.com/dashboard/todos via `glab api todos?state=pending`
and surfaces them in a separate table — action / title / project /
updated. This is the closest GitLab-native equivalent of GitHub's
notifications/inbox and lands in lieu of porting GitHub's search-DSL
which doesn't translate.

- shared/types: GitLabTodo type with action_name, target_type/iid,
  target_url, project_path, author, updated_at. action_name kept as
  open-ended string because new GitLab versions extend the verb set.
- gitlab/client.ts: listTodos uses `glab api --paginate todos?state=
  pending&per_page=50`. User-scoped — cwd doesn't matter, but the
  IPC path-validation guard still requires *some* registered repo
  path so we keep the signature consistent with the rest of gl.*.
- IPC: `gitlab:todos` channel; preload `gl.todos`.
- TaskPage:
  - gitlabView ('project' | 'todos') gates which list to render.
  - Sub-toggle row above the chip strip; chips are hidden on the
    Todos view since pending state has no Open/Merged/Closed axis.
  - Refresh button serves both views (uses gitlabRefreshNonce).
  - Todos table: 5-col grid, action verb (snake_case → spaces),
    target title, project path (mono font for repo-likeness),
    updated date, open-link icon. Row click opens target_url.

Full typecheck (node + cli + web) clean. 165 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): gitlabProjects settings (recents auto-tracked) + tests

Settings persistence for GitLab project preferences plus tests for
the surface added since the last green run.

- shared/types: GitLabProjectSettings { pinned, recent } and an
  optional GlobalSettings.gitlabProjects slot. Optional for
  backward compat with profiles saved before this branch — the
  persistence merge fills the empty default.
- shared/gitlab-projects: pure helper computeNextGitLabRecents that
  prepends-and-dedupes by host+path, caps at GITLAB_RECENTS_MAX
  (10). Pulled out of the IPC handler so it tests without mocking
  Store.
- gitlab IPC: workItemByPath handler now pushes the resolved
  project ref onto recents on success. 404 / auth-fail lookups
  do not pollute the list — recents reflects projects the user
  actually read.

Tests added (12 new, 177 total passing in affected files):
- gitlab-projects.test: prepend, dedupe, host-vs-host distinct,
  cap at max, no input mutation.
- client.test: listTodos mapping, defensive state coercion,
  empty-on-error fallback, missing-target field defaults.
- mappers.test: description / author / authorAvatarUrl pass-
  through on both mapMRInfo and mapGitLabIssueInfo, plus the
  "absent vs blank" distinguishing assertion.
- mappers-workitem.test (split): mapMRToWorkItem + mapIssueToWorkItem
  cases moved out of mappers.test.ts to keep both files under the
  oxlint max-lines budget.

Full typecheck (node + cli + web) clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): combined listWorkItems IPC + TaskPage refactor

Centralize the MR + issue merge logic that TaskPage was doing inline
into a single backend function and IPC channel. Future callers (the
picker's GitLab tab, any new widget) get the merge / sort / state-
mapping rule for free. The TaskPage effect drops from 60 lines of
inline orchestration to a single call.

- gitlab/issues: listIssues now accepts an IssueListState so the
  combined caller can ask for closed / all instead of always opened.
  CLI fallback path picks the right --opened / --closed / --all flag
  per glab version. Existing callers keep the 'opened' default.
- gitlab/client: listWorkItems(state, page, perPage, preference) fans
  out listMergeRequests + a raw issues fetch in parallel, merges by
  updatedAt desc, returns a GitLabPagedResult<GitLabWorkItem>.
  Bypasses listIssues for the issues side because IssueInfo strips
  updated_at — the combined sort needs it.
  state='merged' skips the issues fetch entirely (issues don't have
  a merged lifecycle).
- IPC: new gitlab:listWorkItems handler.
- preload: gl.listWorkItems alongside gl.listMRs.
- TaskPage: GitLab fetch effect now calls gl.listWorkItems and stops
  re-implementing the merge. Same UX, fewer moving parts.

Tests added (8 new in client-work-items.test.ts; +1 fix to
issues.test.ts for the new url-param order):
- merge ordering by updatedAt desc
- 'merged' state skips issues fetch
- closed / all state pass-through
- not_found envelope when project ref unresolved
- mr-error vs issue-side success interleaving
- combined error surfacing on either side failing

Full typecheck (node + cli + web) clean. 117 unit tests passing in
src/main/gitlab/ and src/shared/gitlab-projects.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): work-item-details + dialog Conversation/Pipeline tabs

Task 3 lean version. The minimal description-only dialog grows two
new tabs (Conversation / Pipeline) and four footer actions
(close / reopen / merge / comment). Files-tab and inline review-
comment positioning stay deferred — they mirror substantial GitHub-
side surface (GitHubItemDialog is 2680 lines, work-item-details.ts is
551) and the v1 demo doesn't need them.

- shared/types: GitLabPipelineJob (id, name, stage, status, webUrl,
  duration), GitLabWorkItemDetails (item + body + comments[] +
  pipelineJobs?[]). Mirrors GitHubWorkItemDetails layout.
- main/gitlab/work-item-details: getWorkItemDetails(repoPath, iid,
  type) fans out parallel reads — issue: detail + discussions; MR:
  detail + discussions, then pipeline jobs follow-up keyed off
  head_pipeline.id. Discussion → MRComment flatten skips system
  notes (auto-generated activity entries) so the conversation tab
  shows only user content. Inline-review position carried through
  as `path` + `line` for v1.5 to consume.
- main/gitlab/client: closeMR / reopenMR / mergeMR / addMRComment
  mutations. mergeMR accepts the same 'merge' | 'squash' | 'rebase'
  union as the GitHub side; close/reopen treat "already X" stderr
  as success since the desired state is reached.
- IPC: gitlab:workItemDetails, closeMR, reopenMR, mergeMR,
  addMRComment channels; preload `gl.*` bindings parallel.
- GitLabItemDialog rewrite: three Tabs (Description / Conversation /
  Pipeline-MRs-only) + footer with comment composer + state-aware
  Merge / Close / Reopen buttons. Cmd/Ctrl+Enter sends the comment
  to match gitlab.com's textarea shortcut. Refresh icon in the
  header re-fetches via a refreshNonce. eslint-disable max-lines on
  the dialog matches the GitHub-side equivalent's reasoning.

Full typecheck (node + cli + web) clean. 184 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): sidebar icon, Smart-mix MRs, Integrations card, "Project MRs" rename

Four follow-up fixes that surfaced from smoke-testing:

- SidebarNav: GitLab icon next to GitHub / Linear in the Tasks-row
  shortcut strip; clicks open the Tasks page already filtered to the
  GitLab source. ui.ts taskPageData.taskSource union grows to accept
  'gitlab' so the openTaskPage call typechecks.
- SmartWorkspaceNameField: list-MRs effect now fires in 'smart' mode
  too, not just on the dedicated GitLab tab. The mixed picker
  surfaces the user's project MRs alongside GitHub items. Paste-URL
  effect still wins when a GitLab URL is in the input — the list
  effect bails on parsedGlLink !== null.
- TaskPage: GitLab toggle relabels "Project" → "Project MRs" so the
  pairing with "My Todos" reads more clearly.
- IntegrationsPane: new GitLab card mirroring the GitHub card —
  status badge (checking / connected / not-installed / not-
  authenticated), install link to gitlab.com/gitlab-org/cli, copy-
  ready `glab auth login` block, learn-more link to the auth/login
  doc, re-check button. Search-entry registered so settings search
  finds it. eslint-disable max-lines justified by the same pattern
  that already lives there for GitHub + Linear.
- preload: PreflightStatus.glab is optional on the type so older
  payloads typecheck; consumers gate on the optional chain.

Full typecheck (node + cli + web) clean. 184 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gitlab): multi-repo aggregation on Tasks screen

Mirrors GitHub's cross-repo behavior. Previously the GitLab tab only
queried the first selected repo; now it fans out to every eligible
selected repo in parallel and merges results sorted by updatedAt
desc. The repo selector at the top of Tasks is the project picker —
it's the same one the GitHub tab uses, so the selection model is
consistent across providers.

- TaskPage gitlab fetch effect: Promise.allSettled across all
  selectedRepos that aren't SSH-relay (folder-mode repos and remote
  worktrees fall through). Each repo's project is resolved from its
  own git remote by the main process; non-GitLab repos return
  not_found which the renderer drops silently so a mixed selection
  (GitHub + GitLab repos) doesn't surface false errors on the GitLab
  tab.
- Per-row repoId tagging stays correct — items keep their source
  repo's id through the merge, which matters for the dialog repoPath
  resolution below.
- Banner display: only shown when EVERY eligible repo failed; partial
  failure is signaled by the row count being lower, not a banner that
  overshadows working repos.
- GitLabItemDialog repoPath: derived from the clicked item's
  source repo (selectedRepos.find by repoId) instead of primaryRepo.
  Without this, clicking an item from a non-primary repo would route
  the detail fetch through the wrong repo's remote.

Full typecheck (node + cli + web) clean. 117 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gitlab): swap MR icon to GitMerge for visual distinction

GitPullRequest (curved-merge) reads similar to GitBranch (forking
line) at the small sizes we use in the picker — feedback was that
MR rows looked like branch rows. GitMerge (arrow-merge-into-line)
reads as its own thing and matches gitlab.com's MR iconography, so
users coming from the web UI find it familiar.

GitHub PRs keep GitPullRequest — that matches github.com and keeps
provider attribution distinct from GitLab MRs at a glance:
  GitHub PR: GitPullRequest (curved merge)
  GitLab MR: GitMerge (arrow merge)
  Branch:    GitBranch (fork)
  Issue:     CircleDot (provider-agnostic)

- SmartWorkspaceNameField RowIcon + SelectionIcon: gitlab-mr →
  GitMerge. github-pr stays GitPullRequest.
- GitLabItemDialog header icon: GitMerge for MRs.

Full typecheck (node + cli + web) clean. 117 unit tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(gitlab): split shared types + preload into per-provider files

Pre-emptive merge-conflict reduction. The two recent main syncs
each surfaced ~5 conflicts, all in the same handful of central
files where every provider lands code. Moving the GitLab footprint
into provider-scoped files cuts the conflict surface roughly in half
without changing any runtime behavior.

- shared/gitlab-types.ts (new, 272 lines): every standalone GitLab
  type that previously lived in shared/types.ts —
  GitLabProjectRef / MRState / MRMergeableState / MRCheckDetail /
  MRInfo / GitLabReaction / MRComment / GitLabCommentResult /
  GitLabIssueInfo / GitLabViewer / GitLabAssignableUser /
  GitLabWorkItem / GitLabMRFile / GitLabProjectSettings /
  GitLabTodo[TargetType] / GitLabPipelineJob /
  GitLabWorkItemDetails / GitLabIssueUpdate / MRListState /
  GitLabPagedResult / ListMergeRequestsResult.
- shared/types.ts: re-exports the GitLab types so existing call
  sites importing from '../shared/types' keep working unchanged.
  GitLabProjectSettings additionally imported locally for the
  GlobalSettings.gitlabProjects field. Worktree.linkedGitLabMR /
  WorktreeMeta.linkedGitLabIssue / GlobalSettings.defaultTaskSource
  union member stay here — they're entangled with non-GitLab
  structs and moving them out would just shuffle the conflict
  vector to a different file.
- preload/gitlab.ts (new, 106 lines): the entire gl.* runtime
  binding block — viewer / projectSlug / mrForBranch / mr /
  listMRs / listWorkItems / issue / listIssues / createIssue /
  updateIssue / addIssueComment / listLabels /
  listAssignableUsers / todos / workItemDetails / closeMR /
  reopenMR / mergeMR / addMRComment / workItemByPath. Exported as
  `glApi`.
- preload/index.ts: imports `glApi` and inlines as `gl: glApi`,
  shrinking the file by ~95 lines.

Net: the two files most prone to conflict on upstream sync
(shared/types.ts, preload/index.ts) lose ~360 lines of
GitLab-specific code that now live in their own files where main's
non-GitLab edits can't touch them.

Full typecheck (node + cli + web) clean. 190 unit tests passing
in src/main/gitlab/, src/shared/gitlab-projects.test.ts,
src/main/ipc/{preflight,worktree-logic}.test.ts,
src/renderer/src/lib/gitlab-links.test.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gitlab): satisfy pnpm pre-flight (lint + handler-registration test)

- TaskPage: lift the selected-repos identity key into a useMemo so the
  GitLab fetch effect's dep array no longer holds a complex expression
  (oxlint exhaustive-deps).
- register-core-handlers.test: mock ./gitlab alongside ./github / ./linear
  so registerGitLabHandlers doesn't try to call ipcMain.handle in a unit
  test that fakes only individual handler modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(source-control): add Bitbucket hosted review support

* fix(source-control): align hosted review lookup with provider model

---------

Co-authored-by: Emilian Stoilkov <emilian.stoilkov@qaiware.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Neil 2026-05-14 15:09:52 -07:00 committed by GitHub
parent 151040f05f
commit bca39bc928
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 7899 additions and 174 deletions

View File

@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import { getBitbucketAuthStatus, getBitbucketPullRequestForBranch } from './client'
import { _resetBitbucketRepoRefCache } from './repository-ref'
const OLD_ENV = process.env
function bitbucketPr(id = 7) {
return {
id,
title: 'Add Bitbucket',
state: 'OPEN',
updated_on: '2026-05-10T00:00:00.000Z',
links: { html: { href: `https://bitbucket.org/team/repo/pull-requests/${id}` } },
source: {
branch: { name: 'feature/bitbucket' },
commit: { hash: 'abc123' },
repository: { full_name: 'team/repo' }
},
destination: {
branch: { name: 'main' },
repository: { full_name: 'team/repo' }
}
}
}
describe('Bitbucket client', () => {
beforeEach(() => {
process.env = { ...OLD_ENV }
process.env.ORCA_BITBUCKET_API_BASE_URL = 'https://api.test.local/2.0'
process.env.ORCA_BITBUCKET_EMAIL = 'user@example.com'
process.env.ORCA_BITBUCKET_API_TOKEN = 'token'
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'git@bitbucket.org:team/repo.git\n',
stderr: ''
})
_resetBitbucketRepoRefCache()
vi.unstubAllGlobals()
})
it('fetches a branch pull request and commit build status', async () => {
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
if (url.includes('/statuses/build')) {
return Response.json({ values: [{ state: 'SUCCESSFUL' }] })
}
return Response.json({ values: [bitbucketPr()] })
})
vi.stubGlobal('fetch', fetchMock)
await expect(
getBitbucketPullRequestForBranch('/repo', 'refs/heads/feature/bitbucket')
).resolves.toEqual({
number: 7,
title: 'Add Bitbucket',
state: 'open',
url: 'https://bitbucket.org/team/repo/pull-requests/7',
status: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'UNKNOWN',
headSha: 'abc123'
})
const firstCall = fetchMock.mock.calls[0]
const listUrl = String(firstCall?.[0])
const listInit = firstCall?.[1]
if (!listInit) {
throw new Error('expected request init')
}
const parsed = new URL(listUrl)
expect(parsed.pathname).toBe('/2.0/repositories/team/repo/pullrequests')
expect(parsed.searchParams.get('q')).toBe(
'source.branch.name = "feature/bitbucket" AND (state = "OPEN" OR state = "MERGED" OR state = "DECLINED" OR state = "SUPERSEDED")'
)
expect(parsed.searchParams.getAll('state')).toEqual([
'OPEN',
'MERGED',
'DECLINED',
'SUPERSEDED'
])
expect((listInit.headers as Record<string, string>).Authorization).toBe(
`Basic ${Buffer.from('user@example.com:token').toString('base64')}`
)
})
it('falls back to a linked PR number when branch lookup misses', async () => {
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
if (url.includes('/statuses/build')) {
return Response.json({ values: [] })
}
if (url.endsWith('/pullrequests/42')) {
return Response.json(bitbucketPr(42))
}
return Response.json({ values: [] })
})
vi.stubGlobal('fetch', fetchMock)
await expect(getBitbucketPullRequestForBranch('/repo', 'different', 42)).resolves.toMatchObject(
{
number: 42,
status: 'neutral'
}
)
})
it('reports env-token auth status through the Bitbucket /user endpoint', async () => {
const fetchMock = vi.fn(async () => Response.json({ username: 'bitbucket-user' }))
vi.stubGlobal('fetch', fetchMock)
await expect(getBitbucketAuthStatus()).resolves.toEqual({
configured: true,
authenticated: true,
account: 'bitbucket-user'
})
})
it('accepts T3Code-compatible Bitbucket environment variable names', async () => {
delete process.env.ORCA_BITBUCKET_EMAIL
delete process.env.ORCA_BITBUCKET_API_TOKEN
process.env.T3CODE_BITBUCKET_EMAIL = 't3@example.com'
process.env.T3CODE_BITBUCKET_API_TOKEN = 't3-token'
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
Response.json({ username: 't3-user' })
)
vi.stubGlobal('fetch', fetchMock)
await expect(getBitbucketAuthStatus()).resolves.toEqual({
configured: true,
authenticated: true,
account: 't3-user'
})
const init = fetchMock.mock.calls[0]?.[1]
if (!init) {
throw new Error('expected request init')
}
expect((init.headers as Record<string, string>).Authorization).toBe(
`Basic ${Buffer.from('t3@example.com:t3-token').toString('base64')}`
)
})
})

View File

@ -0,0 +1,224 @@
import { Buffer } from 'buffer'
import type { CheckStatus } from '../../shared/types'
import {
deriveBitbucketBuildStatus,
mapBitbucketPullRequest,
type BitbucketPullRequestInfo,
type RawBitbucketBuildStatus,
type RawBitbucketPullRequest
} from './pull-request-mappers'
import { getBitbucketRepoRef, type BitbucketRepoRef } from './repository-ref'
const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0'
const REQUEST_TIMEOUT_MS = 5000
const ALL_PULL_REQUEST_STATES = ['OPEN', 'MERGED', 'DECLINED', 'SUPERSEDED'] as const
type BitbucketAuthConfig = {
baseUrl: string
accessToken: string | null
email: string | null
apiToken: string | null
}
export type BitbucketAuthStatus = {
configured: boolean
authenticated: boolean
account: string | null
}
type RequestOptions = {
searchParams?: Record<string, string | readonly string[]>
timeoutMs?: number
}
function envValue(primary: string, fallback: string): string | null {
const value = process.env[primary]?.trim() || process.env[fallback]?.trim() || ''
return value.length > 0 ? value : null
}
function getAuthConfig(): BitbucketAuthConfig {
return {
baseUrl:
envValue('ORCA_BITBUCKET_API_BASE_URL', 'T3CODE_BITBUCKET_API_BASE_URL') ??
DEFAULT_API_BASE_URL,
accessToken: envValue('ORCA_BITBUCKET_ACCESS_TOKEN', 'T3CODE_BITBUCKET_ACCESS_TOKEN'),
email: envValue('ORCA_BITBUCKET_EMAIL', 'T3CODE_BITBUCKET_EMAIL'),
apiToken: envValue('ORCA_BITBUCKET_API_TOKEN', 'T3CODE_BITBUCKET_API_TOKEN')
}
}
function hasAuth(config: BitbucketAuthConfig): boolean {
return Boolean(config.accessToken || (config.email && config.apiToken))
}
function authHeaders(config: BitbucketAuthConfig): Record<string, string> {
if (config.accessToken) {
return { Authorization: `Bearer ${config.accessToken}` }
}
if (config.email && config.apiToken) {
const encoded = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64')
return { Authorization: `Basic ${encoded}` }
}
return {}
}
function isStringArray(value: string | readonly string[]): value is readonly string[] {
return Array.isArray(value)
}
function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): string {
const config = getAuthConfig()
const base = config.baseUrl.replace(/\/+$/, '')
const url = new URL(`${base}${path}`)
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
if (isStringArray(value)) {
for (const item of value) {
url.searchParams.append(key, item)
}
} else {
url.searchParams.set(key, value)
}
}
}
return url.toString()
}
async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T | null> {
const config = getAuthConfig()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? REQUEST_TIMEOUT_MS)
try {
const response = await fetch(apiUrl(path, options.searchParams), {
headers: {
Accept: 'application/json',
...authHeaders(config)
},
signal: controller.signal
})
if (!response.ok) {
return null
}
return (await response.json()) as T
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
function encodedRepoPath(repo: BitbucketRepoRef): string {
return `${encodeURIComponent(repo.workspace)}/${encodeURIComponent(repo.repoSlug)}`
}
function escapeBitbucketQueryString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function allStateFilter(): string {
return `(${ALL_PULL_REQUEST_STATES.map((state) => `state = "${state}"`).join(' OR ')})`
}
async function getBuildStatus(
repo: BitbucketRepoRef,
headSha: string | undefined
): Promise<CheckStatus> {
if (!headSha) {
return 'neutral'
}
const data = await requestJson<{ values?: RawBitbucketBuildStatus[] }>(
`/repositories/${encodedRepoPath(repo)}/commit/${encodeURIComponent(headSha)}/statuses/build`,
{ searchParams: { pagelen: '100' } }
)
return deriveBitbucketBuildStatus(data?.values ?? [])
}
async function normalizePullRequest(
repo: BitbucketRepoRef,
raw: RawBitbucketPullRequest
): Promise<BitbucketPullRequestInfo | null> {
const headSha = raw.source?.commit?.hash?.trim()
const status = await getBuildStatus(repo, headSha)
return mapBitbucketPullRequest(raw, status)
}
export async function getBitbucketAuthStatus(): Promise<BitbucketAuthStatus> {
const config = getAuthConfig()
if (!hasAuth(config)) {
return { configured: false, authenticated: false, account: null }
}
const user = await requestJson<{
username?: string | null
display_name?: string | null
account_id?: string | null
}>('/user', { timeoutMs: 4000 })
return {
configured: true,
authenticated: user !== null,
account: user?.username ?? user?.display_name ?? user?.account_id ?? null
}
}
export async function getBitbucketPullRequest(
repoPath: string,
prNumber: number
): Promise<BitbucketPullRequestInfo | null> {
const repo = await getBitbucketRepoRef(repoPath)
if (!repo) {
return null
}
const raw = await requestJson<RawBitbucketPullRequest>(
`/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(prNumber))}`
)
return raw ? normalizePullRequest(repo, raw) : null
}
export async function getBitbucketPullRequestForBranch(
repoPath: string,
branch: string,
linkedPRNumber?: number | null
): Promise<BitbucketPullRequestInfo | null> {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedPRNumber == null) {
return null
}
const repo = await getBitbucketRepoRef(repoPath)
if (!repo) {
return null
}
if (branchName) {
const query = [
`source.branch.name = "${escapeBitbucketQueryString(branchName)}"`,
allStateFilter()
].join(' AND ')
const list = await requestJson<{ values?: RawBitbucketPullRequest[] }>(
`/repositories/${encodedRepoPath(repo)}/pullrequests`,
{
searchParams: {
pagelen: '1',
sort: '-updated_on',
q: query,
state: ALL_PULL_REQUEST_STATES
}
}
)
const raw = list?.values?.[0]
if (raw) {
return normalizePullRequest(repo, raw)
}
}
if (typeof linkedPRNumber !== 'number') {
return null
}
const raw = await requestJson<RawBitbucketPullRequest>(
`/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`
)
return raw ? normalizePullRequest(repo, raw) : null
}
export async function getBitbucketRepoSlug(repoPath: string): Promise<BitbucketRepoRef | null> {
return getBitbucketRepoRef(repoPath)
}

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
deriveBitbucketBuildStatus,
mapBitbucketPullRequest,
mapBitbucketPullRequestState
} from './pull-request-mappers'
describe('Bitbucket pull request mappers', () => {
it('normalizes Bitbucket pull request states', () => {
expect(mapBitbucketPullRequestState('OPEN')).toBe('open')
expect(mapBitbucketPullRequestState('MERGED')).toBe('merged')
expect(mapBitbucketPullRequestState('DECLINED')).toBe('closed')
expect(mapBitbucketPullRequestState('SUPERSEDED')).toBe('closed')
})
it('derives Orca check status from Bitbucket build statuses', () => {
expect(deriveBitbucketBuildStatus([])).toBe('neutral')
expect(deriveBitbucketBuildStatus([{ state: 'SUCCESSFUL' }])).toBe('success')
expect(deriveBitbucketBuildStatus([{ state: 'INPROGRESS' }])).toBe('pending')
expect(deriveBitbucketBuildStatus([{ state: 'FAILED' }])).toBe('failure')
})
it('maps raw pull request JSON into the shared PR-like shape', () => {
expect(
mapBitbucketPullRequest(
{
id: 42,
title: 'Add Bitbucket',
state: 'MERGED',
updated_on: '2026-05-10T00:00:00.000Z',
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } },
source: { branch: { name: 'feature' }, commit: { hash: 'abc123' } },
destination: { branch: { name: 'main' } }
},
'success'
)
).toEqual({
number: 42,
title: 'Add Bitbucket',
state: 'merged',
url: 'https://bitbucket.org/team/repo/pull-requests/42',
status: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'UNKNOWN',
headSha: 'abc123'
})
})
})

View File

@ -0,0 +1,95 @@
import type { CheckStatus, PRMergeableState } from '../../shared/types'
export type RawBitbucketPullRequest = {
id?: number
title?: string
state?: string | null
updated_on?: string | null
links?: {
html?: {
href?: string
}
}
source?: {
branch?: {
name?: string
}
commit?: {
hash?: string
} | null
}
destination?: {
branch?: {
name?: string
}
}
}
export type BitbucketPullRequestInfo = {
number: number
title: string
state: 'open' | 'closed' | 'merged'
url: string
status: CheckStatus
updatedAt: string
mergeable: PRMergeableState
headSha?: string
}
export type RawBitbucketBuildStatus = {
state?: string | null
}
export function mapBitbucketPullRequestState(
state: string | null | undefined
): BitbucketPullRequestInfo['state'] {
switch (state?.trim().toUpperCase()) {
case 'MERGED':
return 'merged'
case 'DECLINED':
case 'SUPERSEDED':
return 'closed'
case 'OPEN':
default:
return 'open'
}
}
export function deriveBitbucketBuildStatus(
statuses: readonly RawBitbucketBuildStatus[]
): CheckStatus {
if (statuses.length === 0) {
return 'neutral'
}
const states = statuses.map((status) => status.state?.trim().toUpperCase() ?? '')
if (states.some((state) => state === 'FAILED' || state === 'STOPPED' || state === 'ERROR')) {
return 'failure'
}
if (states.some((state) => state === 'INPROGRESS' || state === 'PENDING')) {
return 'pending'
}
if (states.every((state) => state === 'SUCCESSFUL')) {
return 'success'
}
return 'neutral'
}
export function mapBitbucketPullRequest(
raw: RawBitbucketPullRequest,
status: CheckStatus
): BitbucketPullRequestInfo | null {
if (typeof raw.id !== 'number' || !raw.title || !raw.links?.html?.href) {
return null
}
const headSha = raw.source?.commit?.hash?.trim()
return {
number: raw.id,
title: raw.title,
state: mapBitbucketPullRequestState(raw.state),
url: raw.links.html.href,
status,
updatedAt: raw.updated_on ?? '',
mergeable: 'UNKNOWN',
...(headSha ? { headSha } : {})
}
}

View File

@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
import {
_resetBitbucketRepoRefCache,
getBitbucketRepoRef,
parseBitbucketRepoRef
} from './repository-ref'
describe('Bitbucket repository refs', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
_resetBitbucketRepoRefCache()
})
it('parses HTTPS, SSH, and ssh:// Bitbucket remotes', () => {
expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git')).toEqual({
workspace: 'team',
repoSlug: 'project'
})
expect(parseBitbucketRepoRef('git@bitbucket.org:team/project.git')).toEqual({
workspace: 'team',
repoSlug: 'project'
})
expect(parseBitbucketRepoRef('ssh://git@bitbucket.org/team/project.git')).toEqual({
workspace: 'team',
repoSlug: 'project'
})
expect(parseBitbucketRepoRef('https://github.com/team/project.git')).toBeNull()
})
it('resolves origin through the WSL-aware git runner and caches the result', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'git@bitbucket.org:team/project.git\n',
stderr: ''
})
await expect(getBitbucketRepoRef('/repo')).resolves.toEqual({
workspace: 'team',
repoSlug: 'project'
})
await expect(getBitbucketRepoRef('/repo')).resolves.toEqual({
workspace: 'team',
repoSlug: 'project'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
})
})

View File

@ -0,0 +1,76 @@
import { gitExecFileAsync } from '../git/runner'
export type BitbucketRepoRef = {
workspace: string
repoSlug: string
}
const repoRefCache = new Map<string, BitbucketRepoRef | null>()
/** @internal - exposed for tests only */
export function _resetBitbucketRepoRefCache(): void {
repoRefCache.clear()
}
function parseBitbucketPath(pathname: string): BitbucketRepoRef | null {
const withoutSuffix = pathname.replace(/\.git$/i, '')
const parts = withoutSuffix
.split('/')
.map((part) => part.trim())
.filter(Boolean)
if (parts.length < 2) {
return null
}
const workspace = parts.at(-2)
const repoSlug = parts.at(-1)
if (!workspace || !repoSlug) {
return null
}
return {
workspace: decodeURIComponent(workspace),
repoSlug: decodeURIComponent(repoSlug)
}
}
export function parseBitbucketRepoRef(remoteUrl: string): BitbucketRepoRef | null {
const trimmed = remoteUrl.trim()
const scpLike = trimmed.match(/^(?:[^@]+@)?bitbucket\.org:([^\s]+?)(?:\.git)?$/i)
if (scpLike) {
return parseBitbucketPath(scpLike[1])
}
try {
const url = new URL(trimmed)
if (url.hostname.toLowerCase() !== 'bitbucket.org') {
return null
}
return parseBitbucketPath(url.pathname)
} catch {
return null
}
}
export async function getBitbucketRepoRefForRemote(
repoPath: string,
remoteName: string
): Promise<BitbucketRepoRef | null> {
const cacheKey = `${repoPath}\0${remoteName}`
if (repoRefCache.has(cacheKey)) {
return repoRefCache.get(cacheKey)!
}
try {
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const result = parseBitbucketRepoRef(stdout)
repoRefCache.set(cacheKey, result)
return result
} catch {
repoRefCache.set(cacheKey, null)
return null
}
}
export async function getBitbucketRepoRef(repoPath: string): Promise<BitbucketRepoRef | null> {
return getBitbucketRepoRefForRemote(repoPath, 'origin')
}

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: command routing, WSL translation, and
git/gh/glab wrappers must stay co-located so platform behavior remains
consistent across every repo-scoped subprocess call. */
/**
* Centralized git/gh/command runner with transparent WSL support.
*
@ -467,6 +470,58 @@ export async function ghExecFileAsync(
throw lastError
}
// ─── glab CLI runner ────────────────────────────────────────────────
// Why: parallel to gh CLI runner above. GitLab support is added by
// cloning gh's surface rather than abstracting both behind a generic
// runner — keeping them as parallel implementations matches the
// project's clone-and-adapt approach for new providers and avoids
// touching the working gh path. Reuses the shared retry/transient
// helpers since HTTP-status- and TCP-error-based classification is
// provider-agnostic.
type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & { cwd?: string; wslDistro?: string }
/**
* Async glab CLI execution. Drop-in replacement for
* `execFileAsync('glab', args, { cwd, encoding, ... })`.
*
* Retry policy mirrors ghExecFileAsync.
*/
export async function glabExecFileAsync(
args: string[],
options: GlabExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
const resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
let lastError: unknown
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
try {
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
lastError = err
const { stderr } = extractExecError(err)
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
if (!isLastAttempt && isTransientGhError(stderr)) {
const retryAfterMs = parseRetryAfterMs(stderr)
const delayMs =
retryAfterMs !== null
? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS)
: GH_RETRY_DELAYS_MS[attempt]
await sleep(delayMs)
continue
}
throw err
}
}
throw lastError
}
// ─── Generic command runner (for rg, etc.) ──────────────────────────
/**

View File

@ -0,0 +1,308 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
const {
glabExecFileAsyncMock,
glabApiWithHeadersMock,
getGlabKnownHostsMock,
getProjectRefMock,
resolveIssueSourceMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
glabExecFileAsyncMock: vi.fn(),
glabApiWithHeadersMock: vi.fn(),
getGlabKnownHostsMock: vi.fn(),
getProjectRefMock: vi.fn(),
resolveIssueSourceMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gl-utils', async () => {
const actual = await vi.importActual<typeof GlUtils>('./gl-utils')
return {
...actual,
glabExecFileAsync: glabExecFileAsyncMock,
glabApiWithHeaders: glabApiWithHeadersMock,
getGlabKnownHosts: getGlabKnownHostsMock,
getProjectRef: getProjectRefMock,
resolveIssueSource: resolveIssueSourceMock,
acquire: acquireMock,
release: releaseMock
}
})
import { getMergeRequest, getMergeRequestForBranch, listMergeRequests } from './client'
describe('gitlab client — MR operations', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
glabApiWithHeadersMock.mockReset()
getGlabKnownHostsMock.mockReset()
getProjectRefMock.mockReset()
resolveIssueSourceMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
})
describe('getMergeRequest', () => {
it('fetches the MR with rolled-up pipeline status', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 10,
title: 'Add feature',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/merge_requests/10',
updated_at: '2026-05-05T00:00:00Z',
sha: 'deadbeef',
head_pipeline: { status: 'success' },
detailed_merge_status: 'mergeable'
})
})
const mr = await getMergeRequest('/repo', 10)
expect(mr).toMatchObject({
number: 10,
title: 'Add feature',
state: 'opened',
url: 'https://gitlab.com/g/p/-/merge_requests/10',
pipelineStatus: 'success',
mergeable: 'MERGEABLE',
headSha: 'deadbeef'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'projects/g%2Fp/merge_requests/10'],
{ cwd: '/repo' }
)
})
it('falls back to `glab mr view` when project ref is unresolved', async () => {
getProjectRefMock.mockResolvedValueOnce(null)
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ iid: 5, title: 't', state: 'opened' })
})
await getMergeRequest('/repo', 5)
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['mr', 'view', '5', '--output', 'json'], {
cwd: '/repo'
})
})
it('returns null when glab errors', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found'))
await expect(getMergeRequest('/repo', 99)).resolves.toBeNull()
})
it('treats neutral pipeline (no head_pipeline) as neutral status', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 1,
title: 't',
state: 'opened',
head_pipeline: null
})
})
const mr = await getMergeRequest('/repo', 1)
expect(mr?.pipelineStatus).toBe('neutral')
})
})
describe('getMergeRequestForBranch', () => {
it('finds the most recently updated MR for a branch across states', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
iid: 7,
title: 'WIP',
state: 'merged',
sha: 'abc',
head_pipeline: { status: 'success' }
}
])
})
const mr = await getMergeRequestForBranch('/repo', 'feature/foo')
expect(mr?.number).toBe(7)
expect(mr?.state).toBe('merged')
expect(mr?.pipelineStatus).toBe('success')
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'projects/g%2Fp/merge_requests?source_branch=feature%2Ffoo&order_by=updated_at&sort=desc&per_page=1'
],
{ cwd: '/repo' }
)
})
it('strips refs/heads/ prefix from the branch arg', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await getMergeRequestForBranch('/repo', 'refs/heads/feature/bar')
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(callArgs[1]).toContain('source_branch=feature%2Fbar')
})
it('returns null when no MR matches the branch', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull()
})
it('falls back to a linked MR iid when the branch lookup misses', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 9,
title: 'Linked MR',
state: 'opened',
pipeline: { status: 'success' }
})
})
const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 9)
expect(mr?.number).toBe(9)
expect(mr?.pipelineStatus).toBe('success')
expect(glabExecFileAsyncMock).toHaveBeenLastCalledWith(
['api', 'projects/g%2Fp/merge_requests/9'],
{ cwd: '/repo' }
)
})
it('returns null for an empty / detached-HEAD branch arg', async () => {
// Why: during a rebase the branch is empty — mirror github/getPRForBranch's
// early return without calling glab.
await expect(getMergeRequestForBranch('/repo', '')).resolves.toBeNull()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('returns null when project ref cannot be resolved', async () => {
getProjectRefMock.mockResolvedValueOnce(null)
await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
})
describe('listMergeRequests', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: await getProjectRefMock(),
fellBack: false
}))
})
it('paginates with X-Total / X-Total-Pages', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 100,
iid: 1,
title: 'first',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/merge_requests/1',
updated_at: '2026-05-05',
source_branch: 'feat-1',
target_branch: 'main',
author: { username: 'alice' },
source_project_id: 5,
target_project_id: 5
}
]),
headers: { 'x-total': '42', 'x-total-pages': '3' }
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
expect(result.items).toHaveLength(1)
expect(result.items[0]).toMatchObject({
type: 'mr',
number: 1,
title: 'first',
state: 'opened',
branchName: 'feat-1',
baseRefName: 'main',
author: 'alice',
isCrossRepository: false,
repoId: 'g/p'
})
expect(result.totalCount).toBe(42)
expect(result.totalPages).toBe(3)
expect(result.page).toBe(1)
})
it("omits the state param when state='all'", async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', 'all', 1, 20)
const callPath = glabApiWithHeadersMock.mock.calls[0][0][0] as string
expect(callPath).not.toContain('state=')
})
it('passes through Open / Merged / Closed states', async () => {
for (const state of ['opened', 'merged', 'closed'] as const) {
glabApiWithHeadersMock.mockReset()
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', state, 1, 20)
const callPath = glabApiWithHeadersMock.mock.calls[0][0][0] as string
expect(callPath).toContain(`state=${state}`)
}
})
it('flags fork MRs as cross-repository', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 200,
iid: 2,
title: 'fork mr',
state: 'opened',
source_branch: 'feat',
target_branch: 'main',
// Different source/target = fork MR
source_project_id: 11,
target_project_id: 5
}
]),
headers: { 'x-total': '1', 'x-total-pages': '1' }
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
expect(result.items[0].isCrossRepository).toBe(true)
})
it('returns a not_found error envelope when project ref is unresolved', async () => {
getProjectRefMock.mockResolvedValueOnce(null)
const result = await listMergeRequests('/repo', 'opened')
expect(result.error?.type).toBe('not_found')
expect(result.items).toEqual([])
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
})
it('falls back to ceil(total/perPage) when x-total-pages is absent', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabApiWithHeadersMock.mockResolvedValueOnce({
body: '[]',
headers: { 'x-total': '57' }
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
expect(result.totalCount).toBe(57)
expect(result.totalPages).toBe(3)
})
it('classifies API errors into the result envelope', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
const result = await listMergeRequests('/repo', 'opened')
expect(result.error?.type).toBe('permission_denied')
expect(result.items).toEqual([])
})
})
})

View File

@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
const {
glabExecFileAsyncMock,
glabApiWithHeadersMock,
getGlabKnownHostsMock,
getProjectRefMock,
resolveIssueSourceMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
glabExecFileAsyncMock: vi.fn(),
glabApiWithHeadersMock: vi.fn(),
getGlabKnownHostsMock: vi.fn(),
getProjectRefMock: vi.fn(),
resolveIssueSourceMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gl-utils', async () => {
const actual = await vi.importActual<typeof GlUtils>('./gl-utils')
return {
...actual,
glabExecFileAsync: glabExecFileAsyncMock,
glabApiWithHeaders: glabApiWithHeadersMock,
getGlabKnownHosts: getGlabKnownHostsMock,
getProjectRef: getProjectRefMock,
resolveIssueSource: resolveIssueSourceMock,
acquire: acquireMock,
release: releaseMock
}
})
import { listWorkItems } from './client'
describe('gitlab client — combined listWorkItems', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
glabApiWithHeadersMock.mockReset()
getGlabKnownHostsMock.mockReset()
getProjectRefMock.mockReset()
resolveIssueSourceMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'gitlab.com', path: 'g/p' },
fellBack: false
}))
})
it('merges MRs + issues and sorts by updatedAt desc', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 100,
iid: 1,
title: 'older mr',
state: 'opened',
updated_at: '2026-05-05T00:00:00Z',
source_project_id: 5,
target_project_id: 5
}
]),
headers: { 'x-total': '1', 'x-total-pages': '1' }
})
// Why: listIssues calls glabExecFileAsync (not glabApiWithHeaders) —
// it reads the issues list endpoint via the regular `glab api` path.
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
id: 200,
iid: 5,
title: 'newer issue',
state: 'opened',
updated_at: '2026-05-08T00:00:00Z'
}
])
})
const result = await listWorkItems('/repo', 'opened', 1, 20)
expect(result.items.map((i) => i.title)).toEqual(['newer issue', 'older mr'])
expect(result.items[0].type).toBe('issue')
expect(result.items[1].type).toBe('mr')
})
it("skips the issues fetch when state === 'merged'", async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({
body: '[]',
headers: { 'x-total': '0', 'x-total-pages': '0' }
})
await listWorkItems('/repo', 'merged', 1, 20)
// Why: the merged-state filter doesn't apply to issues (issues
// don't have a merged lifecycle), so the IPC must not even spawn
// the issues read. Verifies the listIssues path was not taken.
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('passes the closed state through to the issues fetch', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'closed', 1, 20)
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(issuesCallPath[1]).toContain('state=closed')
})
it("omits the state param when 'all'", async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'all', 1, 20)
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(issuesCallPath[1]).not.toContain('state=')
})
it('returns a not_found error envelope when project ref is unresolved', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false })
const result = await listWorkItems('/repo', 'opened')
expect(result.error?.type).toBe('not_found')
expect(result.items).toEqual([])
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('surfaces the MR error envelope into the combined result', async () => {
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
const result = await listWorkItems('/repo', 'opened', 1, 20)
expect(result.error?.type).toBe('permission_denied')
})
it('still returns issues when MRs error out', async () => {
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 500'))
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{ id: 200, iid: 9, title: 'live issue', state: 'opened', updated_at: '2026-05-08' }
])
})
const result = await listWorkItems('/repo', 'opened', 1, 20)
expect(result.items).toHaveLength(1)
expect(result.items[0].title).toBe('live issue')
expect(result.error).toBeDefined()
})
})

View File

@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
const { glabExecFileAsyncMock, getGlabKnownHostsMock, acquireMock, releaseMock } = vi.hoisted(
() => ({
glabExecFileAsyncMock: vi.fn(),
getGlabKnownHostsMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
})
)
vi.mock('./gl-utils', async () => {
const actual = await vi.importActual<typeof GlUtils>('./gl-utils')
return {
...actual,
glabExecFileAsync: glabExecFileAsyncMock,
getGlabKnownHosts: getGlabKnownHostsMock,
acquire: acquireMock,
release: releaseMock
}
})
import { getAuthenticatedViewer, getWorkItemByProjectRef, listTodos } from './client'
describe('gitlab client — viewer & paste-URL lookup', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
getGlabKnownHostsMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
})
describe('getAuthenticatedViewer', () => {
it('returns username + email when glab api user succeeds', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ username: 'alice', email: 'alice@example.com' })
})
await expect(getAuthenticatedViewer()).resolves.toEqual({
username: 'alice',
email: 'alice@example.com'
})
})
it('coerces a missing email to null', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ username: 'alice', email: null })
})
await expect(getAuthenticatedViewer()).resolves.toEqual({
username: 'alice',
email: null
})
})
it('returns null when glab fails', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not authenticated'))
await expect(getAuthenticatedViewer()).resolves.toBeNull()
})
it('returns null when username is empty', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ username: ' ', email: null })
})
await expect(getAuthenticatedViewer()).resolves.toBeNull()
})
})
describe('getWorkItemByProjectRef', () => {
it('fetches an MR and maps to GitLabWorkItem', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 100,
iid: 5,
title: 't',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/merge_requests/5',
source_branch: 'feat',
target_branch: 'main'
})
})
const item = await getWorkItemByProjectRef(
'/repo',
{ host: 'gitlab.com', path: 'g/p' },
5,
'mr'
)
expect(item).toMatchObject({ type: 'mr', number: 5, branchName: 'feat' })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'projects/g%2Fp/merge_requests/5'],
{ cwd: '/repo' }
)
})
it('fetches an issue and maps to GitLabWorkItem', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 200,
iid: 9,
title: 'bug',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/issues/9'
})
})
const item = await getWorkItemByProjectRef(
'/repo',
{ host: 'gitlab.com', path: 'g/p' },
9,
'issue'
)
expect(item).toMatchObject({ type: 'issue', number: 9 })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['api', 'projects/g%2Fp/issues/9'], {
cwd: '/repo'
})
})
it('returns null when the API errors', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not found'))
const item = await getWorkItemByProjectRef(
'/repo',
{ host: 'gitlab.com', path: 'g/p' },
9,
'issue'
)
expect(item).toBeNull()
})
})
describe('listTodos', () => {
it('maps glab todos response to GitLabTodo shape', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
id: 1,
action_name: 'assigned',
target_type: 'MergeRequest',
target: {
iid: 42,
title: 'Add feature',
web_url: 'https://gitlab.com/g/p/-/merge_requests/42'
},
target_url: 'https://gitlab.com/g/p/-/merge_requests/42',
author: { username: 'alice', avatar_url: 'https://example.com/a.png' },
project: { path_with_namespace: 'g/p' },
updated_at: '2026-05-08T10:00:00Z',
state: 'pending'
}
])
})
await expect(listTodos('/repo')).resolves.toEqual([
{
id: 1,
actionName: 'assigned',
targetType: 'MergeRequest',
targetIid: 42,
targetTitle: 'Add feature',
targetUrl: 'https://gitlab.com/g/p/-/merge_requests/42',
projectPath: 'g/p',
authorUsername: 'alice',
authorAvatarUrl: 'https://example.com/a.png',
updatedAt: '2026-05-08T10:00:00Z',
state: 'pending'
}
])
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--paginate', 'todos?state=pending&per_page=50'],
{ cwd: '/repo' }
)
})
it('coerces non-pending state values to pending (defensive)', async () => {
// Why: we filter to state=pending in the request, but if a future
// glab change leaks a different state through, the type's narrow
// 'pending' | 'done' union should still hold — anything not 'done'
// collapses to 'pending' rather than violating the type.
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{ id: 2, action_name: 'mentioned', target_type: 'Issue', state: 'weird' }
])
})
const result = await listTodos('/repo')
expect(result[0].state).toBe('pending')
})
it('falls back to empty list when glab errors', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('auth failed'))
await expect(listTodos('/repo')).resolves.toEqual([])
})
it('handles missing target / project / author fields gracefully', async () => {
// Why: GitLab Todos for Commit / Note targets sometimes omit
// `target` entirely — defaults must keep the record well-formed
// so the renderer doesn't choke on .title access.
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([{ id: 3, action_name: 'build_failed', target_type: 'Commit' }])
})
const result = await listTodos('/repo')
expect(result[0]).toMatchObject({
targetIid: null,
targetTitle: '',
targetUrl: '',
projectPath: '',
authorUsername: '',
authorAvatarUrl: ''
})
})
})
})

619
src/main/gitlab/client.ts Normal file
View File

@ -0,0 +1,619 @@
/* eslint-disable max-lines -- Why: parallel to src/main/github/client.ts
co-locating GitLab MR/issue/work-item operations keeps the concurrency
acquire/release pattern obvious across operations. */
import type {
ClassifiedError,
GitLabPagedResult,
GitLabTodo,
GitLabViewer,
GitLabWorkItem,
IssueSourcePreference,
ListMergeRequestsResult,
MRComment,
MRInfo,
MRListState
} from '../../shared/types'
import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers'
import {
acquire,
classifyListIssuesError,
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
glabApiWithHeaders,
glabExecFileAsync,
release,
resolveIssueSource,
type ProjectRef
} from './gl-utils'
import type { IssueListState } from './issues'
// Why: glab REST API addresses projects by URL-encoded path. Centralized
// so call sites don't forget the slash escapes for nested groups.
function encodedProject(projectPath: string): string {
return encodeURIComponent(projectPath)
}
/**
* Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer
* from the GitHub client returns null when glab is unavailable, the
* user is unauthenticated, or the lookup fails.
*/
export async function getAuthenticatedViewer(): Promise<GitLabViewer | null> {
await acquire()
try {
const { stdout } = await glabExecFileAsync(['api', 'user'])
const viewer = JSON.parse(stdout) as { username?: string; email?: string | null }
if (!viewer.username?.trim()) {
return null
}
return {
username: viewer.username.trim(),
email: viewer.email?.trim() || null
}
} catch {
return null
} finally {
release()
}
}
/**
* Resolve a project's full GitLab project ref (host + path). Mirrors
* github/getRepoSlug. Returns null for non-GitLab remotes.
*/
export async function getProjectSlug(repoPath: string): Promise<ProjectRef | null> {
const knownHosts = await getGlabKnownHosts()
return getProjectRef(repoPath, knownHosts)
}
/**
* Fetch a single merge request with the pipeline status rolled up.
* Returns null when the MR doesn't exist or glab fails callers
* decide whether to surface "not found" UI.
*/
export async function getMergeRequest(repoPath: string, iid: number): Promise<MRInfo | null> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
await acquire()
try {
const args = projectRef
? ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`]
: ['mr', 'view', String(iid), '--output', 'json']
const { stdout } = await glabExecFileAsync(args, { cwd: repoPath })
const data = JSON.parse(stdout) as Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
pipeline?: { status?: string } | null
}
// Why: GitLab's MR detail surfaces the head pipeline directly.
// Older instances expose `pipeline` instead of `head_pipeline` — try
// both. If neither is set the rollup falls back to neutral.
const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null)
return mapMRInfo(data, pipelineStatus)
} catch {
return null
} finally {
release()
}
}
/**
* Find the merge request whose source branch matches the given branch
* name. Mirrors github/getPRForBranch returns the most recently
* updated MR for the branch, or null when none exists. The branch is the
* local checkout's current ref (Orca strips refs/heads/ prefix upstream
* so we don't need to here).
*/
export async function getMergeRequestForBranch(
repoPath: string,
branch: string,
linkedMRIid?: number | null
): Promise<MRInfo | null> {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedMRIid == null) {
return null
}
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
if (!projectRef) {
return null
}
await acquire()
try {
if (branchName) {
const { stdout } = await glabExecFileAsync(
[
'api',
`projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as (Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
})[]
if (Array.isArray(data) && data.length > 0) {
const raw = data[0]
const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? null)
return mapMRInfo(raw, pipelineStatus)
}
}
if (typeof linkedMRIid !== 'number') {
return null
}
// Why: create-from-MR worktrees may use a fresh local branch name rather
// than the MR source branch. Fall back to the durable linked iid so the
// core review status still follows the workspace.
const { stdout } = await glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`],
{ cwd: repoPath }
)
const raw = JSON.parse(stdout) as Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
pipeline?: { status?: string } | null
}
const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null)
return mapMRInfo(raw, pipelineStatus)
} catch {
return null
} finally {
release()
}
}
/**
* List merge requests for a project with strict pagination. Returns
* total counts pulled from X-Total / X-Total-Pages response headers so
* callers can render "Page X of Y" UIs.
*/
export async function listMergeRequests(
repoPath: string,
state: MRListState = 'opened',
page = 1,
perPage = 20,
preference?: IssueSourcePreference
): Promise<ListMergeRequestsResult> {
const knownHosts = await getGlabKnownHosts()
// Why: MRs sit on `origin` in the fork model (the user's fork is where
// they push branches and submit MRs). Mirror github's `getOwnerRepo`
// call site by going through the upstream/origin preference resolver
// so cross-fork workflows reuse the same plumbing.
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
if (!projectRef) {
return {
items: [],
page,
perPage,
totalCount: 0,
totalPages: 0,
error: {
type: 'not_found',
message: 'No GitLab project found for this repository.'
}
}
}
// Why: 'all' is exposed as the picker filter but GitLab's API expects
// no state param to mean "any state". Drop the param when 'all'.
const stateParam = state === 'all' ? '' : `&state=${state}`
const path =
`projects/${encodedProject(projectRef.path)}/merge_requests?` +
`page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}`
const repoId = projectRef.path
await acquire()
try {
const { body, headers } = await glabApiWithHeaders([path], { cwd: repoPath })
const data = JSON.parse(body) as Parameters<typeof mapMRToWorkItem>[0][]
return {
items: data.map((d) => mapMRToWorkItem(d, repoId)),
page,
perPage,
totalCount: parseHeaderInt(headers['x-total'], 0),
// Why: when 'all' state is requested or the per_page is large,
// GitLab may not include x-total-pages; fall back to ceil(total/perPage).
totalPages:
parseHeaderInt(headers['x-total-pages'], 0) ||
Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage))
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return {
items: [],
page,
perPage,
totalCount: 0,
totalPages: 0,
error: classifyListIssuesError(stderr)
}
} finally {
release()
}
}
function parseHeaderInt(value: string | undefined, fallback: number): number {
if (!value) {
return fallback
}
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : fallback
}
/**
* Fetch a work item (MR or issue) given an explicit project ref +
* iid + type. Mirrors github/getWorkItemByOwnerRepo used by the
* paste-URL flow in the picker where the URL determines the project
* directly rather than going through the local repo's remotes.
*/
export async function getWorkItemByProjectRef(
repoPath: string,
projectRef: ProjectRef,
iid: number,
type: 'issue' | 'mr'
): Promise<GitLabWorkItem | null> {
await acquire()
try {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/${resource}/${iid}`],
{ cwd: repoPath }
)
const data = JSON.parse(stdout)
if (type === 'mr') {
return mapMRToWorkItem(data, projectRef.path)
}
return mapIssueToWorkItem(data, projectRef.path)
} catch {
return null
} finally {
release()
}
}
// Why: combined MR + issue list for the Tasks-screen and picker
// surfaces. Centralizes the merge logic that TaskPage previously did
// inline so the IPC layer has a single function to call. Pagination is
// approximate — the v1 contract is "page 1 of perPage MRs + perPage
// issues, mixed by updatedAt desc" which is good enough for a typical
// project's <100 active items.
export type ListWorkItemsState = MRListState
function mrStateToIssueState(state: MRListState): IssueListState | null {
// Why: GitLab issues don't have a 'merged' state. When the user is
// filtering MRs to merged, return null so listWorkItems can skip the
// issues fetch entirely instead of mis-mapping to opened/closed.
switch (state) {
case 'opened':
return 'opened'
case 'closed':
return 'closed'
case 'all':
return 'all'
case 'merged':
return null
}
}
export async function listWorkItems(
repoPath: string,
state: MRListState = 'opened',
page = 1,
perPage = 20,
preference?: IssueSourcePreference
): Promise<GitLabPagedResult<GitLabWorkItem>> {
const issueState = mrStateToIssueState(state)
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
if (!projectRef) {
return {
items: [],
page,
perPage,
totalCount: 0,
totalPages: 0,
error: {
type: 'not_found',
message: 'No GitLab project found for this repository.'
}
}
}
// Why: fan out the two read calls so the response time is the slower
// of the two, not their sum. Errors classify per-side; an MR-side
// failure with a successful issues fetch still surfaces issues with
// an error envelope.
//
// Why we don't go through `listIssues` here: that function returns
// IssueInfo, which deliberately strips the raw glab fields (notably
// `updated_at`). The combined sort needs updatedAt, so we read the
// raw issues API directly and run mapIssueToWorkItem against the
// raw payload instead.
const [mrs, issues] = await Promise.all([
listMergeRequests(repoPath, state, page, perPage, preference),
issueState === null
? Promise.resolve({
items: [] as GitLabWorkItem[],
error: undefined as ClassifiedError | undefined
})
: fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage)
])
const merged = [...mrs.items, ...issues.items].sort((a, b) =>
(b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')
)
// Why: combine error envelopes — the renderer's banner cares about
// any failed fetch, not which one. MR-side error wins because it's
// strictly more informative than an issues-side error in most
// permission scenarios (issues can be disabled per project).
const error: ClassifiedError | undefined = mrs.error ?? issues.error
return {
items: merged,
page,
perPage,
// Why: approximate totals — an exact combined-pagination total would
// require a server-side ordering primitive across two distinct
// resources, which the GitLab API doesn't offer. MR total is the
// right direction; the UI's "Page X of Y" reads as a hint, not a
// strict count.
totalCount: mrs.totalCount,
totalPages: mrs.totalPages,
...(error ? { error } : {})
}
}
async function fetchIssuesAsWorkItems(
repoPath: string,
projectRef: ProjectRef,
state: IssueListState,
perPage: number
): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> {
await acquire()
try {
const stateParam = state === 'all' ? '' : `&state=${state}`
const { stdout } = await glabExecFileAsync(
[
'api',
`projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as Parameters<typeof mapIssueToWorkItem>[0][]
return {
items: data.map((d) => mapIssueToWorkItem(d, projectRef.path)),
error: undefined
}
} catch (err) {
return {
items: [],
error: classifyListIssuesError(err instanceof Error ? err.message : String(err))
}
} finally {
release()
}
}
/**
* List the authenticated user's GitLab todos (gitlab.com/dashboard/todos).
* Cross-project `glab api todos` is user-scoped so the cwd doesn't
* affect the result; callers may pass any registered repo path so the
* IPC handler's path-validation guard has something to check.
*
* Why: GitLab's todos surface is the closest GitLab-native analogue of
* GitHub's notifications/inbox. Surfacing it in Orca lets users start
* work directly from a mention/assignment without going to gitlab.com
* first.
*/
export async function listTodos(repoPath: string): Promise<GitLabTodo[]> {
await acquire()
try {
// Why: per_page=50 keeps the first-page round-trip small. Pagination
// is left for a follow-up — most users have <50 pending todos in
// practice and the UI shows the highest-priority ones first.
const { stdout } = await glabExecFileAsync(
['api', '--paginate', 'todos?state=pending&per_page=50'],
{ cwd: repoPath }
)
type RESTTodo = {
id?: number
action_name?: string
target_type?: string
target?: {
iid?: number
title?: string
web_url?: string
} | null
target_url?: string
author?: { username?: string | null; avatar_url?: string | null } | null
project?: { path_with_namespace?: string } | null
updated_at?: string
state?: string
}
// Why: --paginate concatenates JSON arrays (one per page) into a
// single stream. glab's behavior is to emit them as one JSON array
// when the endpoint returns arrays — we trust that contract here.
const data = JSON.parse(stdout) as RESTTodo[]
return data.map<GitLabTodo>((t) => ({
id: t.id ?? 0,
actionName: t.action_name ?? '',
targetType: t.target_type ?? '',
targetIid: typeof t.target?.iid === 'number' ? t.target.iid : null,
targetTitle: t.target?.title ?? '',
targetUrl: t.target_url ?? t.target?.web_url ?? '',
projectPath: t.project?.path_with_namespace ?? '',
authorUsername: t.author?.username ?? '',
authorAvatarUrl: t.author?.avatar_url ?? '',
updatedAt: t.updated_at ?? '',
state: t.state === 'done' ? 'done' : 'pending'
}))
} catch {
// Why: silent empty-list on auth/network failures matches the rest
// of the read-side surface (`listLabels`, `listAssignableUsers`).
// The caller's banner / loading-state UI signals connectivity issues.
return []
} finally {
release()
}
}
// ── MR mutations ──────────────────────────────────────────────────
// Why: mirror the GitHub-side actions (mergePR, updatePRTitle, close
// via gh issue close, etc.) for the GitLab dialog footer. All take a
// repoPath + iid and resolve the project ref via the existing helper.
async function withProjectRef<T>(
repoPath: string,
fn: (projectRef: ProjectRef, repoFlag: string) => Promise<T>,
fallback: T
): Promise<T> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
if (!projectRef) {
return fallback
}
return fn(projectRef, projectRef.path)
}
export async function closeMR(
repoPath: string,
iid: number
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
await acquire()
try {
await glabExecFileAsync(['mr', 'close', String(iid), '-R', repoFlag], { cwd: repoPath })
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Why: glab returns a non-zero exit when the MR is already
// closed — treat that as success since the desired state is
// reached.
if (msg.toLowerCase().includes('already')) {
return { ok: true }
}
return { ok: false, error: msg }
} finally {
release()
}
},
{ ok: false, error: 'Could not resolve GitLab project for this repository' }
)
}
export async function reopenMR(
repoPath: string,
iid: number
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
await acquire()
try {
await glabExecFileAsync(['mr', 'reopen', String(iid), '-R', repoFlag], { cwd: repoPath })
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.toLowerCase().includes('already')) {
return { ok: true }
}
return { ok: false, error: msg }
} finally {
release()
}
},
{ ok: false, error: 'Could not resolve GitLab project for this repository' }
)
}
export async function mergeMR(
repoPath: string,
iid: number,
method: 'merge' | 'squash' | 'rebase' = 'merge'
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
await acquire()
try {
// Why: glab mr merge accepts --squash and --rebase flags;
// omitting both does a regular merge commit. Map our union
// to the right glab flag.
const methodFlag =
method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : []
await glabExecFileAsync(
['mr', 'merge', String(iid), '-R', repoFlag, '--yes', ...methodFlag],
{ cwd: repoPath }
)
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
} finally {
release()
}
},
{ ok: false, error: 'Could not resolve GitLab project for this repository' }
)
}
export async function addMRComment(
repoPath: string,
iid: number,
body: string
): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> {
return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>(
repoPath,
async (projectRef) => {
await acquire()
try {
const { stdout } = await glabExecFileAsync(
[
'api',
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`,
'-f',
`body=${body}`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as {
id?: number
author?: { username?: string; avatar_url?: string; state?: string } | null
body?: string
created_at?: string
}
return {
ok: true,
comment: {
id: data.id ?? Date.now(),
author: data.author?.username ?? 'You',
authorAvatarUrl: data.author?.avatar_url ?? '',
body: data.body ?? body,
createdAt: data.created_at ?? new Date().toISOString(),
url: '',
isBot: data.author?.state === 'bot'
}
}
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
} finally {
release()
}
},
{ ok: false, error: 'Could not resolve GitLab project for this repository' }
)
}
/** 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'
// Why: surface the upstream-aware project-ref helper so non-issue call
// sites that need the resolved project (e.g. the paste-URL UI) don't
// have to import from gl-utils directly.
export { getProjectRefForRemote }

View File

@ -0,0 +1,331 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
glabExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
glabExecFileAsync: glabExecFileAsyncMock
}))
import {
_resetKnownHostsCache,
_resetProjectRefCache,
classifyGlabError,
classifyListIssuesError,
getIssueProjectRef,
getGlabKnownHosts,
getProjectRef,
parseGitLabProjectRef,
parseGlabApiResponse,
parseGlabAuthStatusHosts,
resolveIssueSource
} from './gl-utils'
describe('gitlab project ref parsing', () => {
it('parses HTTPS and SSH GitLab.com remotes', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({
host: 'gitlab.com',
path: 'stablyai/orca'
})
})
it('preserves nested group paths', () => {
expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({
host: 'gitlab.com',
path: 'group/subgroup/project'
})
expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({
host: 'gitlab.com',
path: 'g1/g2/g3/proj'
})
})
it('returns null for non-GitLab hosts when host not in knownHosts', () => {
expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull()
expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull()
})
it('matches self-hosted hosts when included in knownHosts', () => {
expect(
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('rejects single-segment paths (host root or user-only)', () => {
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
})
it('handles missing .git suffix', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
})
describe('gitlab project ref resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
_resetProjectRefCache()
})
it('keeps getProjectRef origin-based', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:fork/orca.git\n'
})
await expect(getProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'fork/orca'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
})
it('prefers upstream for issue project ref resolution', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:stablyai/orca.git\n'
})
await expect(getIssueProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'stablyai/orca'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], {
cwd: '/repo'
})
})
it('falls back to origin when upstream is missing or non-GitLab', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' })
await expect(getIssueProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'fork/orca'
})
})
it('does not mix origin and upstream cache entries for the same repo path', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:fork/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:stablyai/orca.git\n' })
await expect(getProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'fork/orca'
})
await expect(getIssueProjectRef('/repo')).resolves.toEqual({
host: 'gitlab.com',
path: 'stablyai/orca'
})
})
})
describe('resolveIssueSource', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
_resetProjectRefCache()
})
it("'auto' + upstream exists → upstream, fellBack=false", async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:stablyai/orca.git\n'
})
await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({
source: { host: 'gitlab.com', path: 'stablyai/orca' },
fellBack: false
})
})
it("'auto' + no upstream → origin, fellBack=false", async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:solo/orca.git\n' })
await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({
source: { host: 'gitlab.com', path: 'solo/orca' },
fellBack: false
})
})
it("'upstream' + no upstream remote → origin, fellBack=true", async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('fatal: No such remote'))
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:solo/orca.git\n' })
await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({
source: { host: 'gitlab.com', path: 'solo/orca' },
fellBack: true
})
})
it("'origin' + upstream exists → origin (ignores upstream), fellBack=false", async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:fork/orca.git\n'
})
await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({
source: { host: 'gitlab.com', path: 'fork/orca' },
fellBack: false
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo'
})
})
it('undefined preference is treated identically to auto', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:stablyai/orca.git\n'
})
await expect(resolveIssueSource('/repo', undefined)).resolves.toEqual({
source: { host: 'gitlab.com', path: 'stablyai/orca' },
fellBack: false
})
})
})
describe('glab error classification', () => {
it('classifies 403/forbidden as permission_denied', () => {
expect(classifyGlabError('HTTP 403 Forbidden').type).toBe('permission_denied')
expect(classifyGlabError('insufficient_scope').type).toBe('permission_denied')
})
it('classifies 404 / project not found as not_found', () => {
expect(classifyGlabError('HTTP 404 Not Found').type).toBe('not_found')
expect(classifyGlabError('Project Not Found').type).toBe('not_found')
})
it('classifies 422 / unprocessable as validation_error', () => {
expect(classifyGlabError('HTTP 422 Unprocessable Entity').type).toBe('validation_error')
})
it('classifies rate-limit signals as rate_limited', () => {
expect(classifyGlabError('HTTP 429 Too Many Requests').type).toBe('rate_limited')
expect(classifyGlabError('rate limit exceeded').type).toBe('rate_limited')
})
it('classifies timeout / dns / network as network_error', () => {
expect(classifyGlabError('connection timeout').type).toBe('network_error')
expect(classifyGlabError('could not resolve host: gitlab.com').type).toBe('network_error')
expect(classifyGlabError('network unreachable').type).toBe('network_error')
})
it('falls back to unknown for unrecognized stderr', () => {
expect(classifyGlabError('something weird happened').type).toBe('unknown')
})
it('rewrites copy for read contexts via classifyListIssuesError', () => {
expect(classifyListIssuesError('HTTP 403').message).toMatch(/permission to read issues/i)
expect(classifyListIssuesError('HTTP 404').message).toBe('Project not found.')
})
})
describe('glab auth status host parsing', () => {
it('extracts hosts from "Logged in to <host>" lines', () => {
const out = `
Logged in to gitlab.com as user1 (oauth2)
Logged in to gitlab.example.com as user2 (token)
`
expect(parseGlabAuthStatusHosts(out).sort()).toEqual(['gitlab.com', 'gitlab.example.com'])
})
it('extracts hosts from header-style lines', () => {
const out = `
gitlab.example.com:
Logged in as user2
`
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com')
})
it('returns empty list for output with no hosts', () => {
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
})
})
describe('parseGlabApiResponse', () => {
it('splits headers and body at the first blank line (LF)', () => {
const stdout = 'HTTP/2.0 200 OK\nX-Total: 42\nX-Total-Pages: 3\n\n[{"iid":1}]'
const parsed = parseGlabApiResponse(stdout)
expect(parsed.headers).toEqual({ 'x-total': '42', 'x-total-pages': '3' })
expect(parsed.body).toBe('[{"iid":1}]')
})
it('handles CRLF line endings', () => {
const stdout = 'HTTP/2.0 200 OK\r\nX-Total: 7\r\n\r\n[]'
const parsed = parseGlabApiResponse(stdout)
expect(parsed.headers['x-total']).toBe('7')
expect(parsed.body).toBe('[]')
})
it('lowercases header names for stable lookup', () => {
const stdout = 'HTTP/2.0 200 OK\nX-Total: 1\nContent-Type: application/json\n\n{}'
const parsed = parseGlabApiResponse(stdout)
expect(parsed.headers['x-total']).toBe('1')
expect(parsed.headers['content-type']).toBe('application/json')
})
it('returns the full input as body when there is no header separator', () => {
const stdout = '{"iid":1}'
const parsed = parseGlabApiResponse(stdout)
expect(parsed.body).toBe(stdout)
expect(parsed.headers).toEqual({})
})
it('skips the status line in the header block', () => {
const stdout = 'HTTP/2.0 200 OK\nX-Total: 5\n\n[]'
const parsed = parseGlabApiResponse(stdout)
// The status line should not have leaked into headers under any key.
expect(parsed.headers['http/2.0']).toBeUndefined()
expect(parsed.headers['x-total']).toBe('5')
})
})
describe('getGlabKnownHosts', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
_resetKnownHostsCache()
})
it('returns gitlab.com plus auth-status hosts, deduped', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as user\n✓ Logged in to gitlab.example.com as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com'])
})
it('falls back to default when glab auth status fails', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated'))
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
})
it('caches the result across calls', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.com as user\n',
stderr: ''
})
await getGlabKnownHosts()
await getGlabKnownHosts()
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
})

315
src/main/gitlab/gl-utils.ts Normal file
View File

@ -0,0 +1,315 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { gitExecFileAsync, glabExecFileAsync } from '../git/runner'
import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types'
// Why: legacy generic execFile wrapper — only used by callers that don't need
// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from
// the runner instead.
export const execFileAsync = promisify(execFile)
export { glabExecFileAsync, gitExecFileAsync }
// ── Concurrency limiter — max 4 parallel glab processes ─────────────
// Why: parallel to gh-utils' limiter. Separate state from the gh limiter
// because gh and glab are independent binaries; one provider's spawns
// shouldn't throttle the other's. Cap matches gh-utils for consistency.
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()
}
}
// ── Error classification ─────────────────────────────────────────────
// Why: glab CLI surfaces API errors as unstructured stderr — same shape
// as gh. Map known GitLab patterns to typed errors so callers can show
// user-friendly messages.
export function classifyGlabError(stderr: string): ClassifiedError {
const s = stderr.toLowerCase()
if (s.includes('http 403') || s.includes('forbidden') || s.includes('insufficient_scope')) {
return {
type: 'permission_denied',
message: "You don't have permission to edit this issue. Check your GitLab token scopes."
}
}
if (s.includes('http 404') || s.includes('project not found')) {
return { type: 'not_found', message: 'Issue not found — it may have been deleted.' }
}
if (s.includes('http 422') || s.includes('unprocessable')) {
return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` }
}
// Why: GitLab returns 429 for rate limit; gh's "rate limit" stderr substring
// also fires through the user-mode token bucket. Cover both.
if (s.includes('rate limit') || s.includes('http 429')) {
return {
type: 'rate_limited',
message: 'GitLab rate limit hit. Try again in a few minutes.'
}
}
if (
s.includes('timeout') ||
s.includes('no such host') ||
s.includes('network') ||
s.includes('could not resolve host')
) {
return { type: 'network_error', message: 'Network error — check your connection.' }
}
return { type: 'unknown', message: `Failed to update issue: ${stderr.trim()}` }
}
// Why: classifyGlabError's copy is phrased for edit/update operations;
// listIssues is a read op. Rewrite the message for read contexts while
// keeping the typed classification intact for callers/telemetry.
export function classifyListIssuesError(stderr: string): ClassifiedError {
const c = classifyGlabError(stderr)
const trimmed = stderr.trim()
// Exhaustive map so newly added error types surface as a TS error here
// rather than silently falling through to edit-phrased copy.
const readMessages: Record<ClassifiedError['type'], string> = {
permission_denied:
"You don't have permission to read issues for this project. Check your GitLab token scopes.",
not_found: 'Project not found.',
issues_disabled: 'Issues are disabled on this project.',
validation_error: `Invalid request — ${trimmed}`,
rate_limited: 'GitLab rate limit hit. Try again in a few minutes.',
network_error: 'Network error — check your connection.',
unknown: `Failed to load issues: ${trimmed}`
}
return { type: c.type, message: readMessages[c.type] }
}
// ── Project ref resolution ──────────────────────────────────────────
// Why: alias the shared shape so `src/shared/types.ts#GitLabProjectRef`
// remains the single source of truth while main-side call sites can use
// the short local name `ProjectRef`.
export type ProjectRef = GitLabProjectRef
const projectRefCache = new Map<string, ProjectRef | null>()
/** @internal — exposed for tests only */
export function _resetProjectRefCache(): void {
projectRefCache.clear()
}
/**
* Hosts always treated as GitLab. Self-hosted instances are added at
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
*/
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
export function parseGitLabProjectRef(
remoteUrl: string,
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
): ProjectRef | null {
const trimmed = remoteUrl.trim()
for (const host of knownHosts) {
const escapedHost = host.replace(/\./g, '\\.')
// Match SSH (git@host:path) and HTTPS (https://host/path) forms with an
// optional .git suffix. Path may contain nested groups — keep it whole.
const match = trimmed.match(new RegExp(`${escapedHost}[:/]([^\\s]+?)(?:\\.git)?$`))
if (!match) {
continue
}
const path = match[1]
// Reject paths without at least one group segment — `gitlab.com:foo`
// alone is not a project reference.
if (!path.includes('/')) {
continue
}
return { host, path }
}
return null
}
export async function getProjectRefForRemote(
repoPath: string,
remoteName: string,
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
): Promise<ProjectRef | null> {
const cacheKey = `${repoPath}\0${remoteName}\0${knownHosts.join(',')}`
if (projectRefCache.has(cacheKey)) {
return projectRefCache.get(cacheKey)!
}
try {
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const result = parseGitLabProjectRef(stdout, knownHosts)
if (result) {
projectRefCache.set(cacheKey, result)
return result
}
} catch {
// ignore — non-GitLab remote or no remote configured
}
projectRefCache.set(cacheKey, null)
return null
}
export async function getProjectRef(
repoPath: string,
knownHosts?: readonly string[]
): Promise<ProjectRef | null> {
return getProjectRefForRemote(repoPath, 'origin', knownHosts)
}
export async function getIssueProjectRef(
repoPath: string,
knownHosts?: readonly string[]
): Promise<ProjectRef | null> {
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts)
if (upstream) {
return upstream
}
return getProjectRefForRemote(repoPath, 'origin', knownHosts)
}
export type ResolvedIssueSource = {
source: ProjectRef | null
/** True when the user preferred `upstream` but the upstream remote is no
* longer configured and the resolver fell back to origin. */
fellBack: boolean
}
/**
* Resolve the issue source for a repo honoring the user's per-repo
* preference. Mirrors `resolveIssueSource` in gh-utils the upstream/
* origin/auto semantics are git-remote concepts, not GitHub-specific.
*/
export async function resolveIssueSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
knownHosts?: readonly string[]
): Promise<ResolvedIssueSource> {
if (preference === 'upstream') {
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts)
if (upstream) {
return { source: upstream, fellBack: false }
}
const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts)
return { source: origin, fellBack: origin !== null }
}
if (preference === 'origin') {
return {
source: await getProjectRefForRemote(repoPath, 'origin', knownHosts),
fellBack: false
}
}
return { source: await getIssueProjectRef(repoPath, knownHosts), fellBack: false }
}
// ── Known-hosts discovery via `glab auth status` ────────────────────
// Why: glab supports multiple hosts (gitlab.com plus self-hosted). The
// authoritative list of "what counts as GitLab" from the user's POV is
// "what hosts have I authenticated with". Parse hostnames out of
// `glab auth status` output and cache the result process-wide.
let knownHostsCache: readonly string[] | null = null
/** @internal — exposed for tests only */
export function _resetKnownHostsCache(): void {
knownHostsCache = null
}
export async function getGlabKnownHosts(): Promise<readonly string[]> {
if (knownHostsCache) {
return knownHostsCache
}
try {
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'])
// Why: glab writes auth status to stderr in some versions, stdout in
// others. Concatenate so the parser sees both.
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
// Always include gitlab.com so a fresh-install user with no auth
// still recognizes the canonical host.
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
knownHostsCache = merged
return merged
} catch {
// Auth check failed (glab not installed, no auth, etc.) — fall back
// to the canonical default. The caller will hit the auth error on
// the first real request anyway.
knownHostsCache = [...DEFAULT_GITLAB_HOSTS]
return knownHostsCache
}
}
// ── Paginated `glab api -i` helper ──────────────────────────────────
// Why: GitLab returns total counts via response headers (X-Total,
// X-Total-Pages) on paginated REST endpoints. `glab api` discards
// headers by default; passing `-i` includes the raw HTTP response
// before the JSON body. Parse out the headers + body so callers can
// surface "Page X of Y" UIs without hand-rolling a second count call.
export type GlabApiResponse = {
body: string
headers: Record<string, string>
}
export async function glabApiWithHeaders(
args: string[],
options?: { cwd?: string }
): Promise<GlabApiResponse> {
const { stdout } = await glabExecFileAsync(['api', '-i', ...args], options)
return parseGlabApiResponse(stdout)
}
/** @internal — exported for tests. */
export function parseGlabApiResponse(stdout: string): GlabApiResponse {
// Why: response is `HTTP/x.y status\nHeader: val\n…\n\n<body>`.
// Match the first blank line (CRLF or LF) as the boundary.
const sepMatch = stdout.match(/\r?\n\r?\n/)
if (!sepMatch || sepMatch.index === undefined) {
return { body: stdout, headers: {} }
}
const headerBlock = stdout.slice(0, sepMatch.index)
const body = stdout.slice(sepMatch.index + sepMatch[0].length)
const headers: Record<string, string> = {}
// Skip the status line (HTTP/x.y …) and parse the rest as key: value.
const lines = headerBlock.split(/\r?\n/)
for (const line of lines) {
const m = line.match(/^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/)
if (m) {
headers[m[1].toLowerCase()] = m[2].trim()
}
}
return { body, headers }
}
// Why: glab auth status output is human-formatted and varies across versions.
// Two patterns observed in the wild:
// 1) "✓ Logged in to gitlab.com as <user>"
// 2) "gitlab.example.com:" header followed by indented status lines
// Match both, dedupe, lowercase. Best-effort — anything that looks like a
// hostname.
export function parseGlabAuthStatusHosts(output: string): string[] {
const hosts = new Set<string>()
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) {
hosts.add(m[1].toLowerCase())
}
for (const line of output.split('\n')) {
const m = line.match(/^([a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}):\s*$/)
if (m) {
hosts.add(m[1].toLowerCase())
}
}
return Array.from(hosts)
}

View File

@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
const {
glabExecFileAsyncMock,
getIssueProjectRefMock,
resolveIssueSourceMock,
getGlabKnownHostsMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
glabExecFileAsyncMock: vi.fn(),
getIssueProjectRefMock: vi.fn(),
resolveIssueSourceMock: vi.fn(),
getGlabKnownHostsMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gl-utils', async () => {
const actual = await vi.importActual<typeof GlUtils>('./gl-utils')
return {
...actual,
glabExecFileAsync: glabExecFileAsyncMock,
getIssueProjectRef: getIssueProjectRefMock,
resolveIssueSource: resolveIssueSourceMock,
getGlabKnownHosts: getGlabKnownHostsMock,
acquire: acquireMock,
release: releaseMock
}
})
import { addIssueComment, createIssue, getIssue, listIssues, updateIssue } from './issues'
describe('gitlab issue operations', () => {
beforeEach(() => {
glabExecFileAsyncMock.mockReset()
getIssueProjectRefMock.mockReset()
resolveIssueSourceMock.mockReset()
getGlabKnownHostsMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
resolveIssueSourceMock.mockImplementation(async () => ({
source: await getIssueProjectRefMock(),
fellBack: false
}))
})
it('gets a single issue from the project ref', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 923,
title: 'Use upstream issues',
state: 'opened',
web_url: 'https://gitlab.com/stablyai/orca/-/issues/923',
labels: []
})
})
await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'projects/stablyai%2Forca/issues/923'],
{ cwd: '/repo-root' }
)
})
it('encodes nested group paths', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({
host: 'gitlab.com',
path: 'group/subgroup/project'
})
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ iid: 1, title: 't', state: 'opened' })
})
await getIssue('/repo-root', 1)
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'projects/group%2Fsubgroup%2Fproject/issues/1'],
{ cwd: '/repo-root' }
)
})
it('lists issues with state=opened ordering', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'projects/stablyai%2Forca/issues?per_page=5&order_by=updated_at&sort=desc&state=opened'
],
{ cwd: '/repo-root' }
)
})
it('surfaces a permission_denied error instead of collapsing to empty', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
const result = await listIssues('/repo-root', 5)
expect(result.items).toEqual([])
expect(result.error?.type).toBe('permission_denied')
})
it('creates an issue and returns its iid + web_url', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 924,
web_url: 'https://gitlab.com/stablyai/orca/-/issues/924'
})
})
await expect(createIssue('/repo-root', 'New issue', 'Body')).resolves.toEqual({
ok: true,
number: 924,
url: 'https://gitlab.com/stablyai/orca/-/issues/924'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'-X',
'POST',
'projects/stablyai%2Forca/issues',
'-f',
'title=New issue',
'-f',
'description=Body'
],
{ cwd: '/repo-root' }
)
})
it('rejects createIssue with empty title', async () => {
await expect(createIssue('/repo-root', ' ', 'body')).resolves.toEqual({
ok: false,
error: 'Title is required'
})
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('updateIssue closes via `glab issue close` when state=closed', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await expect(updateIssue('/repo-root', 5, { state: 'closed' })).resolves.toEqual({ ok: true })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['issue', 'close', '5', '-R', 'stablyai/orca'],
{ cwd: '/repo-root' }
)
})
it("updateIssue treats 'already closed' as a no-op", async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('Issue is already closed'))
await expect(updateIssue('/repo-root', 5, { state: 'closed' })).resolves.toEqual({ ok: true })
})
it('updateIssue applies field edits via `glab issue update`', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await expect(
updateIssue('/repo-root', 5, {
title: 'Renamed',
addLabels: ['bug'],
removeLabels: ['stale'],
addAssignees: ['alice'],
removeAssignees: ['bob']
})
).resolves.toEqual({ ok: true })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'issue',
'update',
'5',
'-R',
'stablyai/orca',
'--title',
'Renamed',
'--label',
'bug',
'--unlabel',
'stale',
'--assignee',
'alice',
'--unassignee',
'bob'
],
{ cwd: '/repo-root' }
)
})
it('addIssueComment posts to /notes and maps the response', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 100,
author: { username: 'alice', avatar_url: 'https://example.com/a.png' },
body: 'Hello',
created_at: '2026-05-05T10:00:00Z'
})
})
const result = await addIssueComment('/repo-root', 5, 'Hello')
expect(result).toEqual({
ok: true,
comment: {
id: 100,
author: 'alice',
authorAvatarUrl: 'https://example.com/a.png',
body: 'Hello',
createdAt: '2026-05-05T10:00:00Z',
url: '',
isBot: false
}
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '-X', 'POST', 'projects/stablyai%2Forca/issues/5/notes', '-f', 'body=Hello'],
{ cwd: '/repo-root' }
)
})
it('returns null from getIssue when project ref cannot be resolved', async () => {
getIssueProjectRefMock.mockResolvedValueOnce(null)
// Why: when there's no GitLab project ref the fallback path
// (`glab issue view` from cwd) runs — simulate a glab failure to ensure
// we surface null cleanly.
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not a glab repo'))
await expect(getIssue('/repo-root', 1)).resolves.toBeNull()
})
it('updateIssue returns error when project ref cannot be resolved', async () => {
getIssueProjectRefMock.mockResolvedValueOnce(null)
await expect(updateIssue('/repo-root', 5, { state: 'closed' })).resolves.toEqual({
ok: false,
error: 'Could not resolve GitLab project for this repository'
})
})
})

414
src/main/gitlab/issues.ts Normal file
View File

@ -0,0 +1,414 @@
/* 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,
GitLabAssignableUser,
GitLabCommentResult,
GitLabIssueInfo,
GitLabIssueUpdate,
IssueSourcePreference,
MRComment
} from '../../shared/types'
import { mapGitLabIssueInfo } from './mappers'
// prettier-ignore
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts } from './gl-utils'
// Why: parallel to GitHub's IssueListResult — distinguishes a successful-
// empty listing from a failed fetch.
export type IssueListResult = {
items: GitLabIssueInfo[]
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.
*
* Why this path doesn't take a preference mirrors the GitHub issues.ts
* commentary: linked-issue lookups persist a number to a worktree at
* creation time. Routing detail lookups through the live per-repo
* preference would silently flip an existing link to a different project
* after the user toggled the selector.
*/
export async function getIssue(
repoPath: string,
issueNumber: number
): Promise<GitLabIssueInfo | null> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
await acquire()
try {
if (projectRef) {
const { stdout } = await glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`],
{ cwd: repoPath }
)
const data = JSON.parse(stdout)
return mapGitLabIssueInfo(data)
}
// Fallback for non-GitLab remotes — let glab infer the project from cwd.
const { stdout } = await glabExecFileAsync(
['issue', 'view', String(issueNumber), '--output', 'json'],
{ cwd: repoPath }
)
const data = JSON.parse(stdout)
return mapGitLabIssueInfo(data)
} catch {
return null
} finally {
release()
}
}
/**
* List issues for a project.
*
* Mirrors github/listIssues returns a structured IssueListResult so
* permission errors surface in the UI instead of collapsing to "No issues".
*/
// Why: GitLab issues only have 'opened' / 'closed' lifecycle states.
// 'all' maps to no state param so the API returns both.
export type IssueListState = 'opened' | 'closed' | 'all'
export async function listIssues(
repoPath: string,
limit = 20,
preference?: IssueSourcePreference,
state: IssueListState = 'opened'
): Promise<IssueListResult> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
await acquire()
try {
if (projectRef) {
const stateParam = state === 'all' ? '' : `&state=${state}`
const { stdout } = await glabExecFileAsync(
[
'api',
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as Record<string, unknown>[]
// Why: GitLab's project issues endpoint returns true issues only
// (MRs are a separate endpoint), so no equivalent of GitHub's
// pull_request filter is needed here.
return {
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
}
}
// Fallback — let glab infer project from cwd. The CLI flag for
// state varies per glab version (--opened, --closed, --all);
// pass through only when targeting a specific state.
const stateFlag = state === 'closed' ? ['--closed'] : state === 'all' ? ['--all'] : ['--opened']
const { stdout } = await glabExecFileAsync(
['issue', 'list', '--output', 'json', '--per-page', String(limit), ...stateFlag],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as unknown[]
return {
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return {
items: [],
error: classifyListIssuesError(stderr)
}
} finally {
release()
}
}
/**
* Create a new GitLab issue. Uses `glab api` with explicit project path so
* the call doesn't depend on cwd matching the project the user picked.
*/
export async function createIssue(
repoPath: string,
title: string,
body: string,
preference?: IssueSourcePreference
): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> {
const trimmedTitle = title.trim()
if (!trimmedTitle) {
return { ok: false, error: 'Title is required' }
}
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
if (!projectRef) {
return {
ok: false,
error: 'Could not resolve GitLab project for this repository'
}
}
await acquire()
try {
const { stdout } = await glabExecFileAsync(
[
'api',
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/issues`,
'-f',
`title=${trimmedTitle}`,
'-f',
// Why: GitLab uses `description` (not `body`) for issue text.
`description=${body}`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as { iid?: number; web_url?: string; url?: string }
if (typeof data.iid !== 'number') {
return { ok: false, error: 'Unexpected response from GitLab' }
}
return {
ok: true,
number: data.iid,
url: String(data.web_url ?? data.url ?? '')
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ok: false, error: message }
} finally {
release()
}
}
/**
* Update an existing GitLab issue.
*
* Why this path doesn't take a preference mirrors github/updateIssue:
* mutations target an issue number already bound to a worktree / linked
* elsewhere. Routing through the live per-repo preference would let a
* user open upstream#N, toggle selector to origin, save, and silently
* write to a different project's issue with the same iid.
*/
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitLabIssueUpdate
): Promise<{ ok: true } | { ok: false; error: string }> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
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], {
cwd: repoPath
})
} 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()
}
}
// Field edits via `glab issue update`.
const editArgs: string[] = ['issue', 'update', String(issueNumber), '-R', repoFlag]
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, { cwd: repoPath })
} 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.
*/
export async function addIssueComment(
repoPath: string,
issueNumber: number,
body: string
): Promise<GitLabCommentResult> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
if (!projectRef) {
return {
ok: false,
error: 'Could not resolve GitLab project for this repository'
}
}
await acquire()
try {
const { stdout } = await glabExecFileAsync(
[
'api',
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}/notes`,
'-f',
`body=${body}`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as {
id?: number
author?: { username?: string; avatar_url?: string; state?: string } | null
body?: string
created_at?: string
// Why: GitLab note responses don't include a per-note web_url; build one
// from the issue URL. We don't have the issue URL here, so leave blank
// — the renderer falls back to the issue URL when comment.url is empty.
}
const comment: MRComment = {
id: data.id ?? Date.now(),
author: data.author?.username ?? 'You',
authorAvatarUrl: data.author?.avatar_url ?? '',
body: data.body ?? body,
createdAt: data.created_at ?? new Date().toISOString(),
url: '',
isBot: data.author?.state === 'bot'
}
return { ok: true, comment }
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
return { ok: false, error: classifyGlabError(stderr).message }
} finally {
release()
}
}
export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference
): Promise<string[]> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
if (!projectRef) {
return []
}
await acquire()
try {
const { stdout } = await glabExecFileAsync(
[
'api',
'--paginate',
`projects/${encodedProject(projectRef.path)}/labels`,
'--jq',
'.[].name'
],
{ cwd: repoPath }
)
return stdout
.trim()
.split('\n')
.filter((l) => l.length > 0)
} catch {
return []
} finally {
release()
}
}
export async function listAssignableUsers(
repoPath: string,
preference?: IssueSourcePreference
): Promise<GitLabAssignableUser[]> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
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',
'--paginate',
`projects/${encodedProject(projectRef.path)}/members/all?per_page=100`,
'--jq',
'.[] | {username, name, avatar_url}'
],
{ cwd: repoPath }
)
type RESTMember = { username?: string; name?: string | null; avatar_url?: 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({
username: user.username,
name: user.name ?? null,
avatarUrl: user.avatar_url ?? ''
})
}
} catch {
// Skip malformed NDJSON lines defensively.
}
}
return users
} catch {
return []
} finally {
release()
}
}

View File

@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers'
describe('mapMRToWorkItem', () => {
it('produces a unified GitLabWorkItem with branch + author', () => {
expect(
mapMRToWorkItem(
{
id: 100,
iid: 5,
title: 'Add support',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/merge_requests/5',
updated_at: '2026-05-05T10:00:00Z',
source_branch: 'feat-x',
target_branch: 'main',
author: { username: 'alice' },
source_project_id: 7,
target_project_id: 7,
labels: [{ name: 'bug' }, 'p1']
},
'g/p'
)
).toEqual({
id: 'gitlab-mr-100',
type: 'mr',
number: 5,
title: 'Add support',
state: 'opened',
url: 'https://gitlab.com/g/p/-/merge_requests/5',
labels: ['bug', 'p1'],
updatedAt: '2026-05-05T10:00:00Z',
author: 'alice',
branchName: 'feat-x',
baseRefName: 'main',
isCrossRepository: false,
repoId: 'g/p'
})
})
it('flags cross-repository when source_project_id !== target_project_id', () => {
const item = mapMRToWorkItem(
{
iid: 1,
title: 't',
state: 'opened',
source_project_id: 5,
target_project_id: 7
},
'g/p'
)
expect(item.isCrossRepository).toBe(true)
})
it('does not flag cross-repository when project ids are absent', () => {
const item = mapMRToWorkItem({ iid: 1, title: 't', state: 'opened' }, 'g/p')
expect(item.isCrossRepository).toBe(false)
})
it('infers draft from a Draft: title prefix', () => {
const item = mapMRToWorkItem({ iid: 1, title: 'Draft: WIP refactor', state: 'opened' }, 'g/p')
expect(item.state).toBe('draft')
})
it('falls back to a deterministic id when GitLab omits global id', () => {
// Why: the GitLab list endpoint always returns id, but the per-MR
// detail endpoint occasionally omits it on older instances. The
// fallback keeps unique-per-(repo,iid) without colliding with other
// MRs in the picker.
const item = mapMRToWorkItem({ iid: 5, title: 't', state: 'opened' }, 'g/p')
expect(item.id).toBe('gitlab-mr-g/p-5')
})
})
describe('mapIssueToWorkItem', () => {
it('coerces opened/closed and produces a unified GitLabWorkItem', () => {
expect(
mapIssueToWorkItem(
{
id: 200,
iid: 9,
title: 'bug',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/issues/9',
updated_at: '2026-05-05T10:00:00Z',
author: { username: 'alice' },
labels: ['bug']
},
'g/p'
)
).toEqual({
id: 'gitlab-issue-200',
type: 'issue',
number: 9,
title: 'bug',
state: 'opened',
url: 'https://gitlab.com/g/p/-/issues/9',
labels: ['bug'],
updatedAt: '2026-05-05T10:00:00Z',
author: 'alice',
repoId: 'g/p'
})
})
it("collapses any non-'opened' state to 'closed'", () => {
expect(mapIssueToWorkItem({ iid: 1, title: 't', state: 'closed' }, 'g/p').state).toBe('closed')
// Defensive: a future state we don't recognize must not leak
// through as a 'merged' or 'draft' value.
expect(mapIssueToWorkItem({ iid: 1, title: 't', state: 'weird' }, 'g/p').state).toBe('closed')
})
})

View File

@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest'
import {
derivePipelineStatus,
mapGitLabIssueInfo,
mapMRInfo,
mapMRState,
mapPipelineJobStatusToCheckStatus,
mapPipelineJobStatusToConclusion
} from './mappers'
describe('mapPipelineJobStatusToCheckStatus', () => {
it('classifies queued lifecycle states', () => {
expect(mapPipelineJobStatusToCheckStatus('created')).toBe('queued')
expect(mapPipelineJobStatusToCheckStatus('pending')).toBe('queued')
expect(mapPipelineJobStatusToCheckStatus('waiting_for_resource')).toBe('queued')
expect(mapPipelineJobStatusToCheckStatus('preparing')).toBe('queued')
})
it('classifies running as in_progress', () => {
expect(mapPipelineJobStatusToCheckStatus('running')).toBe('in_progress')
})
it('classifies success/failed/canceled/skipped/manual as completed', () => {
expect(mapPipelineJobStatusToCheckStatus('success')).toBe('completed')
expect(mapPipelineJobStatusToCheckStatus('failed')).toBe('completed')
expect(mapPipelineJobStatusToCheckStatus('canceled')).toBe('completed')
expect(mapPipelineJobStatusToCheckStatus('skipped')).toBe('completed')
expect(mapPipelineJobStatusToCheckStatus('manual')).toBe('completed')
})
})
describe('mapPipelineJobStatusToConclusion', () => {
it('maps terminal outcomes', () => {
expect(mapPipelineJobStatusToConclusion('success')).toBe('success')
expect(mapPipelineJobStatusToConclusion('failed')).toBe('failure')
expect(mapPipelineJobStatusToConclusion('canceled')).toBe('cancelled')
expect(mapPipelineJobStatusToConclusion('canceling')).toBe('cancelled')
expect(mapPipelineJobStatusToConclusion('skipped')).toBe('skipped')
})
it("maps 'manual' to neutral so it doesn't stall pending forever", () => {
expect(mapPipelineJobStatusToConclusion('manual')).toBe('neutral')
})
it('maps active lifecycle states to pending', () => {
expect(mapPipelineJobStatusToConclusion('running')).toBe('pending')
expect(mapPipelineJobStatusToConclusion('pending')).toBe('pending')
expect(mapPipelineJobStatusToConclusion('scheduled')).toBe('pending')
})
it('returns null for unknown', () => {
expect(mapPipelineJobStatusToConclusion('weird-status')).toBeNull()
})
})
describe('mapMRState', () => {
it('maps merged/closed/locked directly', () => {
expect(mapMRState('merged')).toBe('merged')
expect(mapMRState('closed')).toBe('closed')
expect(mapMRState('locked')).toBe('locked')
})
it('returns draft when the draft flag is set', () => {
expect(mapMRState('opened', true)).toBe('draft')
})
it("infers draft from a 'Draft:' title prefix", () => {
expect(mapMRState('opened', false, 'Draft: refactor auth')).toBe('draft')
expect(mapMRState('opened', undefined, 'WIP: in progress')).toBe('draft')
})
it("returns 'opened' for plain open MRs", () => {
expect(mapMRState('opened', false, 'Add gitlab support')).toBe('opened')
expect(mapMRState('opened')).toBe('opened')
})
})
describe('mapGitLabIssueInfo', () => {
it('uses iid as the number when present', () => {
expect(
mapGitLabIssueInfo({
iid: 42,
title: 'A',
state: 'opened',
web_url: 'https://gitlab.com/g/p/-/issues/42',
labels: [{ name: 'bug' }, { name: 'p1' }]
})
).toEqual({
number: 42,
title: 'A',
state: 'opened',
url: 'https://gitlab.com/g/p/-/issues/42',
labels: ['bug', 'p1']
})
})
it('falls back to number when iid is absent', () => {
expect(mapGitLabIssueInfo({ number: 7, title: 'B', state: 'closed' })).toEqual({
number: 7,
title: 'B',
state: 'closed',
url: '',
labels: []
})
})
it('handles string-only labels', () => {
expect(mapGitLabIssueInfo({ iid: 1, title: 'C', state: 'opened', labels: ['bug'] })).toEqual({
number: 1,
title: 'C',
state: 'opened',
url: '',
labels: ['bug']
})
})
it('passes description / author / authorAvatarUrl through when present', () => {
const info = mapGitLabIssueInfo({
iid: 9,
title: 'bug',
state: 'opened',
description: 'Steps to reproduce.',
author: { username: 'bob', avatar_url: 'https://example.com/b.png' }
})
expect(info.description).toBe('Steps to reproduce.')
expect(info.author).toBe('bob')
expect(info.authorAvatarUrl).toBe('https://example.com/b.png')
})
})
describe('mapMRInfo', () => {
it('builds an MRInfo from a typical glab payload', () => {
expect(
mapMRInfo(
{
iid: 10,
title: 'Add gitlab support',
state: 'opened',
draft: false,
web_url: 'https://gitlab.com/g/p/-/merge_requests/10',
updated_at: '2026-05-05T10:00:00Z',
sha: 'deadbeef',
has_conflicts: false,
detailed_merge_status: 'mergeable'
},
'success'
)
).toEqual({
number: 10,
title: 'Add gitlab support',
state: 'opened',
url: 'https://gitlab.com/g/p/-/merge_requests/10',
pipelineStatus: 'success',
updatedAt: '2026-05-05T10:00:00Z',
mergeable: 'MERGEABLE',
headSha: 'deadbeef'
})
})
it('marks CONFLICTING when has_conflicts is true', () => {
const info = mapMRInfo(
{
iid: 1,
title: 't',
state: 'opened',
has_conflicts: true,
detailed_merge_status: 'mergeable'
},
'pending'
)
expect(info.mergeable).toBe('CONFLICTING')
})
it('marks UNKNOWN when detailed_merge_status is non-mergeable but not a conflict', () => {
const info = mapMRInfo(
{ iid: 1, title: 't', state: 'opened', detailed_merge_status: 'checking' },
'pending'
)
expect(info.mergeable).toBe('UNKNOWN')
})
it('returns draft state when draft flag is set', () => {
const info = mapMRInfo({ iid: 1, title: 't', state: 'opened', draft: true }, 'neutral')
expect(info.state).toBe('draft')
})
it('passes description / author / authorAvatarUrl through when present', () => {
const info = mapMRInfo(
{
iid: 5,
title: 't',
state: 'opened',
description: '## Body\n\nDetails here.',
author: { username: 'alice', avatar_url: 'https://example.com/a.png' }
},
'success'
)
expect(info.description).toBe('## Body\n\nDetails here.')
expect(info.author).toBe('alice')
expect(info.authorAvatarUrl).toBe('https://example.com/a.png')
})
it('omits description / author when absent (distinguishes from list payloads)', () => {
// Why: detail vs list endpoints differ — a `description` of '' on the
// type would be ambiguous with "list payload that stripped the body".
// Prefer absent over default '' so callers can tell them apart.
const info = mapMRInfo({ iid: 5, title: 't', state: 'opened' }, 'success')
expect('description' in info).toBe(false)
expect('author' in info).toBe(false)
expect('authorAvatarUrl' in info).toBe(false)
})
})
// Why: mapMRToWorkItem / mapIssueToWorkItem tests live in
// mappers-workitem.test.ts so this file stays under the oxlint
// max-lines budget. Same import surface, same describe-per-export
// shape — split is mechanical, not behavioral.
describe('derivePipelineStatus', () => {
it('returns neutral for null/undefined/empty', () => {
expect(derivePipelineStatus(null)).toBe('neutral')
expect(derivePipelineStatus(undefined)).toBe('neutral')
expect(derivePipelineStatus([])).toBe('neutral')
})
it('classifies a top-level pipeline string', () => {
expect(derivePipelineStatus('success')).toBe('success')
expect(derivePipelineStatus('failed')).toBe('failure')
expect(derivePipelineStatus('running')).toBe('pending')
expect(derivePipelineStatus('manual')).toBe('neutral')
})
it('rolls up an array of jobs', () => {
expect(derivePipelineStatus([{ status: 'success' }, { status: 'success' }])).toBe('success')
expect(derivePipelineStatus([{ status: 'success' }, { status: 'failed' }])).toBe('failure')
expect(derivePipelineStatus([{ status: 'success' }, { status: 'running' }])).toBe('pending')
})
it('failure beats pending in the rollup', () => {
expect(derivePipelineStatus([{ status: 'failed' }, { status: 'running' }])).toBe('failure')
})
it('handles a single object with status', () => {
expect(derivePipelineStatus({ status: 'success' })).toBe('success')
})
})

327
src/main/gitlab/mappers.ts Normal file
View File

@ -0,0 +1,327 @@
import type {
CheckStatus,
GitLabIssueInfo,
GitLabWorkItem,
MRCheckDetail,
MRInfo,
MRState
} from '../../shared/types'
// ── Pipeline job mapping (GitLab REST `/pipelines/:id/jobs`) ────────
// Why: GitLab pipeline jobs roughly map to GitHub check-runs, but use a
// single `status` field that combines lifecycle + outcome. We split it
// into PRCheckDetail's status + conclusion shape so the renderer can
// share a row with the GitHub side.
export function mapPipelineJobStatusToCheckStatus(status: string): MRCheckDetail['status'] {
const s = status?.toLowerCase()
if (s === 'created' || s === 'pending' || s === 'waiting_for_resource' || s === 'preparing') {
return 'queued'
}
if (s === 'running') {
return 'in_progress'
}
return 'completed'
}
export function mapPipelineJobStatusToConclusion(status: string): MRCheckDetail['conclusion'] {
const s = status?.toLowerCase()
if (s === 'success') {
return 'success'
}
if (s === 'failed') {
return 'failure'
}
if (s === 'canceled' || s === 'canceling') {
return 'cancelled'
}
if (s === 'skipped') {
return 'skipped'
}
// Why: 'manual' jobs require user trigger and never auto-complete; we
// surface them as neutral rather than pending so they don't stall the
// top-level rollup at "pending" forever.
if (s === 'manual') {
return 'neutral'
}
if (
s === 'created' ||
s === 'pending' ||
s === 'running' ||
s === 'waiting_for_resource' ||
s === 'preparing' ||
s === 'scheduled'
) {
return 'pending'
}
return null
}
// ── MR state mapping ────────────────────────────────────────────────
// Why: glab returns the API state directly. Apply the draft flag (or a
// `Draft:` title prefix, which is GitLab's title-based draft convention)
// so the UI sees a single discriminator.
export function mapMRState(state: string, isDraft?: boolean, title?: string): MRState {
const s = state?.toLowerCase()
if (s === 'merged') {
return 'merged'
}
if (s === 'closed') {
return 'closed'
}
if (s === 'locked') {
return 'locked'
}
// Why: GitLab supports drafts via either a boolean field (newer API) or
// a `Draft:` / `WIP:` title prefix (legacy). Either signal counts.
if (isDraft || (title && /^(draft|wip):\s*/i.test(title))) {
return 'draft'
}
return 'opened'
}
// ── Issue mapping ────────────────────────────────────────────────────
// glab issue view returns: { iid, title, state, web_url, labels: [{name}] | string[] }
// `state` is already lowercase 'opened' | 'closed' so the mapping is
// mostly a normalization shim.
export function mapGitLabIssueInfo(data: {
iid?: number
number?: number
title: string
state: string
web_url?: string
url?: string
labels?: { name: string }[] | string[]
description?: string | null
author?: { username?: string | null; avatar_url?: string | null } | null
}): GitLabIssueInfo {
// Why: glab CLI flips between exposing `iid` and `number` depending on
// command + --output flag combination. Accept both.
const number = data.iid ?? data.number ?? 0
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
return {
number,
title: data.title,
state: data.state?.toLowerCase() === 'opened' ? 'opened' : 'closed',
url: data.web_url ?? data.url ?? '',
labels,
// Why: same description / author optional plumbing as mapMRInfo —
// list payloads strip these so callers can tell "absent" from "blank".
...(typeof data.description === 'string' ? { description: data.description } : {}),
...(data.author?.username ? { author: data.author.username } : {}),
...(data.author?.avatar_url ? { authorAvatarUrl: data.author.avatar_url } : {})
}
}
// ── MR info mapping ──────────────────────────────────────────────────
// Why: parallel to mapPRState's role for GitHub. glab returns iid +
// web_url + state + draft + sha + has_conflicts.
type GitLabMRRaw = {
iid?: number
number?: number
title: string
state: string
draft?: boolean
web_url?: string
url?: string
updated_at?: string
updatedAt?: string
sha?: string
has_conflicts?: boolean
detailed_merge_status?: string
description?: string | null
author?: { username?: string | null; avatar_url?: string | null } | null
}
export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInfo {
return {
number: data.iid ?? data.number ?? 0,
title: data.title,
state: mapMRState(data.state, data.draft, data.title),
url: data.web_url ?? data.url ?? '',
pipelineStatus,
updatedAt: data.updated_at ?? data.updatedAt ?? '',
mergeable: deriveMergeable(data),
headSha: data.sha,
// Why: detail-endpoint payloads include `description`; list endpoints
// strip it. Pass through what's present rather than coercing missing
// values to '' so downstream UIs can distinguish "no body authored"
// from "this came from a list and the body is unknown".
...(typeof data.description === 'string' ? { description: data.description } : {}),
...(data.author?.username ? { author: data.author.username } : {}),
...(data.author?.avatar_url ? { authorAvatarUrl: data.author.avatar_url } : {})
}
}
function deriveMergeable(data: GitLabMRRaw): MRInfo['mergeable'] {
if (data.has_conflicts === true) {
return 'CONFLICTING'
}
// Why: detailed_merge_status is GitLab's richest signal. Treat
// 'mergeable' as the only positive value — every other state
// (checking, ci_must_pass, draft_status, etc.) is an unknown from the
// user's POV because it may flip without warning.
if (data.detailed_merge_status === 'mergeable') {
return 'MERGEABLE'
}
if (data.detailed_merge_status === 'broken_status' || data.detailed_merge_status === 'conflict') {
return 'CONFLICTING'
}
return 'UNKNOWN'
}
// ── Pipeline rollup (parallel to GitHub deriveCheckStatus) ──────────
// Why: GitLab returns a single pipeline `status` for the head commit; we
// can also receive an array of jobs and roll them up the same way the
// GitHub side does. Accept either shape.
export function derivePipelineStatus(
rollup: { status?: string }[] | { status?: string } | string | null | undefined
): CheckStatus {
if (!rollup) {
return 'neutral'
}
if (typeof rollup === 'string') {
return classifyPipelineString(rollup)
}
if (!Array.isArray(rollup)) {
return classifyPipelineString(rollup.status ?? '')
}
if (rollup.length === 0) {
return 'neutral'
}
let hasFailure = false
let hasPending = false
for (const job of rollup) {
const s = job.status?.toLowerCase()
if (s === 'failed') {
hasFailure = true
} else if (
s === 'created' ||
s === 'pending' ||
s === 'running' ||
s === 'waiting_for_resource' ||
s === 'preparing' ||
s === 'scheduled'
) {
hasPending = true
}
}
if (hasFailure) {
return 'failure'
}
if (hasPending) {
return 'pending'
}
return 'success'
}
// ── Raw → GitLabWorkItem mapping ────────────────────────────────────
// Why: list endpoints return MR / issue records; the picker consumes a
// unified GitLabWorkItem. Mirrors the GitHub side where MainWorkItem is
// produced from PR / issue REST + GraphQL responses.
type GitLabMRRawForWorkItem = {
id?: number
iid?: number
title: string
state: string
draft?: boolean
web_url?: string
url?: string
updated_at?: string
source_branch?: string
target_branch?: string
author?: { username?: string | null } | null
labels?: ({ name: string } | string)[]
/** Why: source_project_id !== target_project_id signals a fork MR.
* GitLab list endpoints include both the picker uses this flag the
* same way GitHub's isCrossRepository disables fork-MR start points
* when the workspace flow can't safely resolve the head. */
source_project_id?: number
target_project_id?: number
}
export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): GitLabWorkItem {
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
const number = data.iid ?? 0
return {
// Why: id needs to be unique across providers in the picker. Prefix
// 'gitlab-mr-' so a GitHub PR #5 and a GitLab MR !5 don't collide.
id: `gitlab-mr-${data.id ?? `${repoId}-${number}`}`,
type: 'mr',
number,
title: data.title,
state: mapMRState(data.state, data.draft, data.title),
url: data.web_url ?? data.url ?? '',
labels,
updatedAt: data.updated_at ?? '',
author: data.author?.username ?? null,
branchName: data.source_branch,
baseRefName: data.target_branch,
isCrossRepository:
data.source_project_id !== undefined &&
data.target_project_id !== undefined &&
data.source_project_id !== data.target_project_id,
repoId
}
}
type GitLabIssueRawForWorkItem = {
id?: number
iid?: number
title: string
state: string
web_url?: string
url?: string
updated_at?: string
author?: { username?: string | null } | null
labels?: ({ name: string } | string)[]
}
export function mapIssueToWorkItem(
data: GitLabIssueRawForWorkItem,
repoId: string
): GitLabWorkItem {
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
const number = data.iid ?? 0
// Issues only ever resolve to 'opened' or 'closed' (issue state space is
// narrower than MRs); coerce defensively without inventing values.
const state = data.state?.toLowerCase() === 'opened' ? 'opened' : 'closed'
return {
id: `gitlab-issue-${data.id ?? `${repoId}-${number}`}`,
type: 'issue',
number,
title: data.title,
state,
url: data.web_url ?? data.url ?? '',
labels,
updatedAt: data.updated_at ?? '',
author: data.author?.username ?? null,
repoId
}
}
function classifyPipelineString(status: string): CheckStatus {
const s = status.toLowerCase()
if (s === 'success') {
return 'success'
}
if (s === 'failed') {
return 'failure'
}
if (
s === 'created' ||
s === 'pending' ||
s === 'running' ||
s === 'waiting_for_resource' ||
s === 'preparing' ||
s === 'scheduled'
) {
return 'pending'
}
return 'neutral'
}

View File

@ -0,0 +1,252 @@
// 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.
// Files / inline review-comment positioning / approvals are deferred.
import type {
GitLabPipelineJob,
GitLabWorkItem,
GitLabWorkItemDetails,
MRComment
} from '../../shared/types'
import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers'
import {
acquire,
getGlabKnownHosts,
getIssueProjectRef,
getProjectRef,
glabExecFileAsync,
release,
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
): Promise<GitLabRawDiscussion[]> {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
[
'api',
'--paginate',
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100`
],
{ cwd: repoPath }
)
return JSON.parse(stdout) as GitLabRawDiscussion[]
}
// ── Pipeline jobs ──────────────────────────────────────────────────
type GitLabRawJob = {
id?: number
name?: string
stage?: string
status?: string
web_url?: string
duration?: number | null
}
function mapPipelineJob(raw: GitLabRawJob): GitLabPipelineJob {
return {
id: raw.id ?? 0,
name: raw.name ?? '',
stage: raw.stage ?? '',
status: raw.status ?? '',
webUrl: raw.web_url ?? '',
duration: typeof raw.duration === 'number' ? raw.duration : null
}
}
async function fetchPipelineJobs(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number
): Promise<GitLabPipelineJob[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
'--paginate',
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100`
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as GitLabRawJob[]
return data.map(mapPipelineJob)
}
// ── Top-level aggregator ───────────────────────────────────────────
type GitLabRawIssue = Parameters<typeof mapIssueToWorkItem>[0] & {
description?: string | null
assignees?: { username?: string | null }[] | null
}
type GitLabRawMR = Parameters<typeof mapMRToWorkItem>[0] & {
description?: string | null
sha?: string
diff_refs?: { base_sha?: string; head_sha?: string; start_sha?: string } | null
head_pipeline?: { id?: number } | null
}
/**
* Fetch full details for a GitLab MR or issue: the work item itself,
* description body, discussion notes flattened to MRComment[], and (for
* MRs only) per-job pipeline status.
*
* Returns null when the project ref can't be resolved or the item
* can't be loaded callers render a "not found" / error state.
*/
export async function getWorkItemDetails(
repoPath: string,
iid: number,
type: 'issue' | 'mr'
): Promise<GitLabWorkItemDetails | null> {
const knownHosts = await getGlabKnownHosts()
// Why: issues honor the upstream/origin preference (issues live on
// upstream when a fork is checked out). MRs always target origin —
// the fork model puts MRs against the project the user pushes to.
const projectRef =
type === 'issue'
? await getIssueProjectRef(repoPath, knownHosts)
: await getProjectRef(repoPath, knownHosts)
if (!projectRef) {
return null
}
await acquire()
try {
if (type === 'issue') {
return await fetchIssueDetails(repoPath, projectRef, iid)
}
return await fetchMRDetails(repoPath, projectRef, iid)
} catch {
return null
} finally {
release()
}
}
async function fetchIssueDetails(
repoPath: string,
projectRef: ProjectRef,
iid: number
): Promise<GitLabWorkItemDetails | null> {
// Why: fan out the two reads. Issues don't have a pipeline so this
// pair covers everything the dialog renders.
const [issueRes, discussions] = await Promise.all([
glabExecFileAsync(['api', `projects/${encodedProject(projectRef.path)}/issues/${iid}`], {
cwd: repoPath
}),
fetchDiscussions(repoPath, projectRef, 'issue', iid)
])
const issueRaw = JSON.parse(issueRes.stdout) as GitLabRawIssue
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapIssueToWorkItem(issueRaw, projectRef.path)
// Why: omit repoId from the returned shape — the renderer stamps
// it from the dialog's caller (TaskPage / picker) so the main
// process doesn't need to know Orca's Repo.id.
const { repoId: _repoId, ...rest } = full
return rest
})()
return {
item,
body: issueRaw.description ?? '',
comments: flattenDiscussions(discussions),
assignees: (issueRaw.assignees ?? [])
.map((a) => a?.username)
.filter((u): u is string => typeof u === 'string')
}
}
async function fetchMRDetails(
repoPath: string,
projectRef: ProjectRef,
iid: number
): Promise<GitLabWorkItemDetails | null> {
// Why: MR detail + discussions in parallel. The pipeline jobs fetch
// depends on `head_pipeline.id` from the MR payload, so it has to
// wait — but it's a single follow-up call rather than a serial chain.
const [mrRes, discussions] = await Promise.all([
glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`],
{ cwd: repoPath }
),
fetchDiscussions(repoPath, projectRef, 'mr', iid)
])
const mrRaw = JSON.parse(mrRes.stdout) as GitLabRawMR
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapMRToWorkItem(mrRaw, projectRef.path)
const { repoId: _repoId, ...rest } = full
return rest
})()
const pipelineId = mrRaw.head_pipeline?.id
const pipelineJobs =
typeof pipelineId === 'number'
? await fetchPipelineJobs(repoPath, projectRef, pipelineId).catch(() => [])
: undefined
return {
item,
body: mrRaw.description ?? '',
comments: flattenDiscussions(discussions),
headSha: mrRaw.sha,
baseSha: mrRaw.diff_refs?.base_sha,
...(pipelineJobs !== undefined ? { pipelineJobs } : {})
}
}

245
src/main/ipc/gitlab.ts Normal file
View File

@ -0,0 +1,245 @@
/* 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 'path'
import type { GitLabIssueUpdate, Repo } from '../../shared/types'
import type { Store } from '../persistence'
import {
addIssueComment,
addMRComment,
closeMR,
createIssue,
getAuthenticatedViewer,
getIssue,
getMergeRequest,
getMergeRequestForBranch,
getProjectSlug,
getWorkItemByProjectRef,
listAssignableUsers,
listIssues,
listLabels,
listMergeRequests,
listTodos,
listWorkItems,
mergeMR,
reopenMR,
updateIssue
} from '../gitlab/client'
import { getWorkItemDetails } from '../gitlab/work-item-details'
import { computeNextGitLabRecents } from '../../shared/gitlab-projects'
import type { ProjectRef } from '../gitlab/gl-utils'
// 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).
function assertRegisteredRepo(repoPath: string, store: Store): Repo {
const resolvedRepoPath = resolve(repoPath)
const repo = store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath)
if (!repo) {
throw new Error('Access denied: unknown repository path')
}
return repo
}
export function registerGitLabHandlers(store: Store): void {
ipcMain.handle('gitlab:viewer', async () => {
return getAuthenticatedViewer()
})
ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getProjectSlug(repo.path)
})
ipcMain.handle(
'gitlab:mrForBranch',
async (_event, args: { repoPath: string; branch: string; linkedMRIid?: number | null }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getMergeRequestForBranch(repo.path, args.branch, args.linkedMRIid ?? null)
}
)
ipcMain.handle('gitlab:mr', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getMergeRequest(repo.path, args.iid)
})
ipcMain.handle(
'gitlab:listMRs',
async (
_event,
args: {
repoPath: string
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listMergeRequests(
repo.path,
args.state ?? 'opened',
args.page ?? 1,
args.perPage ?? 20
)
}
)
ipcMain.handle('gitlab:issue', async (_event, args: { repoPath: string; number: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getIssue(repo.path, args.number)
})
ipcMain.handle(
'gitlab:listIssues',
async (_event, args: { repoPath: string; limit?: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const result = await listIssues(repo.path, args.limit ?? 20)
// Why: parallel to gh:listIssues which returns just items[]. The
// structured envelope is preserved for callers that need the
// classified error; bare-items consumers get the same shape.
return result.items
}
)
ipcMain.handle(
'gitlab:createIssue',
async (_event, args: { repoPath: string; title: string; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return createIssue(repo.path, args.title, args.body)
}
)
ipcMain.handle(
'gitlab:updateIssue',
async (_event, args: { repoPath: string; number: number; updates: GitLabIssueUpdate }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return updateIssue(repo.path, args.number, args.updates)
}
)
ipcMain.handle(
'gitlab:addIssueComment',
async (_event, args: { repoPath: string; number: number; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return addIssueComment(repo.path, args.number, args.body)
}
)
ipcMain.handle('gitlab:listLabels', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listLabels(repo.path)
})
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listAssignableUsers(repo.path)
})
// 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
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listWorkItems(repo.path, args.state ?? 'opened', args.page ?? 1, args.perPage ?? 20)
}
)
// Why: aggregated dialog payload — body + discussions + pipeline jobs.
// Powers GitLabItemDialog's tabs.
ipcMain.handle(
'gitlab:workItemDetails',
async (_event, args: { repoPath: string; iid: number; type: 'issue' | 'mr' }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getWorkItemDetails(repo.path, args.iid, args.type)
}
)
ipcMain.handle('gitlab:closeMR', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return closeMR(repo.path, args.iid)
})
ipcMain.handle('gitlab:reopenMR', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return reopenMR(repo.path, args.iid)
})
ipcMain.handle(
'gitlab:mergeMR',
async (
_event,
args: { repoPath: string; iid: number; method?: 'merge' | 'squash' | 'rebase' }
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return mergeMR(repo.path, args.iid, args.method ?? 'merge')
}
)
ipcMain.handle(
'gitlab:addMRComment',
async (_event, args: { repoPath: string; iid: number; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return addMRComment(repo.path, args.iid, args.body)
}
)
// 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: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listTodos(repo.path)
})
// 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: {
repoPath: string
host: string
path: string
iid: number
type: 'issue' | 'mr'
}
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const projectRef: ProjectRef = { host: args.host, path: args.path }
const result = await getWorkItemByProjectRef(repo.path, projectRef, args.iid, args.type)
// 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) {
addGitLabProjectToRecent(store, args.host, args.path)
}
return result
}
)
}
function addGitLabProjectToRecent(store: Store, host: string, path: string): void {
const settings = store.getSettings()
const existing = settings.gitlabProjects ?? { pinned: [], recent: [] }
store.updateSettings({
gitlabProjects: {
pinned: existing.pinned,
recent: computeNextGitLabRecents(existing.recent, host, path)
}
})
}

View File

@ -0,0 +1,38 @@
import { ipcMain } from 'electron'
import { resolve } from 'path'
import type { HostedReviewForBranchArgs } from '../../shared/hosted-review'
import type { Repo } from '../../shared/types'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
function assertRegisteredRepo(repoPath: string, store: Store): Repo {
const resolvedRepoPath = resolve(repoPath)
const repo = store.getRepos().find((r) => resolve(r.path) === resolvedRepoPath)
if (!repo) {
throw new Error('Access denied: unknown repository path')
}
return repo
}
export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void {
ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const review = await getHostedReviewForBranch({
repoPath: repo.path,
branch: args.branch,
linkedGitHubPR: args.linkedGitHubPR ?? null,
linkedGitLabMR: args.linkedGitLabMR ?? null,
linkedBitbucketPR: args.linkedBitbucketPR ?? null
})
if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) {
stats.record({
type: 'pr_created',
at: Date.now(),
repoId: repo.id,
meta: { prNumber: review.number, prUrl: review.url }
})
}
return review
})
}

View File

@ -1,13 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, execFileMock, execFileAsyncMock, hydrateShellPathMock, mergePathSegmentsMock } =
vi.hoisted(() => ({
handleMock: vi.fn(),
execFileMock: vi.fn(),
execFileAsyncMock: vi.fn(),
hydrateShellPathMock: vi.fn(),
mergePathSegmentsMock: vi.fn()
}))
const {
handleMock,
execFileMock,
execFileAsyncMock,
hydrateShellPathMock,
mergePathSegmentsMock,
getBitbucketAuthStatusMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
execFileMock: vi.fn(),
execFileAsyncMock: vi.fn(),
hydrateShellPathMock: vi.fn(),
mergePathSegmentsMock: vi.fn(),
getBitbucketAuthStatusMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
@ -30,6 +37,10 @@ vi.mock('../startup/hydrate-shell-path', () => ({
mergePathSegments: mergePathSegmentsMock
}))
vi.mock('../bitbucket/client', () => ({
getBitbucketAuthStatus: getBitbucketAuthStatusMock
}))
import {
_resetPreflightCache,
detectInstalledAgents,
@ -47,6 +58,12 @@ describe('preflight', () => {
execFileAsyncMock.mockReset()
hydrateShellPathMock.mockReset()
mergePathSegmentsMock.mockReset()
getBitbucketAuthStatusMock.mockReset()
getBitbucketAuthStatusMock.mockResolvedValue({
configured: false,
authenticated: false,
account: null
})
_resetPreflightCache()
for (const key of Object.keys(handlers)) {
@ -58,19 +75,29 @@ describe('preflight', () => {
})
})
// Why: every preflight run probes (in order) `git --version`, `gh --version`,
// `glab --version`, then in parallel `gh auth status` + `glab auth status` —
// five execFile calls per cycle. Tests below provide values for all five.
it('marks gh as authenticated when gh auth status exits successfully', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
const status = await runPreflightCheck()
expect(status).toEqual({
git: { installed: true },
gh: { installed: true, authenticated: true }
gh: { installed: true, authenticated: true },
glab: { installed: true, authenticated: true },
bitbucket: { configured: false, authenticated: false, account: null }
})
expect(execFileAsyncMock).toHaveBeenNthCalledWith(3, 'gh', ['auth', 'status'], {
expect(execFileAsyncMock).toHaveBeenNthCalledWith(4, 'gh', ['auth', 'status'], {
encoding: 'utf-8'
})
expect(execFileAsyncMock).toHaveBeenNthCalledWith(5, 'glab', ['auth', 'status'], {
encoding: 'utf-8'
})
})
@ -79,7 +106,9 @@ describe('preflight', () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
const status = await runPreflightCheck()
@ -90,35 +119,71 @@ describe('preflight', () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockRejectedValueOnce({ stderr: 'Logged in to github.com account octocat\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
const status = await runPreflightCheck()
expect(status.gh).toEqual({ installed: true, authenticated: true })
})
it('marks glab as not installed when `glab --version` fails', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockRejectedValueOnce(new Error('command not found: glab'))
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
const status = await runPreflightCheck()
expect(status.glab).toEqual({ installed: false, authenticated: false })
// Why: with glab uninstalled, glab auth status must not run — that
// would surface a misleading "command not found" error in logs.
expect(execFileAsyncMock).toHaveBeenCalledTimes(4)
})
it('marks glab as installed but unauthenticated when auth status fails', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitLab hosts.\n' })
const status = await runPreflightCheck()
expect(status.glab).toEqual({ installed: true, authenticated: false })
})
it('re-runs the probe when forced so updated gh auth state is visible without relaunch', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
const firstStatus = await runPreflightCheck()
const refreshedStatus = await runPreflightCheck(true)
expect(firstStatus.gh).toEqual({ installed: true, authenticated: false })
expect(refreshedStatus.gh).toEqual({ installed: true, authenticated: true })
expect(execFileAsyncMock).toHaveBeenCalledTimes(6)
expect(execFileAsyncMock).toHaveBeenCalledTimes(10)
})
it('registers the preflight handler', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockResolvedValueOnce({ stdout: 'github.com\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
registerPreflightHandlers()
@ -126,7 +191,9 @@ describe('preflight', () => {
expect(status).toEqual({
git: { installed: true },
gh: { installed: true, authenticated: true }
gh: { installed: true, authenticated: true },
glab: { installed: true, authenticated: true },
bitbucket: { configured: false, authenticated: false, account: null }
})
})
@ -134,10 +201,14 @@ describe('preflight', () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
registerPreflightHandlers()
@ -146,11 +217,15 @@ describe('preflight', () => {
expect(firstStatus).toEqual({
git: { installed: true },
gh: { installed: true, authenticated: false }
gh: { installed: true, authenticated: false },
glab: { installed: true, authenticated: true },
bitbucket: { configured: false, authenticated: false, account: null }
})
expect(refreshedStatus).toEqual({
git: { installed: true },
gh: { installed: true, authenticated: true }
gh: { installed: true, authenticated: true },
glab: { installed: true, authenticated: true },
bitbucket: { configured: false, authenticated: false, account: null }
})
})

View File

@ -5,6 +5,7 @@ import path from 'path'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { PathSource, ShellHydrationFailureReason } from '../../shared/types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { getBitbucketAuthStatus } from '../bitbucket/client'
import { getActiveMultiplexer } from './ssh'
const execFileAsync = promisify(execFile)
@ -12,6 +13,12 @@ const execFileAsync = promisify(execFile)
export type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
// Why: optional so existing renderer call sites that only render git/gh
// status keep typechecking. Consumers that surface GitLab-specific
// affordances (the GitLab tab in the source picker, MR list, etc.)
// gate on `glab?.authenticated`.
glab?: { installed: boolean; authenticated: boolean }
bitbucket?: { configured: boolean; authenticated: boolean; account: string | null }
}
// Why: cache the result so repeated Landing mounts don't re-spawn processes.
@ -118,21 +125,42 @@ async function isGhAuthenticated(): Promise<boolean> {
}
}
// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth
// status to stderr in some versions and stdout in others; check both.
async function isGlabAuthenticated(): Promise<boolean> {
try {
await execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' })
return true
} catch (error) {
const stdout = (error as { stdout?: string }).stdout ?? ''
const stderr = (error as { stderr?: string }).stderr ?? ''
const output = `${stdout}\n${stderr}`
return output.includes('Logged in')
}
}
export async function runPreflightCheck(force = false): Promise<PreflightStatus> {
if (cached && !force) {
return cached
}
const [gitInstalled, ghInstalled] = await Promise.all([
const [gitInstalled, ghInstalled, glabInstalled] = await Promise.all([
isCommandAvailable('git'),
isCommandAvailable('gh')
isCommandAvailable('gh'),
isCommandAvailable('glab')
])
const ghAuthenticated = ghInstalled ? await isGhAuthenticated() : false
const [ghAuthenticated, glabAuthenticated, bitbucket] = await Promise.all([
ghInstalled ? isGhAuthenticated() : Promise.resolve(false),
glabInstalled ? isGlabAuthenticated() : Promise.resolve(false),
getBitbucketAuthStatus()
])
cached = {
git: { installed: gitInstalled },
gh: { installed: ghInstalled, authenticated: ghAuthenticated }
gh: { installed: ghInstalled, authenticated: ghAuthenticated },
glab: { installed: glabInstalled, authenticated: glabAuthenticated },
bitbucket
}
return cached

View File

@ -37,6 +37,8 @@ const {
registerFilesystemWatcherHandlersMock,
registerAppHandlersMock,
registerLinearHandlersMock,
registerGitLabHandlersMock,
registerHostedReviewHandlersMock,
registerExportHandlersMock,
registerOnboardingHandlersMock,
registerSpeechHandlersMock
@ -75,6 +77,8 @@ const {
registerFilesystemWatcherHandlersMock: vi.fn(),
registerAppHandlersMock: vi.fn(),
registerLinearHandlersMock: vi.fn(),
registerGitLabHandlersMock: vi.fn(),
registerHostedReviewHandlersMock: vi.fn(),
registerExportHandlersMock: vi.fn(),
registerOnboardingHandlersMock: vi.fn(),
registerSpeechHandlersMock: vi.fn()
@ -219,6 +223,14 @@ vi.mock('./linear', () => ({
registerLinearHandlers: registerLinearHandlersMock
}))
vi.mock('./gitlab', () => ({
registerGitLabHandlers: registerGitLabHandlersMock
}))
vi.mock('./hosted-review', () => ({
registerHostedReviewHandlers: registerHostedReviewHandlersMock
}))
import { registerCoreHandlers } from './register-core-handlers'
describe('registerCoreHandlers', () => {
@ -257,6 +269,8 @@ describe('registerCoreHandlers', () => {
registerFilesystemWatcherHandlersMock.mockReset()
registerAppHandlersMock.mockReset()
registerLinearHandlersMock.mockReset()
registerGitLabHandlersMock.mockReset()
registerHostedReviewHandlersMock.mockReset()
registerExportHandlersMock.mockReset()
registerSpeechHandlersMock.mockReset()
})
@ -291,6 +305,8 @@ describe('registerCoreHandlers', () => {
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
expect(registerLinearHandlersMock).toHaveBeenCalled()
expect(registerGitLabHandlersMock).toHaveBeenCalledWith(store)
expect(registerHostedReviewHandlersMock).toHaveBeenCalledWith(store, stats)
expect(registerFeedbackHandlersMock).toHaveBeenCalled()
expect(registerStatsHandlersMock).toHaveBeenCalledWith(stats)
expect(registerMemoryHandlersMock).toHaveBeenCalledWith(store)

View File

@ -9,6 +9,8 @@ import { registerFilesystemWatcherHandlers } from './filesystem-watcher'
import { registerClaudeUsageHandlers } from './claude-usage'
import { registerCodexUsageHandlers } from './codex-usage'
import { registerGitHubHandlers } from './github'
import { registerGitLabHandlers } from './gitlab'
import { registerHostedReviewHandlers } from './hosted-review'
import { registerLinearHandlers } from './linear'
import { registerFeedbackHandlers } from './feedback'
import { registerExportHandlers } from './export'
@ -85,6 +87,8 @@ export function registerCoreHandlers(
registerClaudeAccountHandlers(claudeAccounts)
registerRateLimitHandlers(rateLimits)
registerGitHubHandlers(store, stats)
registerGitLabHandlers(store)
registerHostedReviewHandlers(store, stats)
registerLinearHandlers()
registerFeedbackHandlers()
registerExportHandlers()

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: these worktree path/name tests share a
single setup-free pure-logic module, and splitting them would make the related
edge cases harder to audit together. */
import { join, resolve } from 'path'
import { describe, expect, it } from 'vitest'
import {
@ -212,6 +215,8 @@ describe('mergeWorktree', () => {
linkedIssue: 42,
linkedPR: 10,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: true,
isUnread: true,
isPinned: true,
@ -232,6 +237,8 @@ describe('mergeWorktree', () => {
linkedIssue: 42,
linkedPR: 10,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: true,
isUnread: true,
isPinned: true,

View File

@ -187,6 +187,8 @@ export function mergeWorktree(
linkedIssue: meta?.linkedIssue ?? null,
linkedPR: meta?.linkedPR ?? null,
linkedLinearIssue: meta?.linkedLinearIssue ?? null,
linkedGitLabMR: meta?.linkedGitLabMR ?? null,
linkedGitLabIssue: meta?.linkedGitLabIssue ?? null,
isArchived: meta?.isArchived ?? false,
isUnread: meta?.isUnread ?? false,
isPinned: meta?.isPinned ?? false,

View File

@ -17,6 +17,8 @@ import { removeWorktree } from '../git/worktree'
import { gitExecFileAsync } from '../git/runner'
import { getDefaultRemote } from '../git/repo'
import { getPullRequestPushTarget, getWorkItem } from '../github/client'
import { getProjectRef as getGlabProjectRef, getGlabKnownHosts } from '../gitlab/gl-utils'
import { getWorkItemByProjectRef as getGitLabWorkItemByProjectRef } from '../gitlab/client'
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
@ -398,6 +400,111 @@ export function registerWorktreeHandlers(
}
)
// Why: GitLab parallel of worktrees:resolvePrBase. Same shape, same
// semantics — caller passes mrIid (with optional source_branch +
// isCrossRepository hints from the picker) and we return either a
// `<remote>/<source_branch>` ref (same-project MRs) or a SHA fetched
// from refs/merge-requests/<iid>/head (fork MRs). The returned value
// is the workspace's base ref; the new worktree branch derives from
// the workspace name, not from the source ref.
ipcMain.handle(
'worktrees:resolveMrBase',
async (
_event,
args: {
repoId: string
mrIid: number
sourceBranch?: string
isCrossRepository?: boolean
}
): Promise<{ baseBranch: string } | { error: string }> => {
const repo = store.getRepo(args.repoId)
if (!repo) {
return { error: 'Repo not found' }
}
// Why: parity with the gh-side guard above. Remote SSH repos are
// out of v1 scope; the picker disables the GitLab tab for them too.
if (repo.connectionId) {
return { error: 'MR start points are not supported for remote repos yet.' }
}
if (isFolderRepo(repo)) {
return { error: 'Folder mode does not support creating worktrees.' }
}
let sourceBranch = args.sourceBranch?.trim() ?? ''
let isCrossRepository = args.isCrossRepository === true
if (!sourceBranch) {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getGlabProjectRef(repo.path, knownHosts)
if (!projectRef) {
return { error: 'No GitLab project found for this repository.' }
}
const item = await getGitLabWorkItemByProjectRef(repo.path, projectRef, args.mrIid, 'mr')
if (!item || item.type !== 'mr') {
return { error: `MR !${args.mrIid} not found.` }
}
sourceBranch = (item.branchName ?? '').trim()
if (!sourceBranch) {
return { error: `MR !${args.mrIid} has no source branch.` }
}
if (item.isCrossRepository === true) {
isCrossRepository = true
}
}
let remote: string
try {
remote = await getDefaultRemote(repo.path)
} catch (error) {
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
}
// Why: GitLab exposes every MR head (fork or same-project) as
// refs/merge-requests/<iid>/head on the target project. Using that
// ref lets us snapshot fork MRs without configuring the fork as a
// remote — same SHA-as-baseBranch shape as the gh-side branch above.
if (isCrossRepository) {
const mrRef = `refs/merge-requests/${args.mrIid}/head`
try {
await gitExecFileAsync(['fetch', remote, mrRef], { cwd: repo.path })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` }
}
let sha: string
try {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], {
cwd: repo.path
})
sha = stdout.trim()
} catch {
return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` }
}
if (!sha) {
return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` }
}
return { baseBranch: sha }
}
try {
await gitExecFileAsync(['fetch', remote, sourceBranch], { cwd: repo.path })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` }
}
const remoteRef = `${remote}/${sourceBranch}`
try {
await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path })
} catch {
return { error: `Remote ref ${remoteRef} does not exist after fetch.` }
}
return { baseBranch: remoteRef }
}
)
ipcMain.handle(
'worktrees:remove',
async (_event, args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => {

View File

@ -1700,6 +1700,8 @@ function getDefaultWorktreeMeta(): WorktreeMeta {
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -167,6 +167,8 @@ const store = {
linkedIssue: 123,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
@ -2344,6 +2346,8 @@ describe('OrcaRuntimeService', () => {
linkedIssue: meta.linkedIssue ?? existingMeta?.linkedIssue ?? null,
linkedPR: meta.linkedPR ?? existingMeta?.linkedPR ?? null,
linkedLinearIssue: meta.linkedLinearIssue ?? existingMeta?.linkedLinearIssue ?? null,
linkedGitLabMR: meta.linkedGitLabMR ?? existingMeta?.linkedGitLabMR ?? null,
linkedGitLabIssue: meta.linkedGitLabIssue ?? existingMeta?.linkedGitLabIssue ?? null,
isArchived: meta.isArchived ?? existingMeta?.isArchived ?? false,
isUnread: meta.isUnread ?? existingMeta?.isUnread ?? false,
isPinned: meta.isPinned ?? existingMeta?.isPinned ?? false,

View File

@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
getProjectSlugMock,
getMergeRequestForBranchMock,
getRepoSlugMock,
getPRForBranchMock,
getBitbucketRepoSlugMock,
getBitbucketPullRequestForBranchMock
} = vi.hoisted(() => ({
getProjectSlugMock: vi.fn(),
getMergeRequestForBranchMock: vi.fn(),
getRepoSlugMock: vi.fn(),
getPRForBranchMock: vi.fn(),
getBitbucketRepoSlugMock: vi.fn(),
getBitbucketPullRequestForBranchMock: vi.fn()
}))
vi.mock('../gitlab/client', () => ({
getProjectSlug: getProjectSlugMock,
getMergeRequestForBranch: getMergeRequestForBranchMock,
getMergeRequest: vi.fn()
}))
vi.mock('../github/client', () => ({
getRepoSlug: getRepoSlugMock,
getPRForBranch: getPRForBranchMock
}))
vi.mock('../bitbucket/client', () => ({
getBitbucketRepoSlug: getBitbucketRepoSlugMock,
getBitbucketPullRequestForBranch: getBitbucketPullRequestForBranchMock,
getBitbucketPullRequest: vi.fn()
}))
import { getHostedReviewForBranch } from './hosted-review'
describe('getHostedReviewForBranch', () => {
beforeEach(() => {
getProjectSlugMock.mockReset()
getMergeRequestForBranchMock.mockReset()
getRepoSlugMock.mockReset()
getPRForBranchMock.mockReset()
getBitbucketRepoSlugMock.mockReset()
getBitbucketPullRequestForBranchMock.mockReset()
})
it('maps GitLab merge requests into the hosted review surface', async () => {
getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' })
getMergeRequestForBranchMock.mockResolvedValue({
number: 7,
title: 'GitLab branch',
state: 'opened',
url: 'https://gitlab.com/g/p/-/merge_requests/7',
pipelineStatus: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'MERGEABLE'
})
await expect(
getHostedReviewForBranch({ repoPath: '/repo', branch: 'refs/heads/feature' })
).resolves.toEqual({
provider: 'gitlab',
number: 7,
title: 'GitLab branch',
state: 'open',
url: 'https://gitlab.com/g/p/-/merge_requests/7',
status: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'MERGEABLE'
})
expect(getPRForBranchMock).not.toHaveBeenCalled()
})
it('falls through to GitHub when origin is not GitLab', async () => {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue({ owner: 'o', repo: 'r' })
getPRForBranchMock.mockResolvedValue({
number: 3,
title: 'GitHub branch',
state: 'open',
url: 'https://github.com/o/r/pull/3',
checksStatus: 'pending',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'UNKNOWN'
})
await expect(
getHostedReviewForBranch({
repoPath: '/repo',
branch: 'feature',
linkedGitHubPR: 3
})
).resolves.toMatchObject({
provider: 'github',
number: 3,
status: 'pending'
})
expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', 'feature', 3)
})
it('falls through to Bitbucket when origin is not GitLab or GitHub', async () => {
getProjectSlugMock.mockResolvedValue(null)
getRepoSlugMock.mockResolvedValue(null)
getBitbucketRepoSlugMock.mockResolvedValue({ workspace: 'team', repoSlug: 'orca' })
getBitbucketPullRequestForBranchMock.mockResolvedValue({
number: 11,
title: 'Bitbucket branch',
state: 'open',
url: 'https://bitbucket.org/team/orca/pull-requests/11',
status: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'UNKNOWN',
headSha: 'abc123'
})
await expect(
getHostedReviewForBranch({
repoPath: '/repo',
branch: 'feature/bitbucket',
linkedBitbucketPR: 11
})
).resolves.toEqual({
provider: 'bitbucket',
number: 11,
title: 'Bitbucket branch',
state: 'open',
url: 'https://bitbucket.org/team/orca/pull-requests/11',
status: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'UNKNOWN',
headSha: 'abc123'
})
expect(getBitbucketPullRequestForBranchMock).toHaveBeenCalledWith(
'/repo',
'feature/bitbucket',
11
)
})
})

View File

@ -0,0 +1,125 @@
import type { HostedReviewInfo } from '../../shared/hosted-review'
import type { MRInfo, PRInfo } from '../../shared/types'
import {
getBitbucketPullRequest,
getBitbucketPullRequestForBranch,
getBitbucketRepoSlug
} from '../bitbucket/client'
import type { BitbucketPullRequestInfo } from '../bitbucket/pull-request-mappers'
import { getPRForBranch, getRepoSlug } from '../github/client'
import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client'
function mapGitHubReview(pr: PRInfo): HostedReviewInfo {
return {
provider: 'github',
number: pr.number,
title: pr.title,
state: pr.state,
url: pr.url,
status: pr.checksStatus,
updatedAt: pr.updatedAt,
mergeable: pr.mergeable,
...(pr.headSha ? { headSha: pr.headSha } : {}),
...(pr.conflictSummary ? { conflictSummary: pr.conflictSummary } : {})
}
}
function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] {
if (state === 'opened' || state === 'locked') {
return 'open'
}
return state
}
function mapGitLabReview(mr: MRInfo): HostedReviewInfo {
return {
provider: 'gitlab',
number: mr.number,
title: mr.title,
state: mapGitLabReviewState(mr.state),
url: mr.url,
status: mr.pipelineStatus,
updatedAt: mr.updatedAt,
mergeable: mr.mergeable,
...(mr.headSha ? { headSha: mr.headSha } : {}),
...(mr.conflictSummary ? { conflictSummary: mr.conflictSummary } : {})
}
}
function mapBitbucketReview(pr: BitbucketPullRequestInfo): HostedReviewInfo {
return {
provider: 'bitbucket',
number: pr.number,
title: pr.title,
state: pr.state,
url: pr.url,
status: pr.status,
updatedAt: pr.updatedAt,
mergeable: pr.mergeable,
...(pr.headSha ? { headSha: pr.headSha } : {})
}
}
export async function getHostedReviewForBranch(input: {
repoPath: string
branch: string
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
}): Promise<HostedReviewInfo | null> {
const branchName = input.branch.replace(/^refs\/heads\//, '')
if (
!branchName &&
input.linkedGitHubPR == null &&
input.linkedGitLabMR == null &&
input.linkedBitbucketPR == null
) {
return null
}
// Why: branch review status is tied to the branch publishing remote.
// GitHub and GitLab task/project surfaces may use richer per-provider
// source preferences, but this core status should follow origin.
const gitlabProject = await getProjectSlug(input.repoPath)
if (gitlabProject) {
const mr =
(await getMergeRequestForBranch(input.repoPath, branchName, input.linkedGitLabMR ?? null)) ??
null
return mr ? mapGitLabReview(mr) : null
}
const githubRepo = await getRepoSlug(input.repoPath)
if (githubRepo) {
const pr = await getPRForBranch(input.repoPath, branchName, input.linkedGitHubPR ?? null)
return pr ? mapGitHubReview(pr) : null
}
const bitbucketRepo = await getBitbucketRepoSlug(input.repoPath)
if (bitbucketRepo) {
const pr = await getBitbucketPullRequestForBranch(
input.repoPath,
branchName,
input.linkedBitbucketPR ?? null
)
return pr ? mapBitbucketReview(pr) : null
}
return null
}
export async function getHostedReviewByNumber(input: {
repoPath: string
provider: 'github' | 'gitlab' | 'bitbucket'
number: number
}): Promise<HostedReviewInfo | null> {
if (input.provider === 'gitlab') {
const mr = await getMergeRequest(input.repoPath, input.number)
return mr ? mapGitLabReview(mr) : null
}
if (input.provider === 'bitbucket') {
const pr = await getBitbucketPullRequest(input.repoPath, input.number)
return pr ? mapBitbucketReview(pr) : null
}
const pr = await getPRForBranch(input.repoPath, '', input.number)
return pr ? mapGitHubReview(pr) : null
}

View File

@ -1,4 +1,5 @@
/* eslint-disable max-lines -- Why: the preload contract is intentionally centralized in one declaration file so renderer and preload stay in lockstep when IPC surfaces change. */
import type { HostedReviewForBranchArgs, HostedReviewInfo } from '../shared/hosted-review'
import type {
BaseRefDefaultResult,
BrowserCookieImportResult,
@ -30,6 +31,18 @@ import type {
GitHubWorkItem,
GitHubWorkItemDetails,
GitHubViewer,
GitLabAssignableUser,
GitLabCommentResult,
GitLabIssueInfo,
GitLabIssueUpdate,
GitLabProjectRef,
GitLabTodo,
GitLabViewer,
GitLabWorkItem,
GitLabWorkItemDetails,
ListMergeRequestsResult,
MRInfo,
MRListState,
ListWorkItemsResult,
IssueInfo,
LinearViewer,
@ -284,6 +297,10 @@ export type DetectedBrowserInfo = {
export type PreflightStatus = {
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
/** Optional older preload payloads predating GitLab support don't
* include it. Consumers gate on `glab?.installed` / `authenticated`. */
glab?: { installed: boolean; authenticated: boolean }
bitbucket?: { configured: boolean; authenticated: boolean; account: string | null }
}
export type RefreshAgentsResult = {
@ -488,6 +505,15 @@ export type PreloadApi = {
headRefName?: string
isCrossRepository?: boolean
}) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>
/** GitLab parallel of resolvePrBase. For same-project MRs returns
* `<remote>/<source_branch>`; for fork MRs fetches
* refs/merge-requests/<iid>/head and returns the SHA. */
resolveMrBase: (args: {
repoId: string
mrIid: number
sourceBranch?: string
isCrossRepository?: boolean
}) => Promise<{ baseBranch: string } | { error: string }>
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise<void>
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
persistSortOrder: (args: { orderedIds: string[] }) => Promise<void>
@ -725,6 +751,89 @@ export type PreloadApi = {
listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise<ListIssueTypesBySlugResult>
updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs) => Promise<GitHubProjectMutationResult>
}
hostedReview: {
forBranch: (args: HostedReviewForBranchArgs) => Promise<HostedReviewInfo | null>
}
// ── GitLab — parallel to gh, MR/issue surface only in v1 ────────
// Shapes mirror gh.* one-to-one where the data matches; diverge
// where GitLab's API differs (MR state values, project path with
// host, paginated envelope from `glab api -i`).
gl: {
viewer: () => Promise<GitLabViewer | null>
projectSlug: (args: { repoPath: string }) => Promise<GitLabProjectRef | null>
mrForBranch: (args: {
repoPath: string
branch: string
linkedMRIid?: number | null
}) => Promise<MRInfo | null>
mr: (args: { repoPath: string; iid: number }) => Promise<MRInfo | null>
listMRs: (args: {
repoPath: string
state?: MRListState
page?: number
perPage?: number
}) => Promise<ListMergeRequestsResult>
/** Combined MR + issue list filtered by state. Issues are skipped
* when state is 'merged' (issues don't merge). */
listWorkItems: (args: {
repoPath: string
state?: MRListState
page?: number
perPage?: number
}) => Promise<ListMergeRequestsResult>
issue: (args: { repoPath: string; number: number }) => Promise<GitLabIssueInfo | null>
listIssues: (args: { repoPath: string; limit?: number }) => Promise<GitLabIssueInfo[]>
createIssue: (args: {
repoPath: string
title: string
body: string
}) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }>
updateIssue: (args: {
repoPath: string
number: number
updates: GitLabIssueUpdate
}) => Promise<{ ok: true } | { ok: false; error: string }>
addIssueComment: (args: {
repoPath: string
number: number
body: string
}) => Promise<GitLabCommentResult>
listLabels: (args: { repoPath: string }) => Promise<string[]>
listAssignableUsers: (args: { repoPath: string }) => Promise<GitLabAssignableUser[]>
/** Cross-project user-scoped todos (gitlab.com/dashboard/todos). */
todos: (args: { repoPath: string }) => Promise<GitLabTodo[]>
/** Aggregated dialog payload — body + discussions + pipeline jobs. */
workItemDetails: (args: {
repoPath: string
iid: number
type: 'issue' | 'mr'
}) => Promise<GitLabWorkItemDetails | null>
closeMR: (args: {
repoPath: string
iid: number
}) => Promise<{ ok: true } | { ok: false; error: string }>
reopenMR: (args: {
repoPath: string
iid: number
}) => Promise<{ ok: true } | { ok: false; error: string }>
mergeMR: (args: {
repoPath: string
iid: number
method?: 'merge' | 'squash' | 'rebase'
}) => Promise<{ ok: true } | { ok: false; error: string }>
addMRComment: (args: {
repoPath: string
iid: number
body: string
}) => Promise<GitLabCommentResult>
workItemByPath: (args: {
repoPath: string
host: string
path: string
iid: number
type: 'issue' | 'mr'
}) => Promise<Omit<GitLabWorkItem, 'repoId'> | null>
}
linear: {
connect: (args: {
apiKey: string

103
src/preload/gitlab.ts Normal file
View File

@ -0,0 +1,103 @@
/* GitLab preload bindings split out of `src/preload/index.ts` so
adding or changing a `gl.*` channel doesn't surface as a merge
conflict on every upstream sync of the much larger central preload
file. Composed back into `api.gl` from `index.ts`. */
import { ipcRenderer } from 'electron'
export const glApi = {
viewer: (): Promise<unknown> => ipcRenderer.invoke('gitlab:viewer'),
projectSlug: (args: { repoPath: string }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:projectSlug', args),
mrForBranch: (args: {
repoPath: string
branch: string
linkedMRIid?: number | null
}): Promise<unknown> => ipcRenderer.invoke('gitlab:mrForBranch', args),
mr: (args: { repoPath: string; iid: number }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:mr', args),
listMRs: (args: {
repoPath: string
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}): Promise<unknown> => ipcRenderer.invoke('gitlab:listMRs', args),
listWorkItems: (args: {
repoPath: string
state?: 'opened' | 'merged' | 'closed' | 'all'
page?: number
perPage?: number
}): Promise<unknown> => ipcRenderer.invoke('gitlab:listWorkItems', args),
issue: (args: { repoPath: string; number: number }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:issue', args),
listIssues: (args: { repoPath: string; limit?: number }): Promise<unknown[]> =>
ipcRenderer.invoke('gitlab:listIssues', args),
createIssue: (args: {
repoPath: string
title: string
body: string
}): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:createIssue', args),
updateIssue: (args: {
repoPath: string
number: number
updates: unknown
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:updateIssue', args),
addIssueComment: (args: { repoPath: string; number: number; body: string }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:addIssueComment', args),
listLabels: (args: { repoPath: string }): Promise<string[]> =>
ipcRenderer.invoke('gitlab:listLabels', args),
listAssignableUsers: (args: { repoPath: string }): Promise<unknown[]> =>
ipcRenderer.invoke('gitlab:listAssignableUsers', args),
todos: (args: { repoPath: string }): Promise<unknown[]> =>
ipcRenderer.invoke('gitlab:todos', args),
workItemDetails: (args: {
repoPath: string
iid: number
type: 'issue' | 'mr'
}): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemDetails', args),
closeMR: (args: {
repoPath: string
iid: number
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:closeMR', args),
reopenMR: (args: {
repoPath: string
iid: number
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:reopenMR', args),
mergeMR: (args: {
repoPath: string
iid: number
method?: 'merge' | 'squash' | 'rebase'
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gitlab:mergeMR', args),
addMRComment: (args: { repoPath: string; iid: number; body: string }): Promise<unknown> =>
ipcRenderer.invoke('gitlab:addMRComment', args),
workItemByPath: (args: {
repoPath: string
host: string
path: string
iid: number
type: 'issue' | 'mr'
}): Promise<unknown> => ipcRenderer.invoke('gitlab:workItemByPath', args)
}

View File

@ -4,6 +4,7 @@ review and type drift checks easier than scattering these bindings across module
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { preloadE2EConfig } from './e2e-config'
import { glApi } from './gitlab'
import type { CliInstallStatus } from '../shared/cli-install-types'
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type {
@ -96,6 +97,7 @@ import {
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT
} from '../shared/updater-renderer-events'
import type { HostedReviewForBranchArgs } from '../shared/hosted-review'
type NativeDropResolution =
| { target: 'editor' }
@ -440,6 +442,14 @@ const api = {
}): Promise<{ baseBranch: string; pushTarget?: unknown } | { error: string }> =>
ipcRenderer.invoke('worktrees:resolvePrBase', args),
resolveMrBase: (args: {
repoId: string
mrIid: number
sourceBranch?: string
isCrossRepository?: boolean
}): Promise<{ baseBranch: string } | { error: string }> =>
ipcRenderer.invoke('worktrees:resolveMrBase', args),
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise<void> =>
ipcRenderer.invoke('worktrees:remove', args),
@ -866,6 +876,16 @@ const api = {
): Promise<GitHubProjectMutationResult> => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args)
},
hostedReview: {
forBranch: (args: HostedReviewForBranchArgs): Promise<unknown> =>
ipcRenderer.invoke('hostedReview:forBranch', args)
},
// Why: GitLab bindings live in `./gitlab` so adding or changing a
// `gl.*` channel doesn't surface as a merge conflict on every
// upstream sync of this central preload file.
gl: glApi,
linear: {
connect: (args: {
apiKey: string
@ -1022,6 +1042,8 @@ const api = {
}): Promise<{
git: { installed: boolean }
gh: { installed: boolean; authenticated: boolean }
glab?: { installed: boolean; authenticated: boolean }
bitbucket?: { configured: boolean; authenticated: boolean; account: string | null }
linear: { connected: boolean }
}> => ipcRenderer.invoke('preflight:check', args),
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents'),

View File

@ -0,0 +1,515 @@
/* eslint-disable max-lines -- Why: dialog co-locates header, three
tabs (Description / Conversation / Pipeline), comment composer,
and four mutation actions. Splitting any of these into separate
components would make the close/reopen/merge state coupling
non-obvious. The GitHub-side equivalent (GitHubItemDialog) carries
the same disable for the same reason. */
/* Why: GitLab counterpart to GitHubItemDialog. Side sheet with three
tabs (Description / Conversation / Pipeline) and footer actions
close/reopen, merge, and a top-level comment composer. Files /
inline review-comment positioning / approvals are deferred to v1.5
since they mirror substantial GitHub-side surface area. */
import React, { useCallback, useEffect, useState } from 'react'
import { CircleDot, ExternalLink, GitMerge, LoaderCircle, RefreshCw, Send } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { VisuallyHidden } from 'radix-ui'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import type {
GitLabPipelineJob,
GitLabWorkItem,
GitLabWorkItemDetails,
MRComment
} from '../../../shared/types'
type Props = {
item: GitLabWorkItem | null
repoPath: string | null
onClose: () => void
onCreateWorkspace?: (item: GitLabWorkItem) => void
}
// Why: GitLab MR / issue states map onto a coarser palette than GitHub.
const STATE_TONE: Record<GitLabWorkItem['state'], string> = {
opened: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
closed: 'bg-rose-500/15 text-rose-700 dark:text-rose-300',
merged: 'bg-violet-500/15 text-violet-700 dark:text-violet-300',
locked: 'bg-rose-500/15 text-rose-700 dark:text-rose-300',
draft: 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
}
// Why: pipeline job statuses map to one of four visual buckets — keep
// the mapping local so the renderer doesn't depend on the backend's
// shared mapper module (which is main-process only).
function jobStatusTone(status: string): string {
switch (status) {
case 'success':
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'
case 'failed':
return 'bg-rose-500/15 text-rose-700 dark:text-rose-300'
case 'running':
case 'pending':
case 'created':
case 'preparing':
case 'waiting_for_resource':
case 'scheduled':
return 'bg-sky-500/15 text-sky-700 dark:text-sky-300'
case 'manual':
return 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
case 'canceled':
case 'skipped':
default:
return 'bg-muted text-muted-foreground'
}
}
function StateBadge({ state }: { state: GitLabWorkItem['state'] }): React.JSX.Element {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide',
STATE_TONE[state]
)}
>
{state}
</span>
)
}
function CommentCard({ comment }: { comment: MRComment }): React.JSX.Element {
return (
<div className="rounded-md border border-border/40 bg-muted/30 p-3">
<div className="mb-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
{comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
alt=""
className="size-5 rounded-full"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<span className="font-medium text-foreground">{comment.author}</span>
{comment.isResolved ? (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
resolved
</span>
) : null}
</div>
<span>{comment.createdAt ? new Date(comment.createdAt).toLocaleDateString() : ''}</span>
</div>
{comment.path ? (
<div className="mb-1.5 font-mono text-[11px] text-muted-foreground">
{comment.path}
{comment.line ? `:${comment.line}` : ''}
</div>
) : null}
<CommentMarkdown content={comment.body} />
</div>
)
}
function PipelineJobRow({ job }: { job: GitLabPipelineJob }): React.JSX.Element {
return (
<button
type="button"
onClick={() => job.webUrl && void window.api.shell.openUrl(job.webUrl)}
className="grid w-full grid-cols-[minmax(0,2fr)_minmax(0,1fr)_80px_60px] items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/40"
>
<span className="min-w-0 truncate font-medium">{job.name}</span>
<span className="min-w-0 truncate text-xs text-muted-foreground">{job.stage}</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-center text-[10px] font-medium uppercase tracking-wide',
jobStatusTone(job.status)
)}
>
{job.status}
</span>
<span className="text-right text-[11px] text-muted-foreground">
{/* Why: durations come back as seconds; show "Nm Ns" for >60s
and "Ns" otherwise. null = job hasn't finished. */}
{typeof job.duration === 'number'
? job.duration >= 60
? `${Math.floor(job.duration / 60)}m ${Math.floor(job.duration % 60)}s`
: `${Math.floor(job.duration)}s`
: '—'}
</span>
</button>
)
}
export default function GitLabItemDialog({
item,
repoPath,
onClose,
onCreateWorkspace
}: Props): React.JSX.Element {
const [details, setDetails] = useState<GitLabWorkItemDetails | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [refreshNonce, setRefreshNonce] = useState(0)
const [commentDraft, setCommentDraft] = useState('')
const [commentSubmitting, setCommentSubmitting] = useState(false)
const [actionInFlight, setActionInFlight] = useState<'close' | 'reopen' | 'merge' | null>(null)
useEffect(() => {
if (!item || !repoPath) {
setDetails(null)
setLoading(false)
setError(null)
return
}
let stale = false
setLoading(true)
setError(null)
void window.api.gl
.workItemDetails({ repoPath, iid: item.number, type: item.type })
.then((data) => {
if (stale) {
return
}
if (!data) {
setError('Item not found.')
return
}
setDetails(data as GitLabWorkItemDetails)
})
.catch((err) => {
if (!stale) {
setError(err instanceof Error ? err.message : String(err))
}
})
.finally(() => {
if (!stale) {
setLoading(false)
}
})
return () => {
stale = true
}
}, [item, repoPath, refreshNonce])
// Why: clear the comment draft when the sheet target changes so the
// user doesn't accidentally post one MR's draft against another.
useEffect(() => {
setCommentDraft('')
}, [item?.id])
const handleRefresh = useCallback(() => {
setRefreshNonce((n) => n + 1)
}, [])
const handleClose = useCallback(async (): Promise<void> => {
if (!item || !repoPath || item.type !== 'mr') {
return
}
setActionInFlight('close')
try {
const res = await window.api.gl.closeMR({ repoPath, iid: item.number })
if (res.ok) {
toast.success(`Closed MR !${item.number}`)
handleRefresh()
} else {
toast.error(res.error)
}
} finally {
setActionInFlight(null)
}
}, [item, repoPath, handleRefresh])
const handleReopen = useCallback(async (): Promise<void> => {
if (!item || !repoPath || item.type !== 'mr') {
return
}
setActionInFlight('reopen')
try {
const res = await window.api.gl.reopenMR({ repoPath, iid: item.number })
if (res.ok) {
toast.success(`Reopened MR !${item.number}`)
handleRefresh()
} else {
toast.error(res.error)
}
} finally {
setActionInFlight(null)
}
}, [item, repoPath, handleRefresh])
const handleMerge = useCallback(async (): Promise<void> => {
if (!item || !repoPath || item.type !== 'mr') {
return
}
setActionInFlight('merge')
try {
const res = await window.api.gl.mergeMR({ repoPath, iid: item.number })
if (res.ok) {
toast.success(`Merged MR !${item.number}`)
handleRefresh()
} else {
toast.error(res.error)
}
} finally {
setActionInFlight(null)
}
}, [item, repoPath, handleRefresh])
const handleSubmitComment = useCallback(async (): Promise<void> => {
const body = commentDraft.trim()
if (!body || !item || !repoPath) {
return
}
setCommentSubmitting(true)
try {
// Why: the IPC for issue comments takes `number`, MR takes `iid`.
// Branch on the item type to hit the right channel.
const res =
item.type === 'mr'
? await window.api.gl.addMRComment({ repoPath, iid: item.number, body })
: await window.api.gl.addIssueComment({ repoPath, number: item.number, body })
if (res.ok) {
setCommentDraft('')
handleRefresh()
} else {
toast.error(res.error)
}
} finally {
setCommentSubmitting(false)
}
}, [commentDraft, item, repoPath, handleRefresh])
// Why: GitMerge for MRs visually disambiguates from GitBranch (and
// matches gitlab.com's MR iconography); CircleDot stays on issues.
const Icon = item?.type === 'mr' ? GitMerge : CircleDot
const prefix = item?.type === 'mr' ? '!' : '#'
const isMR = item?.type === 'mr'
const canClose = isMR && item?.state === 'opened'
const canReopen = isMR && item?.state === 'closed'
const canMerge = isMR && item?.state === 'opened'
return (
<Sheet open={item !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
<VisuallyHidden.Root>
<SheetTitle>{item ? item.title : 'Work item'}</SheetTitle>
<SheetDescription>GitLab work item detail</SheetDescription>
</VisuallyHidden.Root>
{item ? (
<>
<header className="flex-none border-b border-border/40 px-5 py-4">
<div className="flex items-start gap-3">
<Icon className="mt-0.5 size-5 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">
{prefix}
{item.number}
</span>
<StateBadge state={item.state} />
{item.author ? <span>by {item.author}</span> : null}
</div>
<h2 className="mt-1.5 text-lg font-semibold leading-tight text-foreground">
{item.title}
</h2>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Refresh"
disabled={loading}
onClick={handleRefresh}
className="size-7"
>
{loading ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
</Button>
</div>
</header>
<Tabs defaultValue="description" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-5 mt-3 self-start">
<TabsTrigger value="description">Description</TabsTrigger>
<TabsTrigger value="conversation">
Conversation
{details?.comments?.length ? (
<span className="ml-1.5 rounded-full bg-muted px-1.5 text-[10px] font-medium">
{details.comments.length}
</span>
) : null}
</TabsTrigger>
{isMR ? (
<TabsTrigger value="pipeline">
Pipeline
{details?.pipelineJobs?.length ? (
<span className="ml-1.5 rounded-full bg-muted px-1.5 text-[10px] font-medium">
{details.pipelineJobs.length}
</span>
) : null}
</TabsTrigger>
) : null}
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 scrollbar-sleek">
{error ? (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<TabsContent value="description" className="mt-0">
{loading && !details ? (
<div className="flex items-center justify-center py-12">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : details?.body ? (
<CommentMarkdown content={details.body} />
) : (
<p className="text-sm text-muted-foreground">No description.</p>
)}
</TabsContent>
<TabsContent value="conversation" className="mt-0 space-y-3">
{loading && !details ? (
<div className="flex items-center justify-center py-12">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : details?.comments?.length ? (
details.comments.map((c) => <CommentCard key={c.id} comment={c} />)
) : (
<p className="text-sm text-muted-foreground">No comments yet.</p>
)}
</TabsContent>
{isMR ? (
<TabsContent value="pipeline" className="mt-0">
{loading && !details ? (
<div className="flex items-center justify-center py-12">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
</div>
) : details?.pipelineJobs?.length ? (
<div className="space-y-1">
{details.pipelineJobs.map((j) => (
<PipelineJobRow key={j.id} job={j} />
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No pipeline runs for this MR.</p>
)}
</TabsContent>
) : null}
</div>
</Tabs>
<footer className="flex-none space-y-3 border-t border-border/40 px-5 py-3">
{/* Why: comment composer at the top of the footer so the
primary actions row stays visually grouped at the bottom. */}
<div className="flex items-end gap-2">
<textarea
value={commentDraft}
onChange={(e) => setCommentDraft(e.target.value)}
placeholder={`Comment on ${prefix}${item.number}`}
rows={2}
disabled={commentSubmitting}
className="min-h-9 w-full resize-none rounded-md border border-input bg-transparent px-2.5 py-1.5 text-sm shadow-xs focus:border-ring focus:outline-none focus:ring-[3px] focus:ring-ring/50"
onKeyDown={(e) => {
// Why: Cmd/Ctrl+Enter sends — matches gitlab.com's
// textarea behavior so users coming from the web UI
// get a familiar shortcut.
if (
e.key === 'Enter' &&
(e.metaKey || e.ctrlKey) &&
commentDraft.trim() &&
!commentSubmitting
) {
e.preventDefault()
void handleSubmitComment()
}
}}
/>
<Button
size="sm"
disabled={!commentDraft.trim() || commentSubmitting}
onClick={() => void handleSubmitComment()}
className="shrink-0 gap-1.5"
>
{commentSubmitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Send className="size-3.5" />
)}
Comment
</Button>
</div>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void window.api.shell.openUrl(item.url)}
className="gap-1.5"
>
<ExternalLink className="size-3.5" />
Open in browser
</Button>
<div className="flex items-center gap-2">
{onCreateWorkspace ? (
<Button variant="outline" size="sm" onClick={() => onCreateWorkspace(item)}>
Create workspace
</Button>
) : null}
{canMerge ? (
<Button
size="sm"
disabled={actionInFlight !== null}
onClick={() => void handleMerge()}
>
{actionInFlight === 'merge' ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : null}
Merge
</Button>
) : null}
{canClose ? (
<Button
variant="outline"
size="sm"
disabled={actionInFlight !== null}
onClick={() => void handleClose()}
>
{actionInFlight === 'close' ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : null}
Close
</Button>
) : null}
{canReopen ? (
<Button
variant="outline"
size="sm"
disabled={actionInFlight !== null}
onClick={() => void handleReopen()}
>
{actionInFlight === 'reopen' ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : null}
Reopen
</Button>
) : null}
</div>
</div>
</footer>
</>
) : null}
</SheetContent>
</Sheet>
)
}

View File

@ -17,7 +17,13 @@ import AgentCombobox from '@/components/agent/AgentCombobox'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import type { GitHubWorkItem, LinearIssue, SparsePreset, TuiAgent } from '../../../shared/types'
import type {
GitHubWorkItem,
GitLabWorkItem,
LinearIssue,
SparsePreset,
TuiAgent
} from '../../../shared/types'
import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect'
import SmartWorkspaceNameField, {
type SmartWorkspaceNameSelection
@ -39,6 +45,7 @@ type NewWorkspaceComposerCardProps = {
name: string
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
smartNameSelection: SmartWorkspaceNameSelection | null
@ -184,6 +191,7 @@ export default function NewWorkspaceComposerCard({
name,
onNameValueChange,
onSmartGitHubItemSelect,
onSmartGitLabItemSelect,
onSmartBranchSelect,
onSmartLinearIssueSelect,
smartNameSelection,
@ -309,6 +317,7 @@ export default function NewWorkspaceComposerCard({
value={name}
onValueChange={onNameValueChange}
onGitHubItemSelect={onSmartGitHubItemSelect}
onGitLabItemSelect={onSmartGitLabItemSelect}
onBranchSelect={onSmartBranchSelect}
onLinearIssueSelect={onSmartLinearIssueSelect}
selectedSource={smartNameSelection}

View File

@ -13,6 +13,7 @@ import {
EllipsisVertical,
ExternalLink,
Github,
Gitlab,
GitPullRequest,
LoaderCircle,
Lock,
@ -58,6 +59,7 @@ import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/I
import GitHubRateLimitPill from '@/components/github/GitHubRateLimitPill'
import { stripRepoQualifiers } from '../../../shared/task-query'
import GitHubItemDialog from '@/components/GitHubItemDialog'
import GitLabItemDialog from '@/components/GitLabItemDialog'
import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper'
import LinearItemDrawer from '@/components/LinearItemDrawer'
import { cn } from '@/lib/utils'
@ -80,12 +82,23 @@ import {
import type {
GitHubOwnerRepo,
GitHubWorkItem,
GitLabTodo,
GitLabWorkItem,
LinearIssue,
TaskViewPresetId
} from '../../../shared/types'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
type TaskSource = 'github' | 'linear'
type TaskSource = 'github' | 'linear' | 'gitlab'
type GitLabTaskFilter = 'opened' | 'merged' | 'closed' | 'all'
const GITLAB_TASK_FILTERS: { id: GitLabTaskFilter; label: string }[] = [
{ id: 'opened', label: 'Open' },
{ id: 'merged', label: 'Merged' },
{ id: 'closed', label: 'Closed' },
{ id: 'all', label: 'All' }
]
type TaskQueryPreset = {
id: TaskViewPresetId
label: string
@ -113,6 +126,11 @@ const SOURCE_OPTIONS: SourceOption[] = [
label: 'GitHub',
Icon: ({ className }) => <Github className={className} />
},
{
id: 'gitlab',
label: 'GitLab',
Icon: ({ className }) => <Gitlab className={className} />
},
{
id: 'linear',
label: 'Linear',
@ -770,6 +788,28 @@ export default function TaskPage(): React.JSX.Element {
const projectModeVisible = taskSource === 'github'
const [githubMode, setGithubMode] = useState<'items' | 'project'>('items')
// ── GitLab task-source state ──────────────────────────────────────
// Why: parallel to Linear's slim per-source state. Skips workItemsCache
// and cross-repo aggregation in v1 — the GitLab list fetches directly
// from `window.api.gl.listMRs` / `listIssues` for the primary repo.
const [gitlabFilter, setGitlabFilter] = useState<GitLabTaskFilter>('opened')
const [gitlabItems, setGitlabItems] = useState<GitLabWorkItem[]>([])
const [gitlabLoading, setGitlabLoading] = useState(false)
const [gitlabError, setGitlabError] = useState<string | null>(null)
const [gitlabRefreshNonce, setGitlabRefreshNonce] = useState(0)
// Why: opens GitLabItemDialog when a row is clicked. Separate state from
// gitlabItems so the dialog target survives a list refresh that might
// remove the item from the visible filter (e.g. closing an MR while
// it's open in the dialog).
const [gitlabDialogItem, setGitlabDialogItem] = useState<GitLabWorkItem | null>(null)
// Why: GitLab tab has two sub-views — the project's MR/issue list,
// and the user's cross-project Todos (gitlab.com/dashboard/todos).
// 'project' is default; 'todos' fetches a separate stream.
const [gitlabView, setGitlabView] = useState<'project' | 'todos'>('project')
const [gitlabTodos, setGitlabTodos] = useState<GitLabTodo[]>([])
const [gitlabTodosLoading, setGitlabTodosLoading] = useState(false)
const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery)
const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery)
const [activeTaskPreset, setActiveTaskPreset] = useState<TaskViewPresetId | null>(
@ -1035,6 +1075,135 @@ export default function TaskPage(): React.JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskSource, linearStatus.connected, taskResumeApplied])
// Why: stable key for `selectedRepos` so the GitLab fetch effect below
// doesn't re-run on every parent re-render just because the array
// reference changed. The memoized string keys off id + path +
// connectionId — the only fields the effect actually reads.
const selectedReposKey = useMemo(
() => selectedRepos.map((r) => `${r.id}|${r.path}|${r.connectionId ?? ''}`).join(','),
[selectedRepos]
)
// Why: GitLab task-source data fetch. Pulls MRs (filtered by state)
// Why: fetch in parallel across every selected non-remote repo and
// merge the results, mirroring the GitHub side's cross-repo
// aggregation. Each repo's project is resolved from its git remote
// by the main process — non-GitLab remotes return an error envelope
// which we silently drop (filter chips on a GitHub-only repo
// shouldn't surface "no GitLab project" banners).
useEffect(() => {
if (taskSource !== 'gitlab') {
return
}
// Why: GitLab queries don't work over SSH-relay (yet) and folder-
// mode repos have no remotes to derive a project from. Filter both.
const eligibleRepos = selectedRepos.filter((r) => !r.connectionId)
if (eligibleRepos.length === 0) {
setGitlabItems([])
setGitlabLoading(false)
setGitlabError(null)
return
}
let stale = false
setGitlabLoading(true)
setGitlabError(null)
void Promise.allSettled(
eligibleRepos.map((repo) =>
window.api.gl
.listWorkItems({
repoPath: repo.path,
state: gitlabFilter,
page: 1,
perPage: 50
})
.then((result) => ({
repoId: repo.id,
items: (result as { items: GitLabWorkItem[] }).items,
// Why: not_found just means "this repo isn't a GitLab project"
// (e.g. a GitHub-only repo in a mixed selection). Drop it
// silently so the GitLab list doesn't show false errors.
error:
(result as { error?: { type?: string; message: string } }).error?.type === 'not_found'
? undefined
: (result as { error?: { message: string } }).error
}))
)
)
.then((results) => {
if (stale) {
return
}
const merged: GitLabWorkItem[] = []
const errs: string[] = []
for (const r of results) {
if (r.status !== 'fulfilled') {
errs.push(r.reason instanceof Error ? r.reason.message : String(r.reason))
continue
}
for (const item of r.value.items) {
merged.push({ ...item, repoId: r.value.repoId })
}
if (r.value.error) {
errs.push(r.value.error.message)
}
}
merged.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
setGitlabItems(merged)
// Why: only surface an error banner when EVERY eligible repo
// failed — partial failure (one of three GitLab projects has
// a permission issue) is better signaled by the bare row count
// than a banner that overshadows the working repos.
if (errs.length > 0 && merged.length === 0) {
setGitlabError(errs[0])
}
})
.finally(() => {
if (!stale) {
setGitlabLoading(false)
}
})
return () => {
stale = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedReposKey encodes the only selectedRepos fields read above; keying off the array ref would re-run on every parent render.
}, [taskSource, gitlabFilter, gitlabRefreshNonce, selectedReposKey])
// Why: Todos fetch lives in its own effect — different trigger
// condition from the project view (no chip filter dependence) and a
// different data path (`gl.todos` is user-scoped, not repo-scoped).
useEffect(() => {
if (taskSource !== 'gitlab' || gitlabView !== 'todos') {
return
}
if (!primaryRepo?.path) {
setGitlabTodos([])
setGitlabTodosLoading(false)
return
}
let stale = false
setGitlabTodosLoading(true)
void window.api.gl
.todos({ repoPath: primaryRepo.path })
.then((todos) => {
if (!stale) {
setGitlabTodos(todos as GitLabTodo[])
}
})
.catch(() => {
if (!stale) {
setGitlabTodos([])
}
})
.finally(() => {
if (!stale) {
setGitlabTodosLoading(false)
}
})
return () => {
stale = true
}
}, [taskSource, gitlabView, gitlabRefreshNonce, primaryRepo?.path])
const defaultLinearTeamSelection = settings?.defaultLinearTeamSelection
const [linearTeamSelection, setLinearTeamSelection] = useState<ReadonlySet<string>>(() => {
if (!defaultLinearTeamSelection) {
@ -2216,6 +2385,95 @@ export default function TaskPage(): React.JSX.Element {
</div>
</div>
</div>
) : taskSource === 'gitlab' ? (
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
{/* Why: view toggle Project = the selected repo's MRs
and issues; My Todos = the user's cross-project
gitlab.com/dashboard/todos stream. They have
different data shapes so we render distinct lists
below. */}
<div className="mb-2 flex items-center gap-2">
{(['project', 'todos'] as const).map((view) => {
const active = gitlabView === view
const label = view === 'project' ? 'Project MRs' : 'My Todos'
return (
<button
key={view}
type="button"
onClick={() => setGitlabView(view)}
className={cn(
'rounded-md border px-2.5 py-1 text-xs transition',
active
? 'border-foreground/40 bg-foreground/90 text-background'
: 'border-border/50 bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
{label}
</button>
)
})}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{/* Why: state chips only apply to the project view
todos are filtered to 'pending' state in the
backend and don't have an Open/Merged/Closed
axis. */}
{gitlabView === 'project'
? GITLAB_TASK_FILTERS.map(({ id, label }) => {
const active = gitlabFilter === id
return (
<button
key={id}
type="button"
onClick={() => {
setGitlabFilter(id)
setGitlabRefreshNonce((n) => n + 1)
}}
className={cn(
'rounded-md border px-2 py-1 text-xs transition',
active
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
)}
>
{label}
</button>
)
})
: null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setGitlabRefreshNonce((n) => n + 1)}
disabled={gitlabLoading || gitlabTodosLoading}
aria-label={
gitlabView === 'project'
? 'Refresh GitLab work items'
: 'Refresh My Todos'
}
className="border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
{gitlabLoading || gitlabTodosLoading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{gitlabView === 'project'
? 'Refresh GitLab work items'
: 'Refresh My Todos'}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
) : null}
</div>
</section>
@ -2502,6 +2760,182 @@ export default function TaskPage(): React.JSX.Element {
) : null}
</div>
</div>
) : taskSource === 'gitlab' && gitlabView === 'todos' ? (
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
<div className="flex-none grid grid-cols-[110px_minmax(0,3fr)_minmax(120px,1.2fr)_110px_50px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span>Action</span>
<span>Title</span>
<span>Project</span>
<span>Updated</span>
<span />
</div>
<div
className="min-h-0 flex-initial overflow-y-auto scrollbar-sleek"
style={{ scrollbarGutter: 'stable' }}
>
{gitlabTodosLoading && gitlabTodos.length === 0 ? (
<div className="divide-y divide-border/50">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="grid w-full gap-3 px-3 py-2 grid-cols-[110px_minmax(0,3fr)_minmax(120px,1.2fr)_110px_50px]"
>
<div className="h-4 w-20 animate-pulse rounded bg-muted/70" />
<div>
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
</div>
<div className="h-3 w-24 animate-pulse rounded bg-muted/60" />
<div className="h-3 w-20 animate-pulse rounded bg-muted/60" />
<div />
</div>
))}
</div>
) : null}
{!gitlabTodosLoading && gitlabTodos.length === 0 ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{primaryRepo
? 'No pending todos. Youre all caught up!'
: 'Select a repo so we can authenticate to GitLab.'}
</div>
) : null}
<div className="divide-y divide-border/50">
{gitlabTodos.map((todo) => (
<div
role="button"
tabIndex={0}
key={todo.id}
onClick={() => void window.api.shell.openUrl(todo.targetUrl)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
void window.api.shell.openUrl(todo.targetUrl)
}
}}
className="grid w-full cursor-pointer gap-3 px-3 py-2 text-left grid-cols-[110px_minmax(0,3fr)_minmax(120px,1.2fr)_110px_50px] hover:bg-muted/50"
title={
todo.targetType === 'MergeRequest'
? `MR !${todo.targetIid ?? ''}`
: todo.targetType === 'Issue'
? `Issue #${todo.targetIid ?? ''}`
: todo.targetType
}
>
<span className="text-xs text-muted-foreground">
{/* Why: GitLab action_name uses snake_case (assigned,
review_requested, build_failed). Replace _ with
space so the row reads like a sentence. */}
{todo.actionName.replace(/_/g, ' ')}
</span>
<span className="min-w-0 truncate text-sm">{todo.targetTitle}</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
{todo.projectPath}
</span>
<span className="text-xs text-muted-foreground">
{todo.updatedAt ? new Date(todo.updatedAt).toLocaleDateString() : ''}
</span>
<span className="flex justify-end">
<ExternalLink className="size-3.5 text-muted-foreground" />
</span>
</div>
))}
</div>
</div>
</div>
) : taskSource === 'gitlab' ? (
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
<div className="flex-none grid grid-cols-[80px_minmax(0,3fr)_120px_110px_50px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span>ID</span>
<span>Title</span>
<span>Type / State</span>
<span>Updated</span>
<span />
</div>
<div
className="min-h-0 flex-initial overflow-y-auto scrollbar-sleek"
style={{ scrollbarGutter: 'stable' }}
>
{gitlabError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{gitlabError}
</div>
) : null}
{gitlabLoading && gitlabItems.length === 0 ? (
// Why: matches the GitHub / Linear shimmer pattern so the card
// never flashes empty during the initial fetch.
<div className="divide-y divide-border/50">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="grid w-full gap-3 px-3 py-2 grid-cols-[80px_minmax(0,3fr)_120px_110px_50px]"
>
<div className="h-4 w-16 animate-pulse rounded bg-muted/70" />
<div>
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
</div>
<div className="h-3 w-20 animate-pulse rounded bg-muted/60" />
<div className="h-3 w-20 animate-pulse rounded bg-muted/60" />
<div />
</div>
))}
</div>
) : null}
{!gitlabLoading && gitlabItems.length === 0 && !gitlabError ? (
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
{primaryRepo
? 'No GitLab work matches this filter.'
: 'Select a repo to see GitLab work items.'}
</div>
) : null}
<div className="divide-y divide-border/50">
{gitlabItems.map((item) => (
// Why: row uses a <div role="button"> rather than a
// <button> because it nests an inner button for
// open-in-browser. Native <button> nesting is invalid
// HTML and React warns; the role + tabIndex + keyDown
// handler preserve a11y semantics.
<div
role="button"
tabIndex={0}
key={item.id}
onClick={() => setGitlabDialogItem(item)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setGitlabDialogItem(item)
}
}}
className="grid w-full cursor-pointer gap-3 px-3 py-2 text-left grid-cols-[80px_minmax(0,3fr)_120px_110px_50px] hover:bg-muted/50"
>
<span className="font-mono text-xs text-muted-foreground">
{/* Why: GitLab's user-facing convention is `!N` for MRs
and `#N` for issues matches gitlab.com's UI so users
scanning the list can map rows back to web links. */}
{item.type === 'mr' ? '!' : '#'}
{item.number}
</span>
<span className="min-w-0 truncate text-sm">{item.title}</span>
<span className="text-xs text-muted-foreground">
{item.type === 'mr' ? 'MR' : 'Issue'} · {item.state}
</span>
<span className="text-xs text-muted-foreground">
{item.updatedAt ? new Date(item.updatedAt).toLocaleDateString() : ''}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void window.api.shell.openUrl(item.url)
}}
aria-label="Open in browser"
className="flex justify-end text-muted-foreground hover:text-foreground"
>
<ExternalLink className="size-3.5" />
</button>
</div>
))}
</div>
</div>
</div>
) : !linearStatusChecked ? (
<div className="mt-4 flex items-center justify-center py-14">
<LoaderCircle className="size-5 animate-spin text-muted-foreground" />
@ -3007,6 +3441,21 @@ export default function TaskPage(): React.JSX.Element {
onClose={() => setDrawerLinearIssue(null)}
/>
<GitLabItemDialog
item={gitlabDialogItem}
// Why: dialog's repoPath has to come from the clicked item's
// own repo, not primaryRepo — items may originate in any of
// the selected repos now that the GitLab fetch is multi-repo.
repoPath={
gitlabDialogItem
? (selectedRepos.find((r) => r.id === gitlabDialogItem.repoId)?.path ??
primaryRepo?.path ??
null)
: null
}
onClose={() => setGitlabDialogItem(null)}
/>
<Dialog
open={linearConnectOpen}
onOpenChange={(open) => {

View File

@ -8,8 +8,10 @@ import {
ExternalLink,
GitBranch,
GitBranchPlus,
GitMerge,
GitPullRequest,
Github,
Gitlab,
LoaderCircle,
Search,
Sparkles,
@ -36,11 +38,23 @@ import {
parseGitHubIssueOrPRLink,
type RepoSlug
} from '@/lib/github-links'
import { parseGitLabIssueOrMRLink } from '@/lib/gitlab-links'
import { cn } from '@/lib/utils'
import { LinearIcon } from '@/components/icons/LinearIcon'
import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types'
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../../shared/types'
type SmartNameMode = 'smart' | 'github' | 'branches' | 'linear' | 'text'
type SmartNameMode = 'smart' | 'github' | 'gitlab' | 'branches' | 'linear' | 'text'
// Why: GitLab MR list filter — Open / Merged / Closed / All — replaces
// GitHub's search-DSL on the GitLab tab per the agreed scope.
type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all'
const MR_STATE_FILTERS: { id: MrStateFilter; label: string }[] = [
{ id: 'opened', label: 'Open' },
{ id: 'merged', label: 'Merged' },
{ id: 'closed', label: 'Closed' },
{ id: 'all', label: 'All' }
]
type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number]
@ -51,6 +65,9 @@ type SmartWorkspaceNameFieldProps = {
value: string
onValueChange: (value: string) => void
onGitHubItemSelect: (item: GitHubWorkItem) => void
/** Optional so callers that pre-date GitLab support don't need to wire
* it. When omitted, GitLab paste-URL detection is silently skipped. */
onGitLabItemSelect?: (item: GitLabWorkItem) => void
onBranchSelect: (refName: string) => void
onLinearIssueSelect: (issue: LinearIssue) => void
selectedSource: SmartWorkspaceNameSelection | null
@ -60,7 +77,7 @@ type SmartWorkspaceNameFieldProps = {
}
export type SmartWorkspaceNameSelection = {
kind: 'github-pr' | 'github-issue' | 'branch' | 'linear'
kind: 'github-pr' | 'github-issue' | 'gitlab-mr' | 'gitlab-issue' | 'branch' | 'linear'
label: string
url?: string
}
@ -75,6 +92,7 @@ const MODES: {
}[] = [
{ id: 'smart', label: 'Smart', Icon: Sparkles },
{ id: 'github', label: 'GitHub', Icon: Github },
{ id: 'gitlab', label: 'GitLab', Icon: Gitlab },
{ id: 'branches', label: 'Branch', Icon: GitBranch },
{
id: 'linear',
@ -91,6 +109,7 @@ const MODES: {
const emptyHintByMode: Record<SmartNameMode, string> = {
smart: 'Start typing to create a name or find a source.',
github: 'Start typing to search GitHub PRs and issues.',
gitlab: 'Start typing to search GitLab MRs and issues.',
branches: 'Start typing to find a branch or create a new one.',
linear: 'Start typing to search Linear issues.',
text: ''
@ -100,6 +119,7 @@ type RowEntry =
| { kind: 'use-name'; value: string; name: string }
| { kind: 'create-branch'; value: string; name: string }
| { kind: 'github'; value: string; item: GitHubWorkItem }
| { kind: 'gitlab'; value: string; item: GitLabWorkItem }
| { kind: 'branch'; value: string; refName: string }
| { kind: 'linear'; value: string; issue: LinearIssue }
@ -110,6 +130,7 @@ export default function SmartWorkspaceNameField({
value,
onValueChange,
onGitHubItemSelect,
onGitLabItemSelect,
onBranchSelect,
onLinearIssueSelect,
selectedSource,
@ -144,12 +165,15 @@ export default function SmartWorkspaceNameField({
[repoId, repos]
)
const [mode, setMode] = useState<SmartNameMode>('smart')
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened')
const [open, setOpen] = useState(false)
const [debouncedQuery, setDebouncedQuery] = useState(value)
const [githubItems, setGithubItems] = useState<GitHubWorkItem[]>([])
const [gitlabItems, setGitlabItems] = useState<GitLabWorkItem[]>([])
const [branches, setBranches] = useState<string[]>([])
const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([])
const [githubLoading, setGithubLoading] = useState(false)
const [gitlabLoading, setGitlabLoading] = useState(false)
const [branchesLoading, setBranchesLoading] = useState(false)
const [linearLoading, setLinearLoading] = useState(false)
const [commandValue, setCommandValue] = useState('')
@ -394,6 +418,127 @@ export default function SmartWorkspaceNameField({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery, linearStatus.connected, shouldQueryLinear])
// Why: GitLab paste-URL flow. Watches the debounced query for a GitLab
// issue/MR URL (parseGitLabIssueOrMRLink already filters non-GitLab URLs
// via the project-internal `/-/` separator) and resolves it to a
// GitLabWorkItem via the IPC. Skipped silently when the host hook
// hasn't supplied an onGitLabItemSelect handler.
const parsedGlLink = useMemo(() => parseGitLabIssueOrMRLink(debouncedQuery), [debouncedQuery])
const shouldQueryGitlab = mode === 'smart' || mode === 'gitlab'
useEffect(() => {
if (
!shouldQueryGitlab ||
!onGitLabItemSelect ||
!selectedRepo?.path ||
selectedRepo.connectionId
) {
// Why: don't clobber list-mode items here — the listMRs effect below
// is the sole writer when the user is in 'gitlab' mode without a URL.
if (parsedGlLink === null && mode !== 'gitlab') {
setGitlabItems([])
}
setGitlabLoading(false)
return
}
if (parsedGlLink === null) {
// Same reason: only clear when leaving the gitlab/smart context.
if (mode !== 'gitlab') {
setGitlabItems([])
}
setGitlabLoading(false)
return
}
let stale = false
setGitlabLoading(true)
void window.api.gl
.workItemByPath({
repoPath: selectedRepo.path,
// Why: parseGitLabIssueOrMRLink doesn't carry the host (the URL
// pattern is host-agnostic on purpose so self-hosted instances
// work). Use 'gitlab.com' as the IPC arg — the main process maps
// by project path internally and the host param is currently
// informational; revisit when the picker grows multi-host UX.
host: 'gitlab.com',
path: parsedGlLink.slug.path,
iid: parsedGlLink.number,
type: parsedGlLink.type
})
.then((item) => {
if (stale) {
return
}
setGitlabItems(item ? [{ ...item, repoId: selectedRepo.id } as GitLabWorkItem] : [])
})
.catch(() => {
if (!stale) {
setGitlabItems([])
}
})
.finally(() => {
if (!stale) {
setGitlabLoading(false)
}
})
return () => {
stale = true
}
}, [mode, onGitLabItemSelect, parsedGlLink, selectedRepo, shouldQueryGitlab])
// Why: when the user is on the GitLab tab (or in 'smart' mix) and
// hasn't pasted a URL, surface the project's MRs filtered by the
// current state chip. Default 'opened' matches gitlab.com's default
// MR list view. Smart mode includes GitLab MRs alongside GitHub
// items so the unified picker actually surfaces both providers.
useEffect(() => {
if ((mode !== 'gitlab' && mode !== 'smart') || !onGitLabItemSelect) {
return
}
if (!selectedRepo?.path || selectedRepo.connectionId) {
setGitlabItems([])
setGitlabLoading(false)
return
}
if (parsedGlLink !== null) {
// Why: paste-URL effect owns the list while a URL is in the input.
return
}
let stale = false
setGitlabLoading(true)
void window.api.gl
.listMRs({
repoPath: selectedRepo.path,
state: mrStateFilter,
page: 1,
perPage: RESULT_LIMIT
})
.then((result) => {
if (stale) {
return
}
// Why: listMRs returns ListMergeRequestsResult { items, ... };
// each item is already a GitLabWorkItem. Stamp repoId on the
// way through so the picker can attribute rows.
const items = (result as { items: GitLabWorkItem[] }).items.map((item) => ({
...item,
repoId: selectedRepo.id
}))
setGitlabItems(items)
})
.catch(() => {
if (!stale) {
setGitlabItems([])
}
})
.finally(() => {
if (!stale) {
setGitlabLoading(false)
}
})
return () => {
stale = true
}
}, [mode, mrStateFilter, onGitLabItemSelect, parsedGlLink, selectedRepo])
const rows = useMemo<RowEntry[]>(() => {
const trimmed = value.trim()
// Why: on the Branches tab the generic "Use … as workspace name" row
@ -430,6 +575,15 @@ export default function SmartWorkspaceNameField({
}))
)
}
if (mode === 'smart' || mode === 'gitlab') {
nextRows.push(
...gitlabItems.map((item) => ({
kind: 'gitlab' as const,
value: `gitlab-${item.type}-${item.number}`,
item
}))
)
}
if (mode === 'smart' || mode === 'branches') {
if (createBranchRow) {
nextRows.push(createBranchRow)
@ -452,7 +606,7 @@ export default function SmartWorkspaceNameField({
)
}
return nextRows.slice(0, RESULT_LIMIT + 1)
}, [branches, githubItems, linearIssues, mode, value])
}, [branches, githubItems, gitlabItems, linearIssues, mode, value])
// Why: source rows (GitHub/branches/Linear) are driven by debouncedQuery,
// so they're stale until the user pauses typing for SEARCH_DEBOUNCE_MS.
@ -517,7 +671,7 @@ export default function SmartWorkspaceNameField({
)
}, [isQueryStale, rows, sourceIntent])
const loading = githubLoading || branchesLoading || linearLoading
const loading = githubLoading || gitlabLoading || branchesLoading || linearLoading
const ActiveInputIcon = mode === 'text' ? CaseSensitive : loading ? LoaderCircle : Search
const handleSelect = useCallback(
@ -529,6 +683,10 @@ export default function SmartWorkspaceNameField({
onValueChange(row.name)
} else if (row.kind === 'github') {
onGitHubItemSelect(row.item)
} else if (row.kind === 'gitlab') {
// Why: optional handler — guarded so the surface degrades to a
// no-op for hosts that haven't wired GitLab support yet.
onGitLabItemSelect?.(row.item)
} else if (row.kind === 'branch') {
onBranchSelect(row.refName)
} else {
@ -536,7 +694,7 @@ export default function SmartWorkspaceNameField({
}
setOpen(false)
},
[onBranchSelect, onGitHubItemSelect, onLinearIssueSelect, onValueChange]
[onBranchSelect, onGitHubItemSelect, onGitLabItemSelect, onLinearIssueSelect, onValueChange]
)
const acceptGitHubLink = useCallback(
@ -813,6 +971,28 @@ export default function SmartWorkspaceNameField({
}
}}
>
{mode === 'gitlab' ? (
// Why: GitLab MR-state filter — Open / Merged / Closed / All —
// mirrors the gitlab.com merge-requests page tab strip so users
// arriving from the web UI find a familiar control.
<div
className="flex shrink-0 items-center gap-1 border-b border-border/40 px-2 py-1.5"
onMouseDown={(e) => e.preventDefault()}
>
{MR_STATE_FILTERS.map(({ id, label }) => (
<Button
key={id}
type="button"
variant={mrStateFilter === id ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setMrStateFilter(id)}
className="h-6 px-2 text-xs"
>
{label}
</Button>
))}
</div>
) : null}
<CommandList className="!max-h-none min-h-0 flex-1 scrollbar-sleek">
{loading && rows.length === 0 ? (
<div className="space-y-1 p-1">
@ -892,6 +1072,19 @@ function RowIcon({ row }: { row: RowEntry }): React.JSX.Element {
<CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
)
}
if (row.kind === 'gitlab') {
// Why: GitLab MRs use GitMerge (arrow-merge-into-line) rather than
// GitPullRequest so the row visually disambiguates from branches
// (GitBranch's fork shape reads similar to GitPullRequest at this
// size). GitMerge also matches gitlab.com's own MR iconography,
// so users coming from the web UI find it familiar. Issues stay
// on CircleDot — the shape is provider-agnostic.
return row.item.type === 'mr' ? (
<GitMerge className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
)
}
if (row.kind === 'branch') {
return <GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
}
@ -902,7 +1095,12 @@ function SelectionIcon({ kind }: { kind: SmartWorkspaceNameSelection['kind'] }):
if (kind === 'github-pr') {
return <GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
}
if (kind === 'github-issue') {
if (kind === 'gitlab-mr') {
// Why: see RowIcon — GitMerge keeps MRs distinct from PRs and
// branches.
return <GitMerge className="size-3.5 shrink-0 text-muted-foreground" />
}
if (kind === 'github-issue' || kind === 'gitlab-issue') {
return <CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
}
if (kind === 'branch') {
@ -935,6 +1133,21 @@ function RowLabel({ row }: { row: RowEntry }): React.JSX.Element {
</span>
)
}
if (row.kind === 'gitlab') {
// Why: GitLab uses `!N` for MRs and `#N` for issues — show the
// appropriate prefix so the row is unambiguous to users coming from
// gitlab.com's UI.
const prefix = row.item.type === 'mr' ? '!' : '#'
return (
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">
{prefix}
{row.item.number}
</span>{' '}
{row.item.title}
</span>
)
}
if (row.kind === 'branch') {
return <span className="min-w-0 truncate font-mono text-[11px]">{row.refName}</span>
}

View File

@ -93,9 +93,9 @@ import type {
GitConflictKind,
GitConflictOperation,
GitStatusEntry,
GitUpstreamStatus,
PRInfo
GitUpstreamStatus
} from '../../../../shared/types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import { STATUS_COLORS, STATUS_LABELS } from './status-display'
type SourceControlScope = 'all' | 'uncommitted'
@ -176,6 +176,30 @@ const CONFLICT_KIND_LABELS: Record<GitConflictKind, string> = {
both_deleted: 'Both deleted'
}
function hostedReviewStateClass(review: HostedReviewInfo): string {
if (review.state === 'merged') {
return 'text-purple-500/80'
}
if (review.state === 'open') {
return 'text-emerald-500/80'
}
if (review.state === 'closed') {
return 'text-muted-foreground/60'
}
return 'text-muted-foreground/50'
}
function HostedReviewIcon({
review,
className
}: {
review: HostedReviewInfo
className?: string
}): React.JSX.Element {
const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
return <Icon className={cn(className, hostedReviewStateClass(review))} />
}
function SourceControlInner(): React.JSX.Element {
const sourceControlRef = useRef<HTMLDivElement>(null)
// Why: React setState is async, so a rapid double-click on the Commit
@ -198,8 +222,8 @@ function SourceControlInner(): React.JSX.Element {
const remoteStatusesByWorktree = useAppStore((s) => s.remoteStatusesByWorktree)
const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive)
const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const hostedReviewCache = useAppStore((s) => s.hostedReviewCache)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
const updateRepo = useAppStore((s) => s.updateRepo)
const beginGitBranchCompareRequest = useAppStore((s) => s.beginGitBranchCompareRequest)
const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult)
@ -363,22 +387,36 @@ function SourceControlInner(): React.JSX.Element {
const hasUncommittedEntries = entries.length > 0
const branchName = activeWorktree?.branch.replace(/^refs\/heads\//, '') ?? 'HEAD'
const prCacheKey = activeRepo && branchName ? `${activeRepo.path}::${branchName}` : null
const prInfo: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
const hostedReviewCacheKey = activeRepo && branchName ? `${activeRepo.path}::${branchName}` : null
const hostedReview: HostedReviewInfo | null = hostedReviewCacheKey
? (hostedReviewCache[hostedReviewCacheKey]?.data ?? null)
: null
const linkedPR = activeWorktree?.linkedPR ?? null
const linkedGitHubPR = activeWorktree?.linkedPR ?? null
const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null
useEffect(() => {
if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') {
return
}
if (activeRepo.connectionId) {
return
}
// Why: the Source Control panel renders the branch's PR badge directly.
// Why: the Source Control panel renders branch review status directly.
// When a terminal checkout moves this worktree onto a new branch, we need
// to fetch that branch's PR immediately instead of waiting for the user to
// reselect the worktree or open the separate Checks panel. Pass linkedPR
// so create-from-PR worktrees resolve via the number-based fallback.
void fetchPRForBranch(activeRepo.path, branchName, { linkedPRNumber: linkedPR })
}, [activeRepo, branchName, fetchPRForBranch, isBranchVisible, isFolder, linkedPR])
// to fetch that branch's PR/MR immediately instead of waiting for the user
// to reselect the worktree. The linked ids handle create-from-review
// worktrees whose local branch differs from the remote head branch.
void fetchHostedReviewForBranch(activeRepo.path, branchName, { linkedGitHubPR, linkedGitLabMR })
}, [
activeRepo,
branchName,
fetchHostedReviewForBranch,
isBranchVisible,
isFolder,
linkedGitHubPR,
linkedGitLabMR
])
const grouped = useMemo(() => {
const groups = {
@ -1346,25 +1384,17 @@ function SourceControlInner(): React.JSX.Element {
{value === 'all' ? 'All' : 'Uncommitted'}
</button>
))}
{prInfo && (
{hostedReview && (
<div className="ml-auto mb-1.5 flex items-center gap-1.5 min-w-0 text-[11.5px] leading-none">
<PullRequestIcon
className={cn(
'size-3 shrink-0',
prInfo.state === 'merged' && 'text-purple-500/80',
prInfo.state === 'open' && 'text-emerald-500/80',
prInfo.state === 'closed' && 'text-muted-foreground/60',
prInfo.state === 'draft' && 'text-muted-foreground/50'
)}
/>
<HostedReviewIcon review={hostedReview} className="size-3 shrink-0" />
<a
href={prInfo.url}
href={hostedReview.url}
target="_blank"
rel="noreferrer"
className="text-foreground opacity-80 font-medium shrink-0 hover:text-foreground hover:underline"
onClick={(e) => e.stopPropagation()}
>
PR #{prInfo.number}
{hostedReview.provider === 'gitlab' ? 'MR' : 'PR'} #{hostedReview.number}
</a>
</div>
)}

View File

@ -1,6 +1,13 @@
/* eslint-disable max-lines -- Why: this pane co-locates GitHub, GitLab,
and Linear integration cards so the preflight-check + status-badge +
install/auth-prompt scaffolding lives in one place rather than fanning
out across per-integration files that would each repeat the same
pattern. Splitting buys nothing while the surface stays this narrow. */
import { useEffect, useState } from 'react'
import {
Github,
Gitlab,
GitPullRequestArrow,
ExternalLink,
LoaderCircle,
Lock,
@ -36,6 +43,16 @@ export const INTEGRATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
description: 'GitHub authentication via the gh CLI.',
keywords: ['github', 'gh', 'integration']
},
{
title: 'GitLab Integration',
description: 'GitLab authentication via the glab CLI.',
keywords: ['gitlab', 'glab', 'integration', 'mr', 'merge request']
},
{
title: 'Bitbucket Integration',
description: 'Bitbucket Cloud authentication via API token environment variables.',
keywords: ['bitbucket', 'integration', 'pull request', 'api token']
},
{
title: 'Linear Integration',
description: 'Connect Linear to browse and link issues.',
@ -44,6 +61,10 @@ export const INTEGRATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
]
type GhStatus = 'checking' | 'connected' | 'not-installed' | 'not-authenticated'
// Why: parallel to GhStatus — GitLab uses glab and the same three failure
// modes (probe in-flight / installed-but-unauth / missing entirely).
type GlabStatus = GhStatus
type BitbucketStatus = 'checking' | 'connected' | 'not-configured' | 'not-authenticated'
export function IntegrationsPane(): React.JSX.Element {
const linearStatus = useAppStore((s) => s.linearStatus)
@ -53,6 +74,9 @@ export function IntegrationsPane(): React.JSX.Element {
const testLinearConnection = useAppStore((s) => s.testLinearConnection)
const [ghStatus, setGhStatus] = useState<GhStatus>('checking')
const [glabStatus, setGlabStatus] = useState<GlabStatus>('checking')
const [bitbucketStatus, setBitbucketStatus] = useState<BitbucketStatus>('checking')
const [bitbucketAccount, setBitbucketAccount] = useState<string | null>(null)
const [linearDialogOpen, setLinearDialogOpen] = useState(false)
const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('')
const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>(
@ -74,6 +98,26 @@ export function IntegrationsPane(): React.JSX.Element {
} else {
setGhStatus('connected')
}
// Why: glab is optional on PreflightStatus — older preload payloads
// may not carry it. Fall through to 'not-installed' in that case so
// the card still renders something actionable.
const glab = status.glab
if (!glab || !glab.installed) {
setGlabStatus('not-installed')
} else if (!glab.authenticated) {
setGlabStatus('not-authenticated')
} else {
setGlabStatus('connected')
}
const bitbucket = status.bitbucket
setBitbucketAccount(bitbucket?.account ?? null)
if (!bitbucket?.configured) {
setBitbucketStatus('not-configured')
} else if (!bitbucket.authenticated) {
setBitbucketStatus('not-authenticated')
} else {
setBitbucketStatus('connected')
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount check
}, [])
@ -124,6 +168,20 @@ export function IntegrationsPane(): React.JSX.Element {
}
}
const handleRefreshGlab = (): void => {
setGlabStatus('checking')
void window.api.preflight.check({ force: true }).then((status) => {
const glab = status.glab
if (!glab || !glab.installed) {
setGlabStatus('not-installed')
} else if (!glab.authenticated) {
setGlabStatus('not-authenticated')
} else {
setGlabStatus('connected')
}
})
}
const handleRefreshGh = (): void => {
setGhStatus('checking')
void window.api.preflight.check({ force: true }).then((status) => {
@ -137,6 +195,21 @@ export function IntegrationsPane(): React.JSX.Element {
})
}
const handleRefreshBitbucket = (): void => {
setBitbucketStatus('checking')
void window.api.preflight.check({ force: true }).then((status) => {
const bitbucket = status.bitbucket
setBitbucketAccount(bitbucket?.account ?? null)
if (!bitbucket?.configured) {
setBitbucketStatus('not-configured')
} else if (!bitbucket.authenticated) {
setBitbucketStatus('not-authenticated')
} else {
setBitbucketStatus('connected')
}
})
}
return (
<div className="space-y-3">
{/* GitHub */}
@ -214,6 +287,168 @@ export function IntegrationsPane(): React.JSX.Element {
)}
</div>
{/* GitLab */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<Gitlab className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">GitLab</p>
<p className="text-xs text-muted-foreground">
Merge requests, issues, todos, and pipelines via the{' '}
<span className="font-mono text-[11px]">glab</span> CLI.
</p>
</div>
{glabStatus === 'checking' ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : glabStatus === 'connected' ? (
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
Connected
</span>
) : (
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
{glabStatus === 'not-installed' ? 'Not installed' : 'Not authenticated'}
</span>
)}
</div>
{glabStatus !== 'checking' && glabStatus !== 'connected' && (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5 space-y-2">
{glabStatus === 'not-installed' ? (
<>
<p className="text-xs text-muted-foreground">
Install the GitLab CLI to enable merge requests, issues, and pipelines.
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl('https://gitlab.com/gitlab-org/cli#installation')
}
>
<ExternalLink className="size-3.5 mr-1.5" />
Install GitLab CLI
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGlab}>
Re-check
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
The GitLab CLI is installed but not authenticated. Run this command in a terminal:
</p>
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-2.5 py-1.5 font-mono text-xs">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
glab auth login
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://gitlab.com/gitlab-org/cli/-/blob/main/docs/source/auth/login.md'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
Learn more
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshGlab}>
Re-check
</Button>
</div>
</>
)}
</div>
)}
</div>
{/* Bitbucket */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">
<GitPullRequestArrow className="size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Bitbucket</p>
<p className="text-xs text-muted-foreground">
{bitbucketStatus === 'connected'
? bitbucketAccount
? `${bitbucketAccount} · Pull requests and build statuses`
: 'Pull requests and build statuses'
: 'Pull requests and build statuses via Bitbucket Cloud API tokens.'}
</p>
</div>
{bitbucketStatus === 'checking' ? (
<LoaderCircle className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : bitbucketStatus === 'connected' ? (
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
Connected
</span>
) : (
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-300">
{bitbucketStatus === 'not-configured' ? 'Not configured' : 'Auth failed'}
</span>
)}
</div>
{bitbucketStatus !== 'checking' && bitbucketStatus !== 'connected' && (
<div className="mt-3 rounded-md border border-border/30 bg-background/50 px-3 py-2.5 space-y-2">
{bitbucketStatus === 'not-configured' ? (
<>
<p className="text-xs text-muted-foreground">
Set <span className="font-mono text-[11px]">ORCA_BITBUCKET_EMAIL</span> and{' '}
<span className="font-mono text-[11px]">ORCA_BITBUCKET_API_TOKEN</span>, or set{' '}
<span className="font-mono text-[11px]">ORCA_BITBUCKET_ACCESS_TOKEN</span>.
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
Learn more
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshBitbucket}>
Re-check
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
Bitbucket credentials are configured but could not authenticate. Check the token
and repository permissions, then restart Orca if environment variables changed.
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.api.shell.openUrl(
'https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/'
)
}
>
<ExternalLink className="size-3.5 mr-1.5" />
Learn more
</Button>
<Button variant="ghost" size="sm" onClick={handleRefreshBitbucket}>
Re-check
</Button>
</div>
</>
)}
</div>
)}
</div>
{/* Linear */}
<div className="rounded-md border border-border/50 bg-muted/30 px-4 py-3">
<div className="flex items-center gap-3">

View File

@ -1,5 +1,5 @@
import React from 'react'
import { Bell, CalendarClock, Github, List, Search } from 'lucide-react'
import { Bell, CalendarClock, Github, Gitlab, List, Search } from 'lucide-react'
import { useAppStore } from '@/store'
import { useRepoMap } from '@/store/selectors'
import { cn } from '@/lib/utils'
@ -123,6 +123,21 @@ const SidebarNav = React.memo(function SidebarNav() {
>
<Github className="size-3.5" aria-hidden />
</span>
<span
role="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation()
if (!canBrowseTasks) {
return
}
openTaskPage({ taskSource: 'gitlab' })
}}
className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground"
aria-label="Open GitLab tasks"
>
<Gitlab className="size-3.5" aria-hidden />
</span>
<span
role="button"
tabIndex={-1}

View File

@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: the worktree card centralizes sidebar card state (selection, drag, agent status, git info, context menu) in one cohesive component so sidebar rendering doesn't fan out across files. */
import React, { useEffect, useMemo, useCallback, useRef, useState } from 'react'
import React, { useEffect, useMemo, useCallback, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { Badge } from '@/components/ui/badge'
@ -29,7 +29,8 @@ import {
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import type { Worktree, Repo, PRInfo, IssueInfo } from '../../../../shared/types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Worktree, Repo, IssueInfo } from '../../../../shared/types'
import {
branchDisplayName,
checksLabel,
@ -38,8 +39,7 @@ import {
EMPTY_BROWSER_TABS,
FilledBellIcon
} from './WorktreeCardHelpers'
import { IssueSection, PrSection, CommentSection } from './WorktreeCardMeta'
import { getWorktreeCardPrDisplay } from './worktree-card-pr-display'
import { IssueSection, ReviewSection, CommentSection } from './WorktreeCardMeta'
import {
selectLivePtyIdsForWorktree,
selectRuntimePaneTitlesForWorktree
@ -73,7 +73,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
}: WorktreeCardProps) {
const openModal = useAppStore((s) => s.openModal)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
const fetchIssue = useAppStore((s) => s.fetchIssue)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
const handleEditIssue = useCallback(
@ -175,14 +175,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
const branch = branchDisplayName(worktree.branch)
const isFolder = repo ? isFolderRepo(repo) : false
const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
const hostedReviewCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : ''
// Subscribe to ONLY the specific cache entry, not entire prCache/issueCache
const prEntry = useAppStore((s) => (prCacheKey ? s.prCache[prCacheKey] : undefined))
// Subscribe to ONLY the specific cache entry, not entire review/issue caches.
const hostedReviewEntry = useAppStore((s) =>
hostedReviewCacheKey ? s.hostedReviewCache[hostedReviewCacheKey] : undefined
)
const issueEntry = useAppStore((s) => (issueCacheKey ? s.issueCache[issueCacheKey] : undefined))
const pr: PRInfo | null | undefined = prEntry !== undefined ? prEntry.data : undefined
const hostedReview: HostedReviewInfo | null | undefined =
hostedReviewEntry !== undefined ? hostedReviewEntry.data : undefined
const issue: IssueInfo | null | undefined = worktree.linkedIssue
? issueEntry !== undefined
? issueEntry.data
@ -199,8 +202,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
title: issue === null ? 'Issue details unavailable' : 'Loading issue...'
}
: null)
const prDisplay = getWorktreeCardPrDisplay(pr, worktree.linkedPR)
const isDeleting = deleteState?.isDeleting ?? false
// Why: the sidebar dot overlays the *stable* hook-reported states (blocked,
@ -310,36 +311,36 @@ const WorktreeCard = React.memo(function WorktreeCard({
const showPR = cardProps.includes('pr')
const showCI = cardProps.includes('ci')
const showIssue = cardProps.includes('issue')
const previousPrLookupRef = useRef<{ cacheKey: string; linkedPRNumber: number | null } | null>(
null
)
// Skip GitHub fetches when the corresponding card sections are hidden.
// Skip hosted-review fetches when the corresponding card sections are hidden.
// This preference is purely presentational, so background refreshes would
// spend rate limit budget on data the user cannot see.
useEffect(() => {
if (repo && !isFolder && !worktree.isBare && prCacheKey && (showPR || showCI)) {
const linkedPRNumber = worktree.linkedPR ?? null
const previousLookup = previousPrLookupRef.current
const linkedPRChanged =
previousLookup !== null &&
previousLookup.cacheKey === prCacheKey &&
previousLookup.linkedPRNumber !== linkedPRNumber
previousPrLookupRef.current = { cacheKey: prCacheKey, linkedPRNumber }
if (
repo &&
!repo.connectionId &&
!isFolder &&
!worktree.isBare &&
hostedReviewCacheKey &&
(showPR || showCI)
) {
// Why: pass linkedPR so worktrees created from a PR (whose new local
// branch differs from the PR's head ref) still resolve their PR via
// a number-based fallback in the main process. Force when that fallback
// changes so the branch cache stops showing stale linked-PR data.
fetchPRForBranch(repo.path, branch, { linkedPRNumber, force: linkedPRChanged })
// branch differs from the remote head ref) still resolve their PR/MR via
// a number-based fallback in the main process.
fetchHostedReviewForBranch(repo.path, branch, {
linkedGitHubPR: worktree.linkedPR ?? null,
linkedGitLabMR: worktree.linkedGitLabMR ?? null
})
}
}, [
repo,
isFolder,
worktree.isBare,
worktree.linkedPR,
fetchPRForBranch,
worktree.linkedGitLabMR,
fetchHostedReviewForBranch,
branch,
prCacheKey,
hostedReviewCacheKey,
showPR,
showCI
])
@ -582,24 +583,24 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
{/* CI Checks & PR state on the right */}
{cardProps.includes('ci') && pr && pr.checksStatus !== 'neutral' && (
{cardProps.includes('ci') && hostedReview && hostedReview.status !== 'neutral' && (
<div className="flex items-center gap-2 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center opacity-80 hover:opacity-100 transition-opacity">
{pr.checksStatus === 'success' && (
{hostedReview.status === 'success' && (
<CircleCheck className="size-3.5 text-emerald-500" />
)}
{pr.checksStatus === 'failure' && (
{hostedReview.status === 'failure' && (
<CircleX className="size-3.5 text-rose-500" />
)}
{pr.checksStatus === 'pending' && (
{hostedReview.status === 'pending' && (
<LoaderCircle className="size-3.5 text-amber-500 animate-spin" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<span>CI checks {checksLabel(pr.checksStatus).toLowerCase()}</span>
<span>CI checks {checksLabel(hostedReview.status).toLowerCase()}</span>
</TooltipContent>
</Tooltip>
</div>
@ -647,20 +648,19 @@ const WorktreeCard = React.memo(function WorktreeCard({
<CacheTimer worktreeId={worktree.id} />
</div>
{/* Meta section: Issue / PR Links / Comment
{/* Meta section: Issue / hosted review / Comment
Layout coupling: spacing here is used to derive size estimates in
WorktreeList's estimateSize. Update that function if changing spacing. */}
{((cardProps.includes('issue') && issueDisplay) ||
(cardProps.includes('pr') && prDisplay) ||
(cardProps.includes('pr') && hostedReview) ||
(cardProps.includes('comment') && worktree.comment)) && (
<div className="flex flex-col gap-[3px] mt-0.5">
{cardProps.includes('issue') && issueDisplay && (
<IssueSection issue={issueDisplay} onClick={handleEditIssue} />
)}
{cardProps.includes('pr') && prDisplay && (
<PrSection
pr={prDisplay}
onClick={handleEditIssue}
{cardProps.includes('pr') && hostedReview && (
<ReviewSection
review={hostedReview}
onEdit={handleEditPr}
onRemove={handleRemovePr}
/>

View File

@ -1,5 +1,5 @@
/**
* Issue, PR, and Comment meta sections for WorktreeCard.
* Issue, review, and Comment meta sections for WorktreeCard.
*
* Why extracted: keeps WorktreeCard.tsx under the 400-line oxlint limit
* while co-locating the HoverCard presentation for each metadata type.
@ -13,7 +13,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { CircleDot, Pencil, Unlink } from 'lucide-react'
import { CircleDot, GitMerge, Pencil, Unlink } from 'lucide-react'
import { cn } from '@/lib/utils'
import CommentMarkdown from './CommentMarkdown'
import { PullRequestIcon, prStateLabel, checksLabel } from './WorktreeCardHelpers'
@ -21,7 +21,7 @@ import {
CLOSE_ALL_CONTEXT_MENUS_EVENT,
WORKTREE_CONTEXT_MENU_SCOPE_ATTR
} from './WorktreeContextMenu'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { IssueInfo } from '../../../../shared/types'
// ── Issue section ────────────────────────────────────────────────────
@ -90,26 +90,99 @@ export function IssueSection({ issue, onClick }: IssueSectionProps): React.JSX.E
)
}
// ── PR section ───────────────────────────────────────────────────────
// ── Hosted review section ────────────────────────────────────────────
type PrSectionProps = {
pr: WorktreeCardPrDisplay
onClick: (e: React.MouseEvent) => void
type ReviewSectionProps = {
review: HostedReviewInfo
onEdit: () => void
onRemove: () => void
}
export function PrSection({
pr,
onClick: _onClick,
onEdit,
onRemove
}: PrSectionProps): React.JSX.Element {
function getReviewLabel(review: HostedReviewInfo): 'MR' | 'PR' {
return review.provider === 'gitlab' ? 'MR' : 'PR'
}
function getProviderName(review: HostedReviewInfo): string {
if (review.provider === 'gitlab') {
return 'GitLab'
}
if (review.provider === 'bitbucket') {
return 'Bitbucket'
}
return 'GitHub'
}
function ReviewIcon({ review }: { review: HostedReviewInfo }): React.JSX.Element {
const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
return (
<Icon
className={cn(
'size-3 shrink-0',
review.state === 'merged' && 'text-purple-600/70 dark:text-purple-400/70',
review.state === 'open' && 'text-emerald-500/80',
review.state === 'closed' && 'text-muted-foreground/60',
review.state === 'draft' && 'text-muted-foreground/50',
(!review.state || !['merged', 'open', 'closed', 'draft'].includes(review.state)) &&
'text-muted-foreground opacity-60'
)}
/>
)
}
export function ReviewSection({ review, onEdit, onRemove }: ReviewSectionProps): React.JSX.Element {
const [menuOpen, setMenuOpen] = React.useState(false)
const [menuPoint, setMenuPoint] = React.useState({ x: 0, y: 0 })
const state = pr.state
const checksStatus = pr.checksStatus
const hasChecks = checksStatus && checksStatus !== 'neutral'
const label = getReviewLabel(review)
const providerName = getProviderName(review)
const hasChecks = review.status !== 'neutral'
const canManageGitHubLink = review.provider === 'github'
const content = (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<a
href={review.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 min-w-0 cursor-pointer group/meta -mx-1.5 px-1.5 py-0.5 rounded transition-colors hover:bg-background/40"
onClick={(e) => e.stopPropagation()}
>
<ReviewIcon review={review} />
<div className="flex-1 min-w-0 flex items-center gap-1.5 text-[11.5px] leading-none">
<span className="text-foreground opacity-80 shrink-0 group-hover/meta:underline">
{label} #{review.number}
</span>
<span className="text-muted-foreground truncate group-hover/meta:text-foreground transition-colors">
{review.title}
</span>
</div>
</a>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72 p-3 text-xs space-y-1.5">
<div className="font-semibold text-[13px]">
{label} #{review.number} {review.title}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span>State: {prStateLabel(review.state)}</span>
{hasChecks && <span>Checks: {checksLabel(review.status)}</span>}
</div>
<a
href={review.url}
target="_blank"
rel="noreferrer"
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
View on {providerName}
</a>
</HoverCardContent>
</HoverCard>
)
if (!canManageGitHubLink) {
return content
}
return (
<div
className="relative"
@ -123,63 +196,7 @@ export function PrSection({
setMenuOpen(true)
}}
>
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<a
href={pr.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 min-w-0 cursor-pointer group/meta -mx-1.5 px-1.5 py-0.5 rounded transition-colors hover:bg-background/40"
onClick={(e) => {
if (pr.url) {
e.stopPropagation()
}
}}
>
<PullRequestIcon
className={cn(
'size-3 shrink-0',
state === 'merged' && 'text-purple-600/70 dark:text-purple-400/70',
state === 'open' && 'text-emerald-500/80',
state === 'closed' && 'text-muted-foreground/60',
state === 'draft' && 'text-muted-foreground/50',
(!state || !['merged', 'open', 'closed', 'draft'].includes(state)) &&
'text-muted-foreground opacity-60'
)}
/>
<div className="flex-1 min-w-0 flex items-center gap-1.5 text-[11.5px] leading-none">
<span className="text-foreground opacity-80 shrink-0 group-hover/meta:underline">
PR #{pr.number}
</span>
<span className="text-muted-foreground truncate group-hover/meta:text-foreground transition-colors">
{pr.title}
</span>
</div>
</a>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-72 p-3 text-xs space-y-1.5">
<div className="font-semibold text-[13px]">
#{pr.number} {pr.title}
</div>
{state && (
<div className="flex items-center gap-2 text-muted-foreground">
<span>State: {prStateLabel(state)}</span>
{hasChecks && <span>Checks: {checksLabel(checksStatus)}</span>}
</div>
)}
{pr.url && (
<a
href={pr.url}
target="_blank"
rel="noreferrer"
className="text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
View on GitHub
</a>
)}
</HoverCardContent>
</HoverCard>
{content}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button

View File

@ -20,12 +20,14 @@ import { isGitRepoKind } from '../../../shared/repo-kind'
import type {
GitHubWorkItem,
GitPushTarget,
GitLabWorkItem,
LinearIssue,
OrcaHooks,
SetupDecision,
SetupRunPolicy,
SparsePreset,
TuiAgent,
WorktreeMeta,
WorkspaceCreateTelemetrySource
} from '../../../shared/types'
import {
@ -83,8 +85,11 @@ export type ComposerCardProps = {
name: string
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
/** GitLab parallel of onBaseBranchPrSelect. */
onBaseBranchMrSelect?: (baseBranch: string, item: GitLabWorkItem) => void
smartNameSelection: SmartWorkspaceNameSelection | null
onClearSmartNameSelection: () => void
agentPrompt: string
@ -288,6 +293,22 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
return initialLinkedWorkItem?.type === 'pr' ? initialLinkedWorkItem.number : null
})
// Why: GitLab parallels of linkedIssue/linkedPR. Kept as separate state
// (rather than reusing the GitHub slots with a provider discriminator) so
// the existing GitHub auto-name / linked-badge / persistence code paths
// stay untouched.
const [linkedGitLabIssue, setLinkedGitLabIssue] = useState<number | null>(() => {
if (persistDraft && newWorkspaceDraft?.linkedGitLabIssue !== undefined) {
return newWorkspaceDraft.linkedGitLabIssue
}
return null
})
const [linkedGitLabMR, setLinkedGitLabMR] = useState<number | null>(() => {
if (persistDraft && newWorkspaceDraft?.linkedGitLabMR !== undefined) {
return newWorkspaceDraft.linkedGitLabMR
}
return initialLinkedWorkItem?.type === 'mr' ? initialLinkedWorkItem.number : null
})
const [baseBranch, setBaseBranch] = useState<string | undefined>(
persistDraft ? newWorkspaceDraft?.baseBranch : initialBaseBranch
)
@ -603,6 +624,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agent: tuiAgent,
linkedIssue,
linkedPR,
linkedGitLabIssue,
linkedGitLabMR,
...(baseBranch !== undefined ? { baseBranch } : {})
})
}, [
@ -612,6 +635,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
baseBranch,
linkedIssue,
linkedPR,
linkedGitLabIssue,
linkedGitLabMR,
linkedWorkItem,
note,
name,
@ -867,6 +892,44 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[name]
)
// Why: parallel of applyLinkedWorkItem for GitLab. Touches the GitLab
// state slots only — the GitHub linkedIssue/linkedPR remain unchanged
// so a workspace can in principle reference items from both providers.
// The auto-name logic mirrors the GitHub side (issue: number-and-title,
// MR: branch name) via getLinkedWorkItemSuggestedName, which already
// accepts both shapes structurally.
const applyLinkedGitLabWorkItem = useCallback(
(item: GitLabWorkItem): void => {
if (item.type === 'issue') {
setLinkedGitLabIssue(item.number)
setLinkedGitLabMR(null)
} else {
setLinkedGitLabIssue(null)
setLinkedGitLabMR(item.number)
}
setLinkedWorkItem({
type: item.type,
number: item.number,
title: item.title,
url: item.url
})
// Why: GitLabWorkItem.branchName lines up with GitHubWorkItem.branchName
// structurally; cast to the suggested-name helper's input shape so we
// reuse the existing naming heuristic without forking it.
const suggestedName = getLinkedWorkItemSuggestedName({
type: item.type === 'mr' ? 'pr' : 'issue',
number: item.number,
title: item.title,
branchName: item.branchName
} as unknown as GitHubWorkItem)
if (suggestedName && (!name.trim() || name === lastAutoNameRef.current)) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
},
[name]
)
const handleSelectLinkedItem = useCallback(
(item: GitHubWorkItem): void => {
applyLinkedWorkItem(item)
@ -1067,12 +1130,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
let hint: string | null = null
if (linkedWorkItem?.type === 'pr' && baseBranch) {
hint = `was PR #${linkedWorkItem.number}`
} else if (linkedWorkItem?.type === 'mr' && baseBranch) {
// Why: GitLab MR convention is `!N`, not `#N` — match the
// upstream UI so the reset hint is recognizable.
hint = `was MR !${linkedWorkItem.number}`
} else if (baseBranch) {
hint = `was ${baseBranch}`
}
setRepoId(value)
setLinkedIssue('')
setLinkedPR(null)
setLinkedGitLabIssue(null)
setLinkedGitLabMR(null)
setLinkedWorkItem(null)
setSparseEnabled(false)
setSparseDirectories('')
@ -1132,6 +1201,26 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[applyLinkedWorkItem]
)
// Why: GitLab parallel of handleBaseBranchPrSelect. Same shape, same
// semantics — except the note prefill uses GitLab's `!N` MR convention
// so a glance at the worktree sidebar makes the provider obvious.
const handleBaseBranchMrSelect = useCallback(
(nextBaseBranch: string, item: GitLabWorkItem): void => {
setBaseBranch(nextBaseBranch)
setStartFromResetHint(null)
applyLinkedGitLabWorkItem(item)
if (item.type === 'mr') {
const suggestedNote = `MR !${item.number}${item.title}`
const currentNote = noteRef.current
if (!currentNote.trim() || currentNote === lastAutoNoteRef.current) {
setNote(suggestedNote)
lastAutoNoteRef.current = suggestedNote
}
}
},
[applyLinkedGitLabWorkItem]
)
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem): void => {
setStartFromResetHint(null)
@ -1169,6 +1258,38 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo]
)
// Why: GitLab parallel of handleSmartGitHubItemSelect. For a picked
// MR, resolves the base branch via worktrees:resolveMrBase (which uses
// refs/merge-requests/<iid>/head for fork MRs the same way the gh side
// uses refs/pull/<N>/head). Issue selections short-circuit since
// there's no branch-resolution step to run.
const handleSmartGitLabItemSelect = useCallback(
(item: GitLabWorkItem): void => {
applyLinkedGitLabWorkItem(item)
setStartFromResetHint(null)
const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo
if (item.type !== 'mr' || !repoForItem) {
return
}
void window.api.worktrees
.resolveMrBase({
repoId: repoForItem.id,
mrIid: item.number,
...(item.branchName ? { sourceBranch: item.branchName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
.then((result) => {
if ('error' in result) {
return
}
handleBaseBranchMrSelect(result.baseBranch, item)
})
},
[applyLinkedGitLabWorkItem, eligibleRepos, handleBaseBranchMrSelect, selectedRepo]
)
const handleSmartBranchSelect = useCallback(
(refName: string): void => {
setBaseBranch(refName)
@ -1257,9 +1378,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const applyWorktreeMeta = useCallback(
async (
worktreeId: string,
meta: {
comment?: string
}
meta: Partial<WorktreeMeta>
): Promise<void> => {
if (Object.keys(meta).length === 0) {
return
@ -1325,7 +1444,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const worktree = result.worktree
const trimmedNote = note.trim()
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
await applyWorktreeMeta(worktree.id, {
...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}),
...(effectiveLinkedPR !== null ? { linkedPR: effectiveLinkedPR } : {}),
...(linkedGitLabIssue !== null ? { linkedGitLabIssue } : {}),
...(linkedGitLabMR !== null ? { linkedGitLabMR } : {}),
...(trimmedNote ? { comment: trimmedNote } : {})
})
const issueCommand =
shouldRunIssueAutomation && issueCommandTrustDecision === 'run'
@ -1397,6 +1522,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
applyWorktreeMeta,
issueCommandTemplate,
effectiveLinkedPR,
linkedGitLabIssue,
linkedGitLabMR,
linkedWorkItem?.title,
linkedWorkItem?.url,
normalizedSparseDirectories,
@ -1643,6 +1770,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
name,
onNameValueChange: handleNameValueChange,
onSmartGitHubItemSelect: handleSmartGitHubItemSelect,
onSmartGitLabItemSelect: handleSmartGitLabItemSelect,
onSmartBranchSelect: handleSmartBranchSelect,
onSmartLinearIssueSelect: handleSmartLinearIssueSelect,
smartNameSelection,
@ -1680,6 +1808,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
baseBranch,
onBaseBranchChange: handleBaseBranchChange,
onBaseBranchPrSelect: handleBaseBranchPrSelect,
onBaseBranchMrSelect: handleBaseBranchMrSelect,
baseBranchLinkedPrNumber:
linkedWorkItem?.type === 'pr' && baseBranch ? linkedWorkItem.number : null,
selectedRepoPath: selectedRepo?.path ?? null,

View File

@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
normalizeGitLabLinkQuery,
parseGitLabIssueOrMRLink,
parseGitLabIssueOrMRNumber
} from './gitlab-links'
describe('parseGitLabIssueOrMRNumber', () => {
it('parses bare numbers, # prefix, and ! prefix', () => {
expect(parseGitLabIssueOrMRNumber('42')).toBe(42)
expect(parseGitLabIssueOrMRNumber('#42')).toBe(42)
expect(parseGitLabIssueOrMRNumber('!42')).toBe(42)
})
it('parses gitlab.com issue and MR URLs', () => {
expect(parseGitLabIssueOrMRNumber('https://gitlab.com/stablyai/orca/-/issues/923')).toBe(923)
expect(
parseGitLabIssueOrMRNumber('https://gitlab.com/stablyai/orca/-/merge_requests/123')
).toBe(123)
})
it('parses URLs from self-hosted GitLab instances', () => {
expect(parseGitLabIssueOrMRNumber('https://gitlab.example.com/team/api/-/issues/7')).toBe(7)
})
it('parses URLs with nested group paths', () => {
expect(
parseGitLabIssueOrMRNumber('https://gitlab.com/group/subgroup/project/-/merge_requests/55')
).toBe(55)
})
it('rejects GitHub URLs (no /-/ separator)', () => {
expect(parseGitLabIssueOrMRNumber('https://github.com/stablyai/orca/issues/923')).toBeNull()
expect(parseGitLabIssueOrMRNumber('https://github.com/stablyai/orca/pull/123')).toBeNull()
})
it('rejects unparseable input', () => {
expect(parseGitLabIssueOrMRNumber('')).toBeNull()
expect(parseGitLabIssueOrMRNumber(' ')).toBeNull()
expect(parseGitLabIssueOrMRNumber('not-a-url')).toBeNull()
})
})
describe('parseGitLabIssueOrMRLink', () => {
it('extracts slug + number + type for issues and MRs', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/-/issues/923')).toEqual({
slug: { path: 'stablyai/orca' },
number: 923,
type: 'issue'
})
expect(
parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/-/merge_requests/77')
).toEqual({ slug: { path: 'stablyai/orca' }, number: 77, type: 'mr' })
})
it('preserves full nested group paths in the slug', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/g/sub/proj/-/issues/1')).toEqual({
slug: { path: 'g/sub/proj' },
number: 1,
type: 'issue'
})
})
it('returns null for single-segment paths (no project)', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/foo/-/issues/1')).toBeNull()
})
it('returns null for non-GitLab URL shapes', () => {
expect(parseGitLabIssueOrMRLink('https://gitlab.com/stablyai/orca/issues/123')).toBeNull()
})
})
describe('normalizeGitLabLinkQuery', () => {
it('routes a bare number to directNumber', () => {
expect(normalizeGitLabLinkQuery('42')).toEqual({ query: '42', directNumber: 42 })
})
it('routes a full URL to query + directNumber', () => {
expect(normalizeGitLabLinkQuery('https://gitlab.com/stablyai/orca/-/issues/923')).toEqual({
query: 'https://gitlab.com/stablyai/orca/-/issues/923',
directNumber: 923
})
})
it('returns the query alone for non-numeric, non-URL input', () => {
expect(normalizeGitLabLinkQuery('search me')).toEqual({
query: 'search me',
directNumber: null
})
})
it('returns empty for empty input', () => {
expect(normalizeGitLabLinkQuery(' ')).toEqual({ query: '', directNumber: null })
})
})

View File

@ -0,0 +1,128 @@
// Why: GitLab project paths can include nested groups, and the host may
// be self-hosted (gitlab.example.com), so the URL pattern uses the
// project-internal `/-/` separator as the GitLab-specific signal rather
// than locking to gitlab.com. Anything matching `/<path>/-/(issues|
// merge_requests)/<digits>` is treated as a GitLab item URL regardless
// of host.
const GL_ITEM_PATH_RE = /\/(?:issues|merge_requests)\/(\d+)(?:\/)?$/i
const GL_ITEM_PATH_FULL_RE = /^\/(.+)\/-\/(issues|merge_requests)\/(\d+)(?:\/)?$/i
export type ProjectSlug = {
/** Full GitLab project path including any nested groups. */
path: string
}
export type GitLabLinkQuery = {
query: string
directNumber: number | null
}
/**
* Parse a GitLab issue or MR reference from plain input. Accepts:
* - bare numbers ("42")
* - hash-prefixed numbers ("#42")
* - exclamation-prefixed numbers ("!42") GitLab convention for MRs
* - full GitLab URLs (any host) for issues or merge_requests
*/
export function parseGitLabIssueOrMRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
// Why: GitLab references issues with `#` and MRs with `!` in markdown
// and copy-paste contexts. Accept both prefixes so users can drop in
// either form.
const numeric = trimmed.startsWith('#') || trimmed.startsWith('!') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) {
return Number.parseInt(numeric, 10)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
const match = GL_ITEM_PATH_RE.exec(url.pathname)
if (!match) {
return null
}
// Why: the basic pattern matches plain GitHub URLs too (e.g.
// /owner/repo/issues/123). Require the `/-/` separator that's
// unique to GitLab to avoid mis-classifying a GitHub URL.
if (!url.pathname.includes('/-/')) {
return null
}
return Number.parseInt(match[1], 10)
}
/**
* Parse a GitLab URL into project path + iid + type. Returns null for
* anything that isn't a recognizable GitLab issue or merge-request URL.
*/
export function parseGitLabIssueOrMRLink(input: string): {
slug: ProjectSlug
number: number
type: 'issue' | 'mr'
} | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
const match = GL_ITEM_PATH_FULL_RE.exec(url.pathname)
if (!match) {
return null
}
const path = match[1]
// Why: a project path needs at least one slash (group/project). A
// single-segment path is the user/group root, not a project.
if (!path.includes('/')) {
return null
}
return {
slug: { path },
type: match[2].toLowerCase() === 'merge_requests' ? 'mr' : 'issue',
number: Number.parseInt(match[3], 10)
}
}
/**
* Normalize link-picker input so both raw issue/MR numbers and full
* GitLab URLs resolve to a usable query + direct-number lookup.
*/
export function normalizeGitLabLinkQuery(raw: string): GitLabLinkQuery {
const trimmed = raw.trim()
if (!trimmed) {
return { query: '', directNumber: null }
}
const direct = parseGitLabIssueOrMRNumber(trimmed)
if (direct !== null && !trimmed.startsWith('http')) {
return { query: trimmed, directNumber: direct }
}
const link = parseGitLabIssueOrMRLink(trimmed)
if (!link) {
return { query: trimmed, directNumber: null }
}
// Why: any GitLab issue/MR URL is accepted by number regardless of
// project slug, mirroring the GitHub-side behavior — fork checkouts
// can legitimately target an upstream's issue numbers.
return {
query: trimmed,
directNumber: link.number
}
}

View File

@ -38,7 +38,10 @@ export const CLIENT_PLATFORM: NodeJS.Platform = navigator.userAgent.includes('Wi
: 'linux'
export type LinkedWorkItemSummary = {
type: 'issue' | 'pr'
/** 'mr' is the GitLab analogue of 'pr'. The shape is otherwise
* identical so the linked-work-item badge in the composer renders
* uniformly across providers. */
type: 'issue' | 'pr' | 'mr'
number: number
title: string
url: string

View File

@ -8,6 +8,7 @@ import { createTabsSlice } from './slices/tabs'
import { createUISlice } from './slices/ui'
import { createSettingsSlice } from './slices/settings'
import { createGitHubSlice } from './slices/github'
import { createHostedReviewSlice } from './slices/hosted-review'
import { createLinearSlice } from './slices/linear'
import { createEditorSlice } from './slices/editor'
import { createStatsSlice } from './slices/stats'
@ -34,6 +35,7 @@ export const useAppStore = create<AppState>()((...a) => ({
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),

View File

@ -80,6 +80,7 @@ import { createTabsSlice } from './tabs'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createHostedReviewSlice } from './hosted-review'
import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
@ -105,6 +106,7 @@ function createTestStore() {
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),

View File

@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import { createHostedReviewSlice } from './hosted-review'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
const mockApi = {
hostedReview: {
forBranch: vi.fn()
}
}
globalThis.window = { api: mockApi } as never
function makeStore() {
return create<Pick<AppState, 'hostedReviewCache' | 'fetchHostedReviewForBranch'>>()((...args) =>
createHostedReviewSlice(...(args as Parameters<typeof createHostedReviewSlice>))
)
}
const review: HostedReviewInfo = {
provider: 'gitlab',
number: 5,
title: 'Shared MR status',
state: 'open',
url: 'https://gitlab.com/g/p/-/merge_requests/5',
status: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'MERGEABLE'
}
describe('hosted review slice', () => {
beforeEach(() => {
mockApi.hostedReview.forBranch.mockReset()
})
it('fetches and caches branch review status through the common IPC surface', async () => {
mockApi.hostedReview.forBranch.mockResolvedValueOnce(review)
const store = makeStore()
await expect(
store.getState().fetchHostedReviewForBranch('/repo', 'feature/gitlab', {
linkedGitLabMR: 5
})
).resolves.toEqual(review)
await expect(
store.getState().fetchHostedReviewForBranch('/repo', 'feature/gitlab')
).resolves.toEqual(review)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(1)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith({
repoPath: '/repo',
branch: 'feature/gitlab',
linkedGitHubPR: null,
linkedGitLabMR: 5,
linkedBitbucketPR: null
})
})
})

View File

@ -0,0 +1,107 @@
import type { StateCreator } from 'zustand'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { AppState } from '../types'
type CacheEntry<T> = { data: T | null; fetchedAt: number }
type FetchOptions = { force?: boolean }
const CACHE_TTL_MS = 60_000
const inflightHostedReviewRequests = new Map<
string,
{ promise: Promise<HostedReviewInfo | null>; force: boolean; generation: number }
>()
const requestGenerations = new Map<string, number>()
function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS
}
export type HostedReviewSlice = {
hostedReviewCache: Record<string, CacheEntry<HostedReviewInfo>>
fetchHostedReviewForBranch: (
repoPath: string,
branch: string,
options?: FetchOptions & {
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
}
) => Promise<HostedReviewInfo | null>
}
export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedReviewSlice> = (
set,
get
) => ({
hostedReviewCache: {},
fetchHostedReviewForBranch: async (
repoPath,
branch,
options
): Promise<HostedReviewInfo | null> => {
const cacheKey = `${repoPath}::${branch}`
const cached = get().hostedReviewCache[cacheKey]
const linkedRefetch =
cached?.data === null &&
((options?.linkedGitHubPR ?? null) !== null ||
(options?.linkedGitLabMR ?? null) !== null ||
(options?.linkedBitbucketPR ?? null) !== null)
if (!options?.force && !linkedRefetch && isFresh(cached)) {
return cached.data
}
const inflightRequest = inflightHostedReviewRequests.get(cacheKey)
if (inflightRequest && (!options?.force || inflightRequest.force) && !linkedRefetch) {
return inflightRequest.promise
}
const generation = (requestGenerations.get(cacheKey) ?? 0) + 1
requestGenerations.set(cacheKey, generation)
const request = (async () => {
try {
const review = await window.api.hostedReview.forBranch({
repoPath,
branch,
linkedGitHubPR: options?.linkedGitHubPR ?? null,
linkedGitLabMR: options?.linkedGitLabMR ?? null,
linkedBitbucketPR: options?.linkedBitbucketPR ?? null
})
if (requestGenerations.get(cacheKey) === generation) {
set((state) => ({
hostedReviewCache: {
...state.hostedReviewCache,
[cacheKey]: { data: review, fetchedAt: Date.now() }
}
}))
}
return review
} catch (error) {
console.error('Failed to fetch hosted review:', error)
if (requestGenerations.get(cacheKey) === generation) {
set((state) => ({
hostedReviewCache: {
...state.hostedReviewCache,
[cacheKey]: { data: null, fetchedAt: Date.now() }
}
}))
}
return null
} finally {
const activeRequest = inflightHostedReviewRequests.get(cacheKey)
if (activeRequest?.generation === generation) {
inflightHostedReviewRequests.delete(cacheKey)
}
}
})()
inflightHostedReviewRequests.set(cacheKey, {
promise: request,
force: Boolean(options?.force),
generation
})
return request
}
})

View File

@ -97,6 +97,7 @@ import { createTabsSlice } from './tabs'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createHostedReviewSlice } from './hosted-review'
import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
@ -122,6 +123,7 @@ function createTestStore() {
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
@ -153,6 +155,8 @@ function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: strin
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -16,6 +16,7 @@ import { createTabsSlice } from './tabs'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createHostedReviewSlice } from './hosted-review'
import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
@ -49,6 +50,7 @@ export function createTestStore() {
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
@ -93,6 +95,8 @@ export function makeWorktree(
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -92,6 +92,7 @@ import { createTabsSlice } from './tabs'
import { createUISlice } from './ui'
import { createSettingsSlice } from './settings'
import { createGitHubSlice } from './github'
import { createHostedReviewSlice } from './hosted-review'
import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
@ -119,6 +120,7 @@ function createTestStore() {
...createUISlice(...a),
...createSettingsSlice(...a),
...createGitHubSlice(...a),
...createHostedReviewSlice(...a),
...createLinearSlice(...a),
...createEditorSlice(...a),
...createStatsSlice(...a),
@ -1110,6 +1112,8 @@ describe('TabsSlice', () => {
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
@ -1203,6 +1207,8 @@ describe('TabsSlice', () => {
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
@ -1276,6 +1282,8 @@ describe('TabsSlice', () => {
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -1665,6 +1665,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -203,7 +203,7 @@ export type UISlice = {
taskPageData: {
preselectedRepoId?: string
prefilledName?: string
taskSource?: 'github' | 'linear'
taskSource?: 'github' | 'linear' | 'gitlab'
}
taskResumeState: TaskResumeState | undefined
setTaskResumeState: (updates: Partial<TaskResumeState>) => void
@ -214,7 +214,7 @@ export type UISlice = {
note: string
attachments: string[]
linkedWorkItem: {
type: 'issue' | 'pr'
type: 'issue' | 'pr' | 'mr'
number: number
title: string
url: string
@ -222,6 +222,10 @@ export type UISlice = {
agent: TuiAgent
linkedIssue: string
linkedPR: number | null
/** GitLab parallels number for an issue, iid for an MR. Optional so
* drafts saved before GitLab support keep loading without migration. */
linkedGitLabIssue?: number | null
linkedGitLabMR?: number | null
// Why: repo-scoped start ref selected via the "Start from" picker.
// Absent means "use the repo's effective base ref".
baseBranch?: string

View File

@ -84,6 +84,8 @@ function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: strin
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,

View File

@ -6,6 +6,7 @@ import type { TabsSlice } from './slices/tabs'
import type { UISlice } from './slices/ui'
import type { SettingsSlice } from './slices/settings'
import type { GitHubSlice } from './slices/github'
import type { HostedReviewSlice } from './slices/hosted-review'
import type { LinearSlice } from './slices/linear'
import type { EditorSlice } from './slices/editor'
import type { StatsSlice } from './slices/stats'
@ -29,6 +30,7 @@ export type AppState = RepoSlice &
UISlice &
SettingsSlice &
GitHubSlice &
HostedReviewSlice &
LinearSlice &
EditorSlice &
StatsSlice &

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { computeNextGitLabRecents, GITLAB_RECENTS_MAX } from './gitlab-projects'
describe('computeNextGitLabRecents', () => {
const fixedNow = new Date('2026-05-08T10:00:00.000Z')
it('prepends a fresh entry to an empty list', () => {
expect(computeNextGitLabRecents([], 'gitlab.com', 'g/p', fixedNow)).toEqual([
{ host: 'gitlab.com', path: 'g/p', lastOpenedAt: fixedNow.toISOString() }
])
})
it('moves an existing entry to the front (dedupes by host + path)', () => {
const existing = [
{ host: 'gitlab.com', path: 'a/b', lastOpenedAt: '2026-05-07' },
{ host: 'gitlab.com', path: 'g/p', lastOpenedAt: '2026-05-06' },
{ host: 'gitlab.com', path: 'c/d', lastOpenedAt: '2026-05-05' }
]
const result = computeNextGitLabRecents(existing, 'gitlab.com', 'g/p', fixedNow)
expect(result.map((r) => r.path)).toEqual(['g/p', 'a/b', 'c/d'])
expect(result[0].lastOpenedAt).toBe(fixedNow.toISOString())
})
it('treats different hosts at the same path as distinct entries', () => {
const existing = [{ host: 'gitlab.example.com', path: 'g/p', lastOpenedAt: '2026-05-07' }]
const result = computeNextGitLabRecents(existing, 'gitlab.com', 'g/p', fixedNow)
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ host: 'gitlab.com', path: 'g/p' })
expect(result[1]).toMatchObject({ host: 'gitlab.example.com', path: 'g/p' })
})
it('caps the list at GITLAB_RECENTS_MAX entries', () => {
const existing = Array.from({ length: GITLAB_RECENTS_MAX }, (_, i) => ({
host: 'gitlab.com',
path: `g/p${i}`,
lastOpenedAt: `2026-05-0${i}`
}))
const result = computeNextGitLabRecents(existing, 'gitlab.com', 'g/new', fixedNow)
expect(result).toHaveLength(GITLAB_RECENTS_MAX)
expect(result[0].path).toBe('g/new')
// Why: oldest entry (the one that was at the tail before the prepend)
// must be the one that fell off — verify by checking it's no longer
// in the result.
expect(result.find((r) => r.path === `g/p${GITLAB_RECENTS_MAX - 1}`)).toBeUndefined()
})
it('does not mutate the input array', () => {
const existing = [{ host: 'gitlab.com', path: 'a/b', lastOpenedAt: '2026-05-07' }]
const snapshot = JSON.stringify(existing)
computeNextGitLabRecents(existing, 'gitlab.com', 'g/p', fixedNow)
expect(JSON.stringify(existing)).toBe(snapshot)
})
})

View File

@ -0,0 +1,25 @@
// Why: pure helpers for GitLabProjectSettings — kept out of the IPC
// handler so the recents logic is testable without mocking the Store.
import type { GitLabProjectSettings } from './types'
/** Default max recents kept before older entries fall off. */
export const GITLAB_RECENTS_MAX = 10
/**
* Compute the next `recent` list when a project at (host, path) is
* opened. Most-recent-first ordering, dedupes by host+path, caps at
* `max` entries. Returns a fresh array caller is responsible for
* persisting via `Store.updateSettings`.
*/
export function computeNextGitLabRecents(
existing: GitLabProjectSettings['recent'],
host: string,
path: string,
now: Date = new Date(),
max: number = GITLAB_RECENTS_MAX
): GitLabProjectSettings['recent'] {
// Why: filter before prepend so re-opening an already-recent project
// moves it to the front rather than producing a duplicate.
const filtered = existing.filter((entry) => !(entry.host === host && entry.path === path))
return [{ host, path, lastOpenedAt: now.toISOString() }, ...filtered].slice(0, max)
}

272
src/shared/gitlab-types.ts Normal file
View File

@ -0,0 +1,272 @@
/* GitLab-specific shared types. Split out of `src/shared/types.ts` so
adding or changing a GitLab type doesn't surface as a merge conflict
on every upstream sync of the much larger central types file.
Imports the small base types (CheckStatus, ClassifiedError,
PRConflictSummary) it depends on; re-exported from `./types` for
import-stability existing call sites (`from '../shared/types'`)
continue to work without changes. */
import type { CheckStatus, ClassifiedError, PRConflictSummary } from './types'
// Why: GitLab's analogue of `GitHubOwnerRepo`. Two structural differences
// from GitHub make the flat owner/repo shape inadequate: (a) projects can
// live under arbitrarily nested groups (`group/subgroup/project`), and
// (b) self-hosted instances live on hostnames other than gitlab.com so the
// host has to travel with the path for URL construction and glab host
// targeting. Aliased as `ProjectRef` in `src/main/gitlab/gl-utils.ts`.
export type GitLabProjectRef = { host: string; path: string }
// ── GitLab MR / issue / work-item shapes ────────────────────────────
// Why: parallel to the GitHub PR/Issue/WorkItem types above. Native
// GitLab state strings are preserved (`opened` vs gh `open`) so we don't
// have to remember whether a value has been mapped — every GitLab-side
// type uses the API's own vocabulary.
export type MRState = 'opened' | 'closed' | 'merged' | 'locked' | 'draft'
export type GitLabIssueState = 'opened' | 'closed'
// Why: glab does not surface a structured "mergeable" field equivalent to
// GitHub's GraphQL `mergeable`; we project the available signals
// (`detailed_merge_status`, `has_conflicts`) onto the same three-value
// shape used by GitHub's PRMergeableState so the UI can stay simple.
export type MRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
// Why: GitLab pipeline jobs and GitHub check-runs map onto the same
// three-state lifecycle. Keep the field names identical to PRCheckDetail
// so the rendering layer can share a row component.
export type MRCheckDetail = {
name: string
status: 'queued' | 'in_progress' | 'completed'
conclusion:
| 'success'
| 'failure'
| 'cancelled'
| 'timed_out'
| 'neutral'
| 'skipped'
| 'pending'
| null
url: string | null
}
export type MRInfo = {
number: number
title: string
state: MRState
url: string
pipelineStatus: CheckStatus
updatedAt: string
mergeable: MRMergeableState
/** Full markdown description as authored on the MR. Optional because
* list endpoints omit it; populated on single-MR fetch (`getMR`). */
description?: string
/** Author username (GitLab `username`). Optional for the same reason. */
author?: string | null
authorAvatarUrl?: string | null
/** GitLab MR head SHA — pipeline status is keyed off the head commit. */
headSha?: string
conflictSummary?: PRConflictSummary
}
// Why: GitLab "emoji awards" are a richer set than GitHub's eight
// reactions; rather than enumerate all of them, carry the raw award name
// and let the renderer decide what to surface.
export type GitLabReaction = {
name: string
count: number
}
export type MRComment = {
id: number
author: string
authorAvatarUrl: string
body: string
createdAt: string
url: string
reactions?: GitLabReaction[]
/** File path for inline review comments (absent for top-level discussion notes). */
path?: string
/** GitLab discussion ID present only for inline review comments. Used to
* resolve/unresolve the discussion via `glab api`. */
threadId?: string
/** Whether the discussion has been resolved. Only meaningful when threadId is set. */
isResolved?: boolean
line?: number
startLine?: number
/** True when GitLab identifies the author as a bot (user `state === 'bot'`
* or matching system user heuristics). Mirrors GitHub PRComment.isBot. */
isBot?: boolean
}
export type GitLabCommentResult = { ok: true; comment: MRComment } | { ok: false; error: string }
export type GitLabIssueInfo = {
number: number
title: string
state: GitLabIssueState
url: string
labels: string[]
/** Full markdown description as authored on the issue. Optional because
* list endpoints omit it; populated on single-issue fetch (`getIssue`). */
description?: string
/** Author username — populated on single-issue fetch. */
author?: string | null
authorAvatarUrl?: string | null
}
export type GitLabViewer = {
username: string
email: string | null
}
export type GitLabAssignableUser = {
username: string
name: string | null
avatarUrl: string
}
export type GitLabWorkItem = {
id: string
type: 'issue' | 'mr'
number: number
title: string
state: 'opened' | 'closed' | 'merged' | 'locked' | 'draft'
url: string
labels: string[]
updatedAt: string
author: string | null
branchName?: string
baseRefName?: string
/** True when an MR's source branch lives in a fork project. The
* Start-from picker mirrors GitHub's behavior and disables fork MRs in
* v1 because resolving a fork head from the source branch alone is not
* safe. */
isCrossRepository?: boolean
/** Stamped by the renderer fetcher / optimistic stubs so cross-project
* views can attribute rows. Mirrors GitHubWorkItem.repoId. */
repoId: string
}
export type GitLabMRFile = {
path: string
oldPath?: string
status: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged'
additions: number
deletions: number
/** GitLab marks files above its diff size limit as binary; we skip content fetches for these. */
isBinary: boolean
}
// Why: parallel of GitHubProjectSettings, scoped to plain GitLab
// projects since v1 doesn't ship Projects-v2-style boards. Recent is
// auto-tracked from the picker's paste-URL flow so users coming back to
// projects they've recently visited don't have to re-paste the URL.
// Pinned is reserved for a future UI affordance — defining the field
// now keeps settings migrations simple later.
export type GitLabProjectSettings = {
pinned: { host: string; path: string }[]
recent: { host: string; path: string; lastOpenedAt: string }[]
}
// Why: GitLab Todos (gitlab.com/dashboard/todos) are cross-project
// notifications — assigned items, mentions, build failures, review
// requests, etc. The action_name field is open-ended in the API; we
// keep it as a string so new GitLab versions don't break the type.
// target_type narrows to the four shapes Orca renders meaningfully —
// other values (DesignManagement::Design, AlertManagement::Alert)
// fall back to a generic "open URL" treatment in the UI.
export type GitLabTodoTargetType = 'MergeRequest' | 'Issue' | 'Commit' | 'Note'
export type GitLabTodo = {
id: number
/** Free-form GitLab action name: 'assigned', 'mentioned', 'build_failed',
* 'marked', 'approval_required', 'review_requested', 'unmergeable', etc. */
actionName: string
targetType: GitLabTodoTargetType | string
/** iid when target is an MR or Issue; '' for Commit/Note targets where the
* identifier is a SHA or note ID instead. */
targetIid: number | null
targetTitle: string
targetUrl: string
/** Project path (`group/subgroup/project`) for the target. Empty for
* rare targets that aren't project-scoped. */
projectPath: string
/** Author of the action that produced the todo. Empty when the todo was
* generated by the system (e.g. build_failed). */
authorUsername: string
authorAvatarUrl: string
/** ISO timestamp from GitLab. */
updatedAt: string
/** GitLab supports todos in 'pending' or 'done' state. v1 only fetches
* pending; the field is on the type for future filter support. */
state: 'pending' | 'done'
}
// Why: per-job pipeline status — surfaces in the GitLab dialog Pipeline
// tab so users can see which job failed and where without leaving Orca.
// Mirrors PRCheckDetail's "single row per check" shape so the rendering
// component is reusable.
export type GitLabPipelineJob = {
id: number
name: string
/** GitLab stage name, e.g. 'build' / 'test' / 'deploy'. */
stage: string
/** Raw GitLab job status — 'success' / 'failed' / 'running' / 'pending'
* / 'canceled' / 'skipped' / 'manual' / 'created' / 'preparing'. The
* renderer maps to a colored pill via the existing status helpers. */
status: string
webUrl: string
/** Duration in seconds. null when the job hasn't finished. */
duration: number | null
}
// Why: aggregated detail payload for GitLabItemDialog. Parallel to
// GitHubWorkItemDetails. Flattens discussion notes into a single comments
// list — inline review-comment positioning is v1.5 work; this surface is
// "read description + conversation + pipeline + act on it".
export type GitLabWorkItemDetails = {
/** repoId is stamped by the renderer from the dialog's caller (TaskPage,
* picker) main-process doesn't know Orca's Repo.id. */
item: Omit<GitLabWorkItem, 'repoId'>
body: string
comments: MRComment[]
/** MR head/base SHAs populated for MRs only. Reserved for a future
* Files tab; the dialog reads `body` for now. */
headSha?: string
baseSha?: string
/** MR-only — populated when the MR's head_pipeline exists. */
pipelineJobs?: GitLabPipelineJob[]
participants?: GitLabAssignableUser[]
/** Issue-only — usernames of current assignees. */
assignees?: string[]
}
export type GitLabIssueUpdate = {
state?: 'opened' | 'closed'
title?: string
/** Mirrors GitHubIssueUpdate.body kept ignored on the repoPath-based
* flow for the same backward-compat reason. Slug-addressed write paths
* cover the body-edit case end-to-end. */
body?: string
addLabels?: string[]
removeLabels?: string[]
addAssignees?: string[]
removeAssignees?: string[]
}
// Why: GitLab-native MR list filter — Open / Merged / Closed / All —
// replaces GitHub's search-DSL on the GitLab tab per the agreed scope.
// 'all' maps to no state filter (any state).
export type MRListState = 'opened' | 'merged' | 'closed' | 'all'
// Why: paginated list result for both MRs and combined work-items.
// totalCount / totalPages come from X-Total / X-Total-Pages response
// headers via `glab api -i`, so the renderer can show "Page X of Y".
export type GitLabPagedResult<T> = {
items: T[]
page: number
perPage: number
totalCount: number
totalPages: number
error?: ClassifiedError
}
export type ListMergeRequestsResult = GitLabPagedResult<GitLabWorkItem>

View File

@ -0,0 +1,26 @@
import type { CheckStatus, PRConflictSummary, PRMergeableState } from './types'
export type HostedReviewProvider = 'github' | 'gitlab' | 'bitbucket'
export type HostedReviewState = 'open' | 'closed' | 'merged' | 'draft'
export type HostedReviewInfo = {
provider: HostedReviewProvider
number: number
title: string
state: HostedReviewState
url: string
status: CheckStatus
updatedAt: string
mergeable: PRMergeableState
headSha?: string
conflictSummary?: PRConflictSummary
}
export type HostedReviewForBranchArgs = {
repoPath: string
branch: string
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
}

View File

@ -4,6 +4,7 @@ import type { Automation, AutomationRun } from './automations-types'
import type { WorkspaceSource } from './telemetry-events'
import type { GitHubProjectSettings } from './github-project-types'
import type { VoiceSettings } from './speech-types'
import type { GitLabProjectSettings } from './gitlab-types'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
@ -113,6 +114,16 @@ export type Worktree = {
linkedIssue: number | null
linkedPR: number | null
linkedLinearIssue: string | null
// Why: parallel slots for GitLab work-item references. Kept as separate
// fields (rather than reusing linkedIssue / linkedPR with a provider
// discriminator) so the persistence layer is unambiguous when a user
// has both a GitHub and a GitLab remote on the same repo, and so the
// existing GitHub renderer code keeps reading linkedPR / linkedIssue
// unchanged. Optional on the type so existing test fixtures and
// persisted older worktrees that never carried these fields continue
// to typecheck and load without migration.
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
isArchived: boolean
isUnread: boolean
isPinned: boolean
@ -152,6 +163,10 @@ export type WorktreeMeta = {
linkedIssue: number | null
linkedPR: number | null
linkedLinearIssue: string | null
/** Optional for backward compatibility — see Worktree.linkedGitLabMR. */
linkedGitLabMR?: number | null
/** Optional for backward compatibility — see Worktree.linkedGitLabIssue. */
linkedGitLabIssue?: number | null
isArchived: boolean
isUnread: boolean
isPinned: boolean
@ -739,6 +754,36 @@ export type ClassifiedError = {
// can continue using the short local name.
export type GitHubOwnerRepo = { owner: string; repo: string }
// Why: GitLab-specific types live in `./gitlab-types` so they can grow
// independently from the central types file (which is touched by every
// upstream feature). Re-exported here so existing call sites
// (`from '../shared/types'`) keep working without changes.
export type {
GitLabAssignableUser,
GitLabCommentResult,
GitLabIssueInfo,
GitLabIssueState,
GitLabIssueUpdate,
GitLabMRFile,
GitLabPagedResult,
GitLabPipelineJob,
GitLabProjectRef,
GitLabProjectSettings,
GitLabReaction,
GitLabTodo,
GitLabTodoTargetType,
GitLabViewer,
GitLabWorkItem,
GitLabWorkItemDetails,
ListMergeRequestsResult,
MRCheckDetail,
MRComment,
MRInfo,
MRListState,
MRMergeableState,
MRState
} from './gitlab-types'
/**
* GitHub API rate-limit buckets surfaced in the TaskPage header so users can
* see remaining budget before they hit the wall. `core` = REST (5000/hr),
@ -1269,7 +1314,7 @@ export type GlobalSettings = {
defaultTaskViewPreset: TaskViewPresetId
/** Why: persists the user's last-used task source so the Tasks page
* reopens to the same provider instead of always defaulting to GitHub. */
defaultTaskSource: 'github' | 'linear'
defaultTaskSource: 'github' | 'linear' | 'gitlab'
/** Why: persists the user's repo selection in the cross-repo tasks view.
* `null` means sticky-all every eligible repo is selected, including
* repos added in future sessions, so the "All repos" label stays
@ -1342,6 +1387,10 @@ export type GlobalSettings = {
* landed won't have the key; `getDefaultSettings()` hydrates the empty
* default via the persistence merge. */
githubProjects?: GitHubProjectSettings
/** GitLab project preferences pinned + recent project paths.
* Optional for backward compatibility with profiles saved before
* GitLab support; the persistence merge fills the empty default. */
gitlabProjects?: GitLabProjectSettings
/** Anonymous product-telemetry state. Optional because the one-shot
* migration in `Store.load()` is what populates it on first boot of the
* telemetry release; before migration runs, the field is absent. After