mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* 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>
250 lines
7.9 KiB
TypeScript
250 lines
7.9 KiB
TypeScript
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'
|
|
})
|
|
})
|
|
})
|