import { describe, expect, it } from "vitest"; import type { PullRequestCommandStatus } from "./forge-service.js"; import { ForgeDiscoveryIncompleteError } from "./forge-service.js"; import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline"; import type { GitLabStatusFacts } from "./gitlab-facts.js"; import { type CreateGitLabServiceOptions, createGitLabService, GlabAuthenticationError, GlabCliMissingError, GlabCommandError, type GlabCommandResult, type GlabCommandRunner, } from "./gitlab-service.js"; type Responder = (args: string[]) => GlabCommandResult | Promise; function ok(stdout: string): GlabCommandResult { return { stdout, stderr: "" }; } function makeService(responder: Responder, overrides: Partial = {}) { const calls: string[][] = []; const runner: GlabCommandRunner = async (args) => { calls.push(args); return responder(args); }; const service = createGitLabService({ runner, resolveGlabPath: async () => "/usr/bin/glab", resolveRemoteUrl: async () => "git@gitlab.example.com:example-group/example-project.git", ...overrides, }); return { service, calls }; } function currentMrListArgs(headRef: string): string[] { return [ "mr", "list", "--all", "--source-branch", headRef, "--order", "updated_at", "--sort", "desc", "--per-page", "100", "-F", "json", ]; } function gitlabAutoMergeStatus( overrides: Partial = {}, ): PullRequestCommandStatus { return { forgeSpecific: { forge: "gitlab", detailedMergeStatus: "ci_still_running", hasConflicts: false, blockingDiscussionsResolved: true, approvalsRequired: 0, approvalsGiven: 0, pipelineStatus: "running", pipelineId: 306, pipelineUrl: null, mergeWhenPipelineSucceeds: false, ...overrides, }, }; } const OPEN_MR = { iid: 14, title: "chore(release): 0.4.0", web_url: "https://gitlab.example.com/example-group/example-project/-/merge_requests/14", state: "opened", source_branch: "release/v0.4.0", target_branch: "main", sha: "1111111111111111111111111111111111111111", source_project_id: 101, target_project_id: 101, draft: false, work_in_progress: false, has_conflicts: false, merged_at: null, detailed_merge_status: "mergeable", description: "Release notes", labels: ["release"], updated_at: "2026-06-25T19:00:00.000Z", references: { full: "example-group/example-project!14", short: "!14" }, head_pipeline: { status: "success" }, }; function mergeRequestWithPipeline(status = "success") { return { ...OPEN_MR, head_pipeline: { id: 306, status, web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", }, }; } const OPEN_ISSUE = { iid: 7, title: "Login button misaligned", web_url: "https://gitlab.example.com/example-group/example-project/-/issues/7", state: "opened", description: "On mobile the login button overflows", labels: ["bug"], updated_at: "2026-06-24T08:00:00.000Z", }; // Verbatim `glab issue list -O json` item (glab 1.105.0, gitlab.com). The list // endpoint returns far more than the neutral mapping needs, and `web_url` points // at `/-/work_items/`, not `/-/issues/`. const REAL_GLAB_ISSUE = { id: 193324690, iid: 1, external_id: "", state: "opened", description: "Simple test", health_status: "", author: { id: 13341367, state: "active", web_url: "https://gitlab.com/example-user", name: "example-user", username: "example-user", }, milestone: null, project_id: 83778606, assignees: [], updated_at: "2026-06-26T09:11:19.642Z", closed_at: null, title: "Test", created_at: "2026-06-26T09:11:19.642Z", labels: [], web_url: "https://gitlab.com/example-user/sample-repo/-/work_items/1", references: { short: "#1", relative: "#1", full: "example-user/sample-repo#1" }, confidential: false, issue_type: "issue", user_notes_count: 0, }; const NESTED_GROUP_MR = { ...OPEN_MR, iid: 73, web_url: "https://gitlab.example.com/example-group/nested/example-project/-/merge_requests/73", references: { full: "example-group/nested/example-project!73", short: "!73" }, }; const PIPELINE_WITH_JOBS = { id: 306, status: "failed", ref: "feat/sample-change", sha: "85e734528c160941f997703c63563d2587736a3e", web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", jobs: [ { id: 929, name: "lint", stage: "test", status: "success", allow_failure: false, web_url: "https://gitlab.example.com/example-group/example-project/-/jobs/929", duration: 12.3, }, { id: 931, name: "unit", stage: "test", status: "failed", allow_failure: false, web_url: "https://gitlab.example.com/example-group/example-project/-/jobs/931", duration: 38.2, }, { id: 932, name: "flaky", stage: "test", status: "failed", allow_failure: true, web_url: "https://gitlab.example.com/example-group/example-project/-/jobs/932", duration: 5, }, { id: 933, name: "deploy-prod", stage: "deploy", status: "skipped", allow_failure: false, web_url: "https://gitlab.example.com/example-group/example-project/-/jobs/933", duration: null, }, ], }; const APPROVALS = { approvals_required: 2, approvals_left: 1, approved_by: [{ user: { username: "reviewer-a" } }], }; // Mirrors `glab api projects/:id/merge_requests/:iid/discussions` (GitLab 16+). const DISCUSSIONS = [ { id: "sys-1", individual_note: true, notes: [ { id: 399, type: null, system: true, body: "enabled an automatic merge", created_at: "2026-06-25T19:24:04.180Z", author: { username: "claude", name: "Claude", web_url: "https://gl/claude" }, }, ], }, { id: "note-1", individual_note: true, notes: [ { id: 401, type: null, system: false, body: "Looks good to me", created_at: "2026-06-25T20:00:00.000Z", author: { username: "reviewer-a", name: "Reviewer A", web_url: "https://gl/reviewer-a", avatar_url: "https://gl/avatar-a.png", }, }, ], }, { id: "thread-1", individual_note: false, notes: [ { id: 402, type: "DiffNote", system: false, body: "This line needs a guard", created_at: "2026-06-25T19:55:00.000Z", resolvable: true, resolved: false, author: { username: "reviewer-b", web_url: "https://gl/reviewer-b" }, position: { new_path: "src/app.ts", old_path: "src/app.ts", new_line: 42, old_line: null }, }, ], }, ]; describe("createGitLabService", () => { it("requires native clone URL proof for mixed transport ports and isolates cached endpoints", async () => { const mr = { ...OPEN_MR, source_branch: "topic" }; let cloneUrl: string | undefined = "ssh://git@gitlab.example.com:2222/example-group/example-project.git"; const { service, calls } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify([mr])); if (args[1] === "view") return ok(JSON.stringify(mr)); if (args[1] === "projects/example-group%2Fexample-project") return ok( JSON.stringify({ id: 101, path_with_namespace: "example-group/example-project", ssh_url_to_repo: cloneUrl, }), ); return ok("{}"); }); const endpoint = { transport: "ssh" as const, host: "gitlab.example.com", port: "2222", path: "example-group/example-project", }; const input = { cwd: "/repo", headRef: "topic", headRepositoryPath: endpoint.path, headRepositoryEndpoint: endpoint, }; expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(14); expect( await service.getCurrentPullRequestStatus({ ...input, headRepositoryEndpoint: { ...endpoint, port: "2223" }, }), ).toBeNull(); expect( (await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" })) ?.number, ).toBe(14); cloneUrl = undefined; expect( await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }), ).toBeNull(); expect( calls.filter((args) => args[1] === "projects/example-group%2Fexample-project"), ).toHaveLength(3); }); it("finds the target fork without reading unrelated source projects", async () => { const unrelated = Array.from({ length: 4 }, (_, i) => ({ ...OPEN_MR, iid: i + 30, source_branch: "topic", source_project_id: i + 200, })); const wanted = { ...OPEN_MR, iid: 44, source_branch: "topic", source_project_id: 300 }; const { service, calls } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify([...unrelated, wanted])); if (args[1] === "view") return ok(JSON.stringify(wanted)); if (args[1] === "projects/team%2Ffork") return ok(JSON.stringify({ id: 300, path_with_namespace: "team/fork" })); if (args[0] === "api" && /^projects\/\d+$/.test(args[1])) return ok( JSON.stringify({ id: Number(args[1].split("/")[1]), path_with_namespace: "other/repo" }), ); return ok("{}"); }); expect( ( await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic", headRepositoryPath: "team/fork", }) )?.number, ).toBe(44); expect(calls.filter((args) => /^projects\/\d+$/.test(args[1] ?? ""))).toHaveLength(0); }); it("maps a glab merge request view to the neutral current PR status", async () => { const { service, calls } = makeService((args) => ok(JSON.stringify(args[1] === "list" ? [OPEN_MR] : OPEN_MR)), ); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0", }); expect(status).toMatchObject({ number: 14, url: "https://gitlab.example.com/example-group/example-project/-/merge_requests/14", title: "chore(release): 0.4.0", state: "open", baseRefName: "main", headRefName: "release/v0.4.0", isMerged: false, isDraft: false, mergeable: "MERGEABLE", checksStatus: "success", reviewDecision: null, repoOwner: "example-group", repoName: "example-project", projectPath: "example-group/example-project", }); expect(status?.forgeSpecific).toMatchObject({ forge: "gitlab", detailedMergeStatus: "mergeable", hasConflicts: false, pipelineStatus: "success", mergeWhenPipelineSucceeds: false, }); expect(calls[0]).toEqual(currentMrListArgs("release/v0.4.0")); expect(calls[1]).toEqual(["mr", "view", "14", "-F", "json"]); }); it("reports a conflicting merge request as CONFLICTING", async () => { const conflicting = { ...OPEN_MR, source_branch: "x", has_conflicts: true, detailed_merge_status: "broken_status", }; const { service } = makeService((args) => ok(JSON.stringify(args[1] === "list" ? [conflicting] : conflicting)), ); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "x" }); expect(status?.mergeable).toBe("CONFLICTING"); }); it("returns null when no merge request exists for the branch", async () => { const { service } = makeService(() => ok("[]")); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/x", }); expect(status).toBeNull(); }); it("selects the latest terminal merge request even when the checkout head differs", async () => { const branch = "dev"; const checkoutSha = "2222222222222222222222222222222222222222"; const newestStale = { ...OPEN_MR, iid: 271, state: "merged", source_branch: branch, sha: "1111111111111111111111111111111111111111", updated_at: "2026-07-17T12:00:00.000Z", }; const exactOlder = { ...OPEN_MR, iid: 270, state: "merged", source_branch: branch, sha: checkoutSha, updated_at: "2026-07-16T12:00:00.000Z", }; const { service, calls } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify([newestStale, exactOlder])); if (args[1] === "view" && args[2] === "271") return ok(JSON.stringify(newestStale)); if (args[0] === "api") return ok("{}"); throw new Error(`unexpected call: ${args.join(" ")}`); }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: branch, headSha: checkoutSha, }); expect(status?.number).toBe(271); expect(status?.headSha).toBe(newestStale.sha); expect(calls[1]).toEqual(["mr", "view", "271", "-F", "json"]); }); it.each(["opened", "merged"])( "matches a nested, renamed fork for a %s merge request", async (state) => { const wrong = { ...OPEN_MR, iid: 41, state, source_branch: "topic", source_project_id: 201 }; const right = { ...wrong, iid: 42, source_project_id: 202 }; const { service } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify([wrong, right])); if (args[1] === "view") return ok(JSON.stringify(right)); if (args[1] === "projects/team%2Fsubgroup%2Frenamed") return ok(JSON.stringify({ id: 202, path_with_namespace: "team/subgroup/renamed" })); if (args[1] === "projects/201") return ok(JSON.stringify({ path_with_namespace: "team/subgroup/other" })); if (args[1] === "projects/202") return ok(JSON.stringify({ path_with_namespace: "team/subgroup/renamed" })); if (args[0] === "api") return ok("{}"); throw new Error(`unexpected call: ${args.join(" ")}`); }); const result = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic", headRepositoryPath: "team/subgroup/renamed", }); expect(result?.number).toBe(42); }, ); it("resolves a checkout hint to the full fork project before selecting the current request", async () => { const hint = { ...OPEN_MR, iid: 41, source_branch: "topic", source_project_id: 202 }; const current = { ...hint, iid: 42, state: "opened" }; const { service } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify([current])); if (args[1] === "view") return ok(JSON.stringify(args[2] === "41" ? hint : current)); if (args[1] === "projects/202") return ok(JSON.stringify({ path_with_namespace: "team/subgroup/renamed" })); if (args[1] === "projects/team%2Fsubgroup%2Frenamed") return ok(JSON.stringify({ id: 202, path_with_namespace: "team/subgroup/renamed" })); if (args[0] === "api") return ok("{}"); throw new Error(`unexpected call: ${args.join(" ")}`); }); const result = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic", headRepositoryOwner: "team", changeRequestNumber: 41, }); expect(result?.number).toBe(42); }); it("prefers an open merge request over a more recently updated terminal request", async () => { const terminal = { ...OPEN_MR, iid: 50, state: "merged", source_branch: "topic", updated_at: "2026-08-01T00:00:00Z", }; const open = { ...OPEN_MR, iid: 49, state: "opened", source_branch: "topic", updated_at: "2026-07-01T00:00:00Z", }; const { service } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify([terminal, open])); if (args[1] === "view") return ok(JSON.stringify(open)); if (args[0] === "api") return ok("{}"); throw new Error(`unexpected call: ${args.join(" ")}`); }); expect( await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }), ).toMatchObject({ number: 49, state: "open" }); }); it("does not select a terminal request when the first page cannot establish whether an open one exists", async () => { const terminal = Array.from({ length: 100 }, (_, i) => ({ ...OPEN_MR, iid: i + 100, state: "merged", source_branch: "topic", })); const open = { ...OPEN_MR, iid: 99, state: "opened", source_branch: "topic" }; const { service, calls } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify(args.includes("--page") ? [open] : terminal)); if (args[1] === "view") return ok(JSON.stringify(open)); if (args[0] === "api") return ok("{}"); throw new Error(`unexpected call: ${args.join(" ")}`); }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }), ).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError); expect(calls.filter((args) => args[1] === "list")).toHaveLength(1); }); it("bounds source project identity reads when a branch is crowded", async () => { const requests = Array.from({ length: 4 }, (_, i) => ({ ...OPEN_MR, iid: i + 20, state: "merged", source_branch: "topic", source_project_id: i + 200, })); const { service, calls } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify(requests)); if (args[0] === "api" && args[1].startsWith("projects/")) return ok(JSON.stringify({ path_with_namespace: "elsewhere/repo" })); throw new Error(`unexpected call: ${args.join(" ")}`); }); expect( await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }), ).toBeNull(); expect( calls.filter((args) => args[0] === "api" && /^projects\/\d+$/.test(args[1])), ).toHaveLength(0); }); it("reuses a resolved source project identity on subsequent polls", async () => { const mr = { ...OPEN_MR, iid: 44, source_branch: "topic", source_project_id: 202 }; const { service, calls } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify([mr])); if (args[1] === "view") return ok(JSON.stringify(mr)); if (args[1] === "projects/team%2Ffork") return ok(JSON.stringify({ id: 202, path_with_namespace: "team/fork" })); if (args[0] === "api") return ok("{}"); throw new Error(`unexpected call: ${args.join(" ")}`); }); const input = { cwd: "/repo", headRef: "topic", headRepositoryPath: "team/fork" }; expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(44); expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(44); expect(calls.filter((args) => args[1] === "projects/team%2Ffork")).toHaveLength(1); await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }); expect(calls.filter((args) => args[1] === "projects/team%2Ffork")).toHaveLength(2); }); it("shows the latest historical merge request after a reused branch advances", async () => { const stale = { ...OPEN_MR, state: "merged", source_branch: "dev", sha: "1111111111111111111111111111111111111111", }; const { service, calls } = makeService((args) => { if (args[1] === "list") return ok(JSON.stringify([stale])); if (args[1] === "view") return ok(JSON.stringify(stale)); if (args[0] === "api") return ok("{}"); throw new Error(`unexpected call: ${args.join(" ")}`); }); await expect( service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "dev", headSha: "2222222222222222222222222222222222222222", }), ).resolves.toMatchObject({ number: stale.iid, state: "merged" }); expect(calls[0]).toEqual(currentMrListArgs("dev")); }); it("looks up a numeric current branch through the source-branch list filter", async () => { const numericBranchMr = { ...OPEN_MR, iid: 21, source_branch: "1234", title: "Fix numeric branch", web_url: "https://gitlab.example.com/example-group/example-project/-/merge_requests/21", references: { full: "example-group/example-project!21", short: "!21" }, }; const { service, calls } = makeService((args) => { if (args[0] === "mr" && args[1] === "list") { return ok(JSON.stringify([numericBranchMr])); } if (args[0] === "mr" && args[1] === "view" && args[2] === "21") { return ok(JSON.stringify(numericBranchMr)); } if (args[0] === "mr" && args[1] === "view" && args[2] === "1234") { throw new Error("numeric branch must not be viewed as an iid"); } if (args[0] === "api" && args[1].endsWith("/approvals")) { return ok("{}"); } throw new Error(`unexpected call: ${args.join(" ")}`); }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "1234", headSha: "2222222222222222222222222222222222222222", }); expect(status).toMatchObject({ number: 21, title: "Fix numeric branch", headRefName: "1234", }); expect(calls[0]).toEqual(currentMrListArgs("1234")); expect(calls[1]).toEqual(["mr", "view", "21", "-F", "json"]); expect(calls).not.toContainEqual(["mr", "view", "1234", "-F", "json"]); }); it("returns null when a numeric current branch has no open merge request", async () => { const { service, calls } = makeService((args) => { if (args[0] === "mr" && args[1] === "list") { return ok("[]"); } if (args[0] === "mr" && args[1] === "view" && args[2] === "1234") { throw new Error("numeric branch must not be viewed as an iid"); } throw new Error(`unexpected call: ${args.join(" ")}`); }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "1234", }); expect(status).toBeNull(); expect(calls).toEqual([currentMrListArgs("1234")]); }); it("lists merge requests as neutral PR summaries", async () => { const { service, calls } = makeService(() => ok(JSON.stringify([OPEN_MR]))); const list = await service.listPullRequests({ cwd: "/repo", limit: 5 }); expect(list).toHaveLength(1); expect(list[0]).toMatchObject({ number: 14, title: "chore(release): 0.4.0", state: "open" }); expect(calls[0]).toEqual(["mr", "list", "-F", "json", "-P", "5"]); }); it("resolves the glab CLI path once per service instance, not per invocation", async () => { let resolveCalls = 0; const { service } = makeService(() => ok(JSON.stringify([OPEN_MR])), { resolveGlabPath: async () => { resolveCalls += 1; return "/usr/bin/glab"; }, }); await service.listPullRequests({ cwd: "/repo", limit: 5 }); await service.listPullRequests({ cwd: "/repo", limit: 5 }); expect(resolveCalls).toBe(1); }); it("maps a same-repo merge request view to a checkout target", async () => { const { service, calls } = makeService(() => ok(JSON.stringify(OPEN_MR))); await expect( service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }), ).resolves.toEqual({ number: 14, baseRefName: "main", headRefName: "release/v0.4.0", checkoutRefs: [ { remoteName: "origin", remoteRef: "refs/merge-requests/14/head" }, { remoteName: "origin", remoteRef: "refs/heads/release/v0.4.0" }, ], headOwnerLogin: null, headRepositorySshUrl: null, headRepositoryUrl: null, isCrossRepository: false, headRefKind: "branch", }); expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]); }); it("marks fork merge request checkout targets as cross-repository", async () => { const { service } = makeService(() => ok( JSON.stringify({ ...OPEN_MR, source_project_id: 202, target_project_id: 101, }), ), ); await expect( service.getPullRequestCheckoutTarget?.({ cwd: "/repo", number: 14 }), ).resolves.toMatchObject({ number: 14, headRefName: "release/v0.4.0", isCrossRepository: true, headOwnerLogin: null, headRepositorySshUrl: null, headRepositoryUrl: null, }); }); it("creates a merge request and parses the URL and iid from glab output", async () => { const { service, calls } = makeService(() => ok( "Creating merge request for release/v0.4.0 into main\n" + "https://gitlab.example.com/example-group/example-project/-/merge_requests/15\n", ), ); const result = await service.createPullRequest({ cwd: "/repo", repo: "example-group/example-project", title: "Ship it", head: "release/v0.4.0", base: "main", body: "Body", }); expect(result).toEqual({ url: "https://gitlab.example.com/example-group/example-project/-/merge_requests/15", number: 15, }); expect(calls[0]).toEqual([ "mr", "create", "--title", "Ship it", "--description", "Body", "--source-branch", "release/v0.4.0", "--target-branch", "main", "--yes", ]); }); it("merges with the requested strategy when GitLab reports the MR as mergeable", async () => { const { service, calls } = makeService(() => ok("")); const result = await service.mergePullRequest({ cwd: "/repo", prNumber: 14, mergeMethod: "squash", status: { forgeSpecific: { forge: "gitlab", detailedMergeStatus: "mergeable", hasConflicts: false, blockingDiscussionsResolved: true, approvalsRequired: 0, approvalsGiven: 0, pipelineStatus: "success", pipelineId: null, pipelineUrl: null, mergeWhenPipelineSucceeds: false, }, }, }); expect(result).toEqual({ success: true }); expect(calls[0]).toEqual(["mr", "merge", "14", "--auto-merge=false", "--yes", "--squash"]); }); it("refuses a direct merge when GitLab does not report the MR as mergeable", async () => { const { service, calls } = makeService(() => ok("")); await expect( service.mergePullRequest({ cwd: "/repo", prNumber: 14, mergeMethod: "merge", status: { forgeSpecific: { forge: "gitlab", detailedMergeStatus: "ci_still_running", hasConflicts: false, blockingDiscussionsResolved: true, approvalsRequired: 0, approvalsGiven: 0, pipelineStatus: "running", pipelineId: null, pipelineUrl: null, mergeWhenPipelineSucceeds: false, }, }, }), ).rejects.toThrow(/ready for direct merge/); expect(calls).toHaveLength(0); }); it("enables auto-merge by scheduling merge when the pipeline succeeds", async () => { const { service, calls } = makeService(() => ok("")); const result = await service.enablePullRequestAutoMerge({ cwd: "/repo", prNumber: 14, mergeMethod: "squash", status: gitlabAutoMergeStatus(), }); expect(result).toEqual({ success: true }); expect(calls[0]).toEqual(["mr", "merge", "14", "--auto-merge", "--yes", "--squash"]); }); it("enables auto-merge without a strategy flag for the plain merge method", async () => { const { service, calls } = makeService(() => ok("")); await service.enablePullRequestAutoMerge({ cwd: "/repo", prNumber: 14, mergeMethod: "merge", status: gitlabAutoMergeStatus(), }); expect(calls[0]).toEqual(["mr", "merge", "14", "--auto-merge", "--yes"]); }); it("refuses to enable auto-merge without an active pipeline because it would merge immediately", async () => { const { service, calls } = makeService(() => ok("")); await expect( service.enablePullRequestAutoMerge({ cwd: "/repo", prNumber: 14, mergeMethod: "squash", status: gitlabAutoMergeStatus({ pipelineStatus: "success" }), }), ).rejects.toThrow(/in-progress pipeline/); expect(calls).toHaveLength(0); }); it("disables auto-merge by cancelling the scheduled merge via the API", async () => { const { service, calls } = makeService(() => ok("")); const result = await service.disablePullRequestAutoMerge({ cwd: "/repo", prNumber: 14, }); expect(result).toEqual({ success: true }); expect(calls[0]).toEqual([ "api", "--method", "POST", "projects/:fullpath/merge_requests/14/cancel_merge_when_pipeline_succeeds", ]); }); it("surfaces the head pipeline id and url on the gitlab status facts", async () => { const pipelineMr = mergeRequestWithPipeline("canceling"); const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([pipelineMr])); if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(pipelineMr)); if (args[0] === "ci" && args[1] === "get") return ok(JSON.stringify(PIPELINE_WITH_JOBS)); return ok("{}"); }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0", }); // checksStatus follows the pipeline whose jobs are listed (the fetched // one, status "failed"), while the facts keep head_pipeline's raw status. expect(status?.checksStatus).toBe("failure"); expect(status?.forgeSpecific).toMatchObject({ forge: "gitlab", pipelineStatus: "canceling", pipelineId: 306, pipelineUrl: "https://gitlab.example.com/example-group/example-project/-/pipelines/306", }); }); it("populates sidebar checks from the merge request head pipeline", async () => { const pipelineMr = mergeRequestWithPipeline(); const pipeline = { ...PIPELINE_WITH_JOBS, status: "success", jobs: [ PIPELINE_WITH_JOBS.jobs[0], PIPELINE_WITH_JOBS.jobs[2], { id: 934, name: "optional-deploy", stage: "deploy", status: "manual", allow_failure: true, web_url: "https://gitlab.example.com/example-group/example-project/-/jobs/934", }, { id: 935, name: "release", stage: "deploy", status: "manual", allow_failure: false, web_url: "https://gitlab.example.com/example-group/example-project/-/jobs/935", }, ], }; const { service, calls } = makeService((args) => { if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([pipelineMr])); if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(pipelineMr)); if (args[0] === "api" && args[1].endsWith("/approvals")) return ok("{}"); if (args[0] === "ci" && args[1] === "get") return ok(JSON.stringify(pipeline)); throw new Error(`unexpected call: ${args.join(" ")}`); }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0", }); expect(calls[3]).toEqual([ "ci", "get", "--merge-request", "14", "--with-job-details", "-F", "json", ]); expect(status?.checksStatus).toBe("success"); expect(status?.checks).toEqual([ { name: "lint", status: "success", url: "https://gitlab.example.com/example-group/example-project/-/jobs/929", workflow: "test", checkRunId: 929, }, { name: "flaky", status: "success", traits: ["warning"], url: "https://gitlab.example.com/example-group/example-project/-/jobs/932", workflow: "test", checkRunId: 932, }, { name: "optional-deploy", status: "skipped", traits: ["manual"], url: "https://gitlab.example.com/example-group/example-project/-/jobs/934", workflow: "deploy", checkRunId: 934, }, { name: "release", status: "pending", traits: ["manual", "action_required"], url: "https://gitlab.example.com/example-group/example-project/-/jobs/935", workflow: "deploy", checkRunId: 935, }, ]); }); it.each([ [ "unavailable", () => { throw { code: 1, stderr: "pipeline details unavailable" }; }, ], ["malformed", () => ok(JSON.stringify({ jobs: "invalid" }))], ] as const)( "keeps the merge request status when pipeline job details are %s", async (_, load) => { const pipelineMr = mergeRequestWithPipeline(); const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([pipelineMr])); if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(pipelineMr)); if (args[0] === "api" && args[1].endsWith("/approvals")) return ok("{}"); if (args[0] === "ci" && args[1] === "get") return load(); throw new Error(`unexpected call: ${args.join(" ")}`); }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0", }); expect(status).toMatchObject({ number: 14, checks: [], checksStatus: "success" }); }, ); it("keeps cancellation transitions active", () => { expect(GITLAB_ACTIVE_PIPELINE_STATUS_SET.has("canceling")).toBe(true); }); it("fetches a pipeline's stages and jobs as neutral check details", async () => { const { service, calls } = makeService(() => ok(JSON.stringify(PIPELINE_WITH_JOBS))); const details = await service.getCheckDetails({ cwd: "/repo", checkRunId: 306, }); expect(calls[0]).toEqual([ "ci", "get", "--pipeline-id", "306", "--with-job-details", "-F", "json", ]); expect(details).toMatchObject({ checkRunId: 306, name: "Pipeline (feat/sample-change)", failedJobs: [], annotations: [], truncated: false, }); expect(details.pipeline).toMatchObject({ id: 306, status: "failed", rawStatus: "failed", ref: "feat/sample-change", stages: [ { name: "test", status: "failed", jobs: [ { id: 929, name: "lint" }, { id: 931, name: "unit", status: "failed", allowFailure: false, durationSeconds: 38.2, }, { id: 932, name: "flaky", status: "failed", allowFailure: true }, ], }, { name: "deploy", status: "skipped", jobs: [{ id: 933, name: "deploy-prod", durationSeconds: null }], }, ], }); }); it("addresses the change request's head pipeline by iid (fork/detached safe)", async () => { const { service, calls } = makeService(() => ok(JSON.stringify(PIPELINE_WITH_JOBS))); await service.getCheckDetails({ cwd: "/repo", checkRunId: 306, changeRequestNumber: 14, }); expect(calls[0]).toEqual([ "ci", "get", "--merge-request", "14", "--with-job-details", "-F", "json", ]); }); it.each([ ["allowed failure", "success", "failed", true, "success", "success", "failed"], ["optional manual", "success", "manual", true, "success", "success", "manual"], ["blocking manual", "manual", "manual", false, "manual", "manual", "manual"], ["cancellation transition", "canceling", "canceling", false, "pending", "pending", "pending"], ] as const)( "maps %s without distorting the pipeline result", async ( _case, pipelineStatus, jobStatus, allowFailure, expectedPipeline, expectedStage, expectedJob, ) => { const { service } = makeService(() => ok( JSON.stringify({ ...PIPELINE_WITH_JOBS, status: pipelineStatus, jobs: [ { id: 940, name: "build", stage: "test", status: "success", allow_failure: false, }, { id: 941, name: "subject", stage: "test", status: jobStatus, allow_failure: allowFailure, }, ], }), ), ); const details = await service.getCheckDetails({ cwd: "/repo", checkRunId: 306 }); expect(details.pipeline).toMatchObject({ status: expectedPipeline, rawStatus: pipelineStatus, stages: [{ status: expectedStage }], }); expect(details.pipeline?.stages[0]?.jobs[1]).toMatchObject({ status: expectedJob, rawStatus: jobStatus, allowFailure, }); }, ); it("populates approval counts from the approvals endpoint", async () => { const { service, calls } = makeService((args) => { if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([OPEN_MR])); if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR)); if (args[0] === "api" && args[1].endsWith("/approvals")) return ok(JSON.stringify(APPROVALS)); throw new Error(`unexpected call: ${args.join(" ")}`); }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0", }); expect(status?.forgeSpecific).toMatchObject({ forge: "gitlab", approvalsRequired: 2, approvalsGiven: 1, }); expect(calls[2]).toEqual([ "api", "projects/example-group%2Fexample-project/merge_requests/14/approvals", ]); }); it("falls back to zero approvals when the approvals endpoint returns an error", async () => { const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([OPEN_MR])); if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR)); throw { code: 1, stderr: "500 Internal Server Error" }; }); const status = await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0", }); expect(status?.number).toBe(14); expect(status?.forgeSpecific).toMatchObject({ forge: "gitlab", approvalsRequired: 0, approvalsGiven: 0, }); }); it("maps MR discussions to a neutral timeline, dropping system notes", async () => { const { service, calls } = makeService((args) => { if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(NESTED_GROUP_MR)); if (args[0] === "api" && args[1].includes("/discussions")) return ok(JSON.stringify(DISCUSSIONS)); throw new Error(`unexpected call: ${args.join(" ")}`); }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 14, repoOwner: "example-group", repoName: "example-project", }); expect(calls[1]).toEqual([ "api", "projects/example-group%2Fnested%2Fexample-project/merge_requests/73/discussions?per_page=100", ]); expect(timeline.error).toBeNull(); expect(timeline.truncated).toBe(false); // System note 399 is dropped; the diff note (19:55) sorts before the comment (20:00). expect(timeline.items.map((item) => item.id)).toEqual(["402", "401"]); const [diffNote, comment] = timeline.items; expect(diffNote).toMatchObject({ kind: "comment", id: "402", author: "reviewer-b", url: "https://gitlab.example.com/example-group/nested/example-project/-/merge_requests/73#note_402", location: { path: "src/app.ts", line: 42, threadId: "thread-1", isResolved: false }, }); expect(comment).toMatchObject({ kind: "comment", id: "401", author: "reviewer-a", authorUrl: "https://gl/reviewer-a", avatarUrl: "https://gl/avatar-a.png", body: "Looks good to me", }); expect(comment).not.toHaveProperty("location"); }); it("groups general (non-file) discussion replies under one top-level thread id", async () => { const discussions = [ { id: "disc-general", individual_note: false, notes: [ { id: 501, system: false, body: "Can you clarify the rollout plan?", created_at: "2026-06-25T20:00:00.000Z", author: { username: "reviewer-a" }, }, { id: 502, system: false, body: "Sure, staged behind a flag.", created_at: "2026-06-25T20:05:00.000Z", author: { username: "author-b" }, }, ], }, { id: "disc-standalone", individual_note: true, notes: [ { id: 503, system: false, body: "Nice work overall.", created_at: "2026-06-25T20:10:00.000Z", author: { username: "reviewer-c" }, }, ], }, ]; const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(NESTED_GROUP_MR)); if (args[0] === "api" && args[1].includes("/discussions")) return ok(JSON.stringify(discussions)); throw new Error(`unexpected call: ${args.join(" ")}`); }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 14, repoOwner: "example-group", repoName: "example-project", }); const byId = new Map(timeline.items.map((item) => [item.id, item])); expect(byId.get("501")).toMatchObject({ kind: "comment", threadId: "disc-general" }); expect(byId.get("502")).toMatchObject({ kind: "comment", threadId: "disc-general" }); expect(byId.get("501")).not.toHaveProperty("location"); // A standalone (individual) note must not be turned into a thread. expect(byId.get("503")).not.toHaveProperty("threadId"); }); it("maps general resolvable discussion resolution to threadIsResolved", async () => { const discussions = [ { id: "disc-unresolved", individual_note: false, notes: [ { id: 511, system: false, body: "Still open question.", created_at: "2026-06-25T20:00:00.000Z", author: { username: "reviewer-a" }, resolvable: true, resolved: false, }, ], }, { id: "disc-resolved", individual_note: false, notes: [ { id: 512, system: false, body: "Addressed, thanks.", created_at: "2026-06-25T20:05:00.000Z", author: { username: "author-b" }, resolvable: true, resolved: true, }, ], }, { id: "disc-plain", individual_note: true, notes: [ { id: 513, system: false, body: "Just a plain comment.", created_at: "2026-06-25T20:10:00.000Z", author: { username: "reviewer-c" }, }, ], }, ]; const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(NESTED_GROUP_MR)); if (args[0] === "api" && args[1].includes("/discussions")) return ok(JSON.stringify(discussions)); throw new Error(`unexpected call: ${args.join(" ")}`); }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 14, repoOwner: "example-group", repoName: "example-project", }); const byId = new Map(timeline.items.map((item) => [item.id, item])); expect(byId.get("511")).toMatchObject({ threadIsResolved: false }); expect(byId.get("512")).toMatchObject({ threadIsResolved: true }); // A non-resolvable plain comment must not gain a resolution state. expect(byId.get("513")).not.toHaveProperty("threadIsResolved"); // General discussions carry no file position, so no location either. expect(byId.get("511")).not.toHaveProperty("location"); }); it("maps a multiline diff range to startLine and omits resolution state for non-resolvable notes", async () => { const discussions = [ { id: "disc-range", individual_note: false, notes: [ { id: 601, system: false, body: "This block spans several lines.", created_at: "2026-06-25T21:00:00.000Z", author: { username: "reviewer-a" }, position: { new_path: "src/app.ts", old_path: "src/app.ts", new_line: 48, old_line: null, line_range: { start: { new_line: 42, old_line: null }, end: { new_line: 48, old_line: null }, }, }, }, ], }, { id: "disc-plain", individual_note: true, notes: [ { id: 602, system: false, body: "General comment without a resolvable flag.", created_at: "2026-06-25T21:05:00.000Z", author: { username: "reviewer-b" }, }, ], }, ]; const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(NESTED_GROUP_MR)); if (args[0] === "api" && args[1].includes("/discussions")) return ok(JSON.stringify(discussions)); throw new Error(`unexpected call: ${args.join(" ")}`); }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 14, repoOwner: "example-group", repoName: "example-project", }); const rangeNote = timeline.items.find((item) => item.id === "601"); expect(rangeNote).toMatchObject({ kind: "comment", location: { path: "src/app.ts", line: 48, startLine: 42 }, }); expect(rangeNote && "location" in rangeNote ? rangeNote.location : null).not.toHaveProperty( "isResolved", ); const plainNote = timeline.items.find((item) => item.id === "602"); expect(plainNote).not.toHaveProperty("location"); }); it("returns a not_found timeline error when discussions cannot be fetched", async () => { const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR)); throw { code: 1, stderr: "404 Merge request not found" }; }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 14, repoOwner: "example-group", repoName: "example-project", }); expect(timeline.items).toEqual([]); expect(timeline.error).toMatchObject({ kind: "not_found" }); }); it.each(["403 Forbidden", "401 Unauthorized"])( "returns a forbidden timeline error for %s", async (stderr) => { const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR)); throw { code: 1, stderr }; }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 14, repoOwner: "example-group", repoName: "example-project", }); expect(timeline).toMatchObject({ items: [], truncated: false, error: { kind: "forbidden" }, }); }, ); it("flags truncation when a next-page probe finds more discussions", async () => { const firstPage = Array.from({ length: 100 }, (_, index) => ({ id: `discussion-${index}`, notes: [], })); const { service, calls } = makeService((args) => { if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR)); // The next-page probe asks for page 101; reply with one more discussion. if (args[1].includes("page=101")) return ok(JSON.stringify([{ id: "overflow", notes: [] }])); return ok(JSON.stringify(firstPage)); }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 14, repoOwner: "example-group", repoName: "example-project", }); expect(calls.some((call) => call[1]?.includes("per_page=1&page=101"))).toBe(true); expect(timeline).toMatchObject({ items: [], truncated: true, error: null }); }); it("does not flag truncation when exactly one full page of discussions exists", async () => { const firstPage = Array.from({ length: 100 }, (_, index) => ({ id: `discussion-${index}`, notes: [], })); const { service } = makeService((args) => { if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(OPEN_MR)); // The probe of page 101 comes back empty: there is no 101st discussion. if (args[1].includes("page=101")) return ok(JSON.stringify([])); return ok(JSON.stringify(firstPage)); }); const timeline = await service.getPullRequestTimeline({ cwd: "/repo", prNumber: 14, repoOwner: "example-group", repoName: "example-project", }); expect(timeline).toMatchObject({ truncated: false, error: null }); }); it("reports authentication via a host-scoped glab auth status", async () => { const { service, calls } = makeService((args) => { if (args[0] === "auth") return ok(""); throw new Error("unexpected"); }); await expect(service.isAuthenticated({ cwd: "/repo" })).resolves.toBe(true); expect(calls[0]).toEqual(["auth", "status", "--hostname", "gitlab.example.com"]); }); it("reports unauthenticated when glab auth status fails", async () => { const { service } = makeService(() => { throw { code: 1, stderr: "401 Unauthorized" }; }); await expect(service.isAuthenticated({ cwd: "/repo" })).resolves.toBe(false); }); it("reports unauthenticated when the cwd has no GitLab remote", async () => { const { service, calls } = makeService(() => ok(""), { resolveRemoteUrl: async () => null }); await expect(service.isAuthenticated({ cwd: "/repo" })).resolves.toBe(false); expect(calls).toHaveLength(0); }); it("throws GlabCliMissingError when glab is not installed", async () => { const { service } = makeService(() => ok("{}"), { resolveGlabPath: async () => null }); await expect(service.getPullRequest({ cwd: "/repo", number: 1 })).rejects.toBeInstanceOf( GlabCliMissingError, ); }); it("normalizes glab auth failures into GlabAuthenticationError", async () => { const { service } = makeService(() => { throw { code: 1, stderr: "error: 401 Unauthorized — not logged in" }; }); await expect(service.getPullRequest({ cwd: "/repo", number: 1 })).rejects.toBeInstanceOf( GlabAuthenticationError, ); }); it("surfaces non-JSON glab stdout as a GlabCommandError", async () => { const { service } = makeService(() => ok("not json at all")); await expect(service.getPullRequest({ cwd: "/repo", number: 1 })).rejects.toBeInstanceOf( GlabCommandError, ); }); it("surfaces schema-mismatched glab JSON as a GlabCommandError", async () => { const { service } = makeService(() => ok(JSON.stringify({ unexpected: true }))); await expect(service.getPullRequest({ cwd: "/repo", number: 1 })).rejects.toBeInstanceOf( GlabCommandError, ); }); it("finds a merge request by number even when its title and description omit it", async () => { const { service } = makeService((args) => { // GitLab text search does not search the IID. Model both API behaviors. if (args.includes("--search")) return ok("[]"); if (args[0] === "api" && args[1].includes("iids[]=14")) { return ok(JSON.stringify([OPEN_MR])); } throw new Error(`unexpected glab args: ${args.join(" ")}`); }); const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: " 14 ", kinds: ["change_request"], }); expect(result.items).toEqual([ expect.objectContaining({ kind: "change_request", number: 14, title: OPEN_MR.title, headRefName: OPEN_MR.source_branch, baseRefName: OPEN_MR.target_branch, }), ]); }); it("finds issues by number through the same search interface", async () => { const { service } = makeService((args) => { if (args.includes("--search")) return ok("[]"); if (args[0] === "api" && args[1].includes("iids[]=7")) { return ok(JSON.stringify([OPEN_ISSUE])); } throw new Error(`unexpected glab args: ${args.join(" ")}`); }); const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: "7", kinds: ["issue"], }); expect(result.items).toEqual([ expect.objectContaining({ kind: "issue", number: 7, title: OPEN_ISSUE.title }), ]); }); it.each([ { kind: "change_request" as const, item: OPEN_MR }, { kind: "issue" as const, item: OPEN_ISSUE }, ])( "preserves numeric text matches after the exact $kind number, without duplicates", async ({ kind, item }) => { const textMatches = [ { ...item, iid: 90, title: `References ${item.iid}`, updated_at: "2026-07-01T00:00:00.000Z", }, item, { ...item, iid: 91, description: `References ${item.iid}`, updated_at: "2026-06-30T00:00:00.000Z", }, ]; const { service } = makeService((args) => { if (args.includes("--search")) return ok(JSON.stringify(textMatches)); if (args[0] === "api") return ok(JSON.stringify([item])); throw new Error(`unexpected glab args: ${args.join(" ")}`); }); const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: String(item.iid), limit: 2, kinds: [kind], }); expect( result.items.map((resultItem) => ({ kind: resultItem.kind, number: resultItem.number })), ).toEqual([ { kind, number: item.iid }, { kind, number: 90 }, ]); }, ); it.each([ { kind: "change_request" as const, item: OPEN_MR }, { kind: "issue" as const, item: OPEN_ISSUE }, ])("keeps $kind text matches when the queried number is not open", async ({ kind, item }) => { const { service } = makeService((args) => { if (args.includes("--search")) { return ok(JSON.stringify([{ ...item, description: "References 2024" }])); } if (args[0] === "api") return ok("[]"); throw new Error(`unexpected glab args: ${args.join(" ")}`); }); const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: "2024", kinds: [kind], }); expect(result.items.map(({ number }) => number)).toEqual([item.iid]); }); it("keeps closed and missing merge requests out of number search results", async () => { const closedMr = { ...OPEN_MR, state: "closed" }; const { service } = makeService((args) => { if (args.includes("--search")) return ok("[]"); if (args[0] !== "api") throw new Error(`unexpected glab args: ${args.join(" ")}`); const endpoint = new URL(args[1], "https://gitlab.example.com/api/v4/"); const matches = endpoint.searchParams.get("iids[]") === String(closedMr.iid) && endpoint.searchParams.get("state") !== "opened"; return ok(JSON.stringify(matches ? [closedMr] : [])); }); for (const query of ["14", "999999"]) { const result = await service.searchIssuesAndPrs({ cwd: "/repo", query, limit: 10, kinds: ["change_request"], }); expect(result.authState).toBe("authenticated"); expect(result.items).toEqual([]); } }); it("searches issues and merge requests and maps them to neutral results", async () => { const { service, calls } = makeService((args) => { if (args[0] === "issue") return ok(JSON.stringify([OPEN_ISSUE])); if (args[0] === "mr") return ok(JSON.stringify([OPEN_MR])); throw new Error(`unexpected glab args: ${args.join(" ")}`); }); const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: "login", limit: 10 }); expect(result.featuresEnabled).toBe(true); expect(result.authState).toBe("authenticated"); expect(result.githubFeaturesEnabled).toBe(true); expect(result.items).toEqual([ { kind: "change_request", number: 14, title: "chore(release): 0.4.0", url: "https://gitlab.example.com/example-group/example-project/-/merge_requests/14", state: "open", body: "Release notes", labels: ["release"], projectPath: "example-group/example-project", baseRefName: "main", headRefName: "release/v0.4.0", updatedAt: "2026-06-25T19:00:00.000Z", }, { kind: "issue", number: 7, title: "Login button misaligned", url: "https://gitlab.example.com/example-group/example-project/-/issues/7", state: "opened", body: "On mobile the login button overflows", labels: ["bug"], baseRefName: null, headRefName: null, updatedAt: "2026-06-24T08:00:00.000Z", }, ]); expect(calls.find((args) => args[0] === "mr")).toEqual([ "mr", "list", "-F", "json", "--search", "login", "-P", "10", ]); expect(calls.find((args) => args[0] === "issue")).toEqual([ "issue", "list", "-O", "json", "--search", "login", "-P", "10", ]); }); it("parses the real glab issue list payload shape and uses the issue JSON flag", async () => { const { service, calls } = makeService((args) => { if (args[0] === "issue") return ok(JSON.stringify([REAL_GLAB_ISSUE])); if (args[0] === "mr") return ok("[]"); throw new Error(`unexpected glab args: ${args.join(" ")}`); }); const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: "" }); expect(result).toEqual({ featuresEnabled: true, authState: "authenticated", githubFeaturesEnabled: true, items: [ { kind: "issue", number: 1, title: "Test", url: "https://gitlab.com/example-user/sample-repo/-/work_items/1", state: "opened", body: "Simple test", labels: [], projectPath: "example-user/sample-repo", baseRefName: null, headRefName: null, updatedAt: "2026-06-26T09:11:19.642Z", }, ], }); expect(calls.find((args) => args[0] === "issue")).toEqual(["issue", "list", "-O", "json"]); }); it("restricts search to merge requests when only the PR kind is requested", async () => { const { service, calls } = makeService((args) => { if (args[0] === "mr") return ok(JSON.stringify([OPEN_MR])); throw new Error(`unexpected glab args: ${args.join(" ")}`); }); const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: "release", kinds: ["github-pr"], }); expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ kind: "change_request", number: 14 }); expect(calls).toEqual([["mr", "list", "-F", "json", "--search", "release"]]); }); it("reports forge features disabled when glab is unavailable or unauthenticated", async () => { const missing = makeService(() => ok("[]"), { resolveGlabPath: async () => null }).service; await expect(missing.searchIssuesAndPrs({ cwd: "/repo", query: "x" })).resolves.toEqual({ items: [], featuresEnabled: false, authState: "cli_missing", githubFeaturesEnabled: false, }); const unauthenticated = makeService(() => { throw { code: 1, stderr: "401 Unauthorized" }; }).service; await expect(unauthenticated.searchIssuesAndPrs({ cwd: "/repo", query: "x" })).resolves.toEqual( { items: [], featuresEnabled: false, authState: "unauthenticated", githubFeaturesEnabled: false, }, ); }); it("rejects search when one requested kind fails for a non-auth reason", async () => { const { service } = makeService((args) => { if (args[0] === "issue") { throw { code: 1, stderr: "temporary GitLab API failure" }; } return ok(JSON.stringify([OPEN_MR])); }); await expect(service.searchIssuesAndPrs({ cwd: "/repo", query: "release" })).rejects.toThrow( GlabCommandError, ); }); });