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:
parent
151040f05f
commit
bca39bc928
|
|
@ -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')}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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.) ──────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -1700,6 +1700,8 @@ function getDefaultWorktreeMeta(): WorktreeMeta {
|
|||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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. You’re 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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue