From bca39bc928222f176da9752033632d6509fc25c4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 14 May 2026 15:09:52 -0700 Subject: [PATCH] Add GitLab and Bitbucket hosted review support (#1839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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, 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) * 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//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//head for fork MRs (parallel of GitHub's refs/pull//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) * 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) * 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//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) * 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) * 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) * 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 + ) +} + +export default function GitLabItemDialog({ + item, + repoPath, + onClose, + onCreateWorkspace +}: Props): React.JSX.Element { + const [details, setDetails] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 => { + 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 => { + 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 => { + 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 => { + 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 ( + !open && onClose()}> + + + {item ? item.title : 'Work item'} + GitLab work item detail + + + {item ? ( + <> +
+
+ +
+
+ + {prefix} + {item.number} + + + {item.author ? by {item.author} : null} +
+

+ {item.title} +

+
+ +
+
+ + + + Description + + Conversation + {details?.comments?.length ? ( + + {details.comments.length} + + ) : null} + + {isMR ? ( + + Pipeline + {details?.pipelineJobs?.length ? ( + + {details.pipelineJobs.length} + + ) : null} + + ) : null} + + +
+ {error ? ( +
+ {error} +
+ ) : null} + + + {loading && !details ? ( +
+ +
+ ) : details?.body ? ( + + ) : ( +

No description.

+ )} +
+ + + {loading && !details ? ( +
+ +
+ ) : details?.comments?.length ? ( + details.comments.map((c) => ) + ) : ( +

No comments yet.

+ )} +
+ + {isMR ? ( + + {loading && !details ? ( +
+ +
+ ) : details?.pipelineJobs?.length ? ( +
+ {details.pipelineJobs.map((j) => ( + + ))} +
+ ) : ( +

No pipeline runs for this MR.

+ )} +
+ ) : null} +
+
+ +