1325 lines
41 KiB
TypeScript
1325 lines
41 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { PullRequestCommandStatus } from "./forge-service.js";
|
|
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<GlabCommandResult>;
|
|
|
|
function ok(stdout: string): GlabCommandResult {
|
|
return { stdout, stderr: "" };
|
|
}
|
|
|
|
function makeService(responder: Responder, overrides: Partial<CreateGitLabServiceOptions> = {}) {
|
|
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<GitLabStatusFacts> = {},
|
|
): 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" },
|
|
};
|
|
|
|
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/<iid>`, not `/-/issues/<iid>`.
|
|
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("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 a terminal merge request only when its head SHA matches the checkout", 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] === "270") return ok(JSON.stringify(exactOlder));
|
|
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(270);
|
|
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
|
|
});
|
|
|
|
it("does not attach 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(() => ok(JSON.stringify([stale])));
|
|
|
|
await expect(
|
|
service.getCurrentPullRequestStatus({
|
|
cwd: "/repo",
|
|
headRef: "dev",
|
|
headSha: "2222222222222222222222222222222222222222",
|
|
}),
|
|
).resolves.toBeNull();
|
|
expect(calls).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,
|
|
});
|
|
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 = {
|
|
...OPEN_MR,
|
|
head_pipeline: {
|
|
id: 306,
|
|
status: "running",
|
|
web_url: "https://gitlab.example.com/example-group/example-project/-/pipelines/306",
|
|
},
|
|
};
|
|
const { service } = makeService((args) =>
|
|
ok(JSON.stringify(args[1] === "list" ? [pipelineMr] : pipelineMr)),
|
|
);
|
|
|
|
const status = await service.getCurrentPullRequestStatus({
|
|
cwd: "/repo",
|
|
headRef: "release/v0.4.0",
|
|
});
|
|
|
|
expect(status?.checksStatus).toBe("pending");
|
|
expect(status?.forgeSpecific).toMatchObject({
|
|
forge: "gitlab",
|
|
pipelineStatus: "running",
|
|
pipelineId: 306,
|
|
pipelineUrl: "https://gitlab.example.com/example-group/example-project/-/pipelines/306",
|
|
});
|
|
});
|
|
|
|
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("does not fail a stage when only allow_failure jobs failed", async () => {
|
|
const { service } = makeService(() =>
|
|
ok(
|
|
JSON.stringify({
|
|
...PIPELINE_WITH_JOBS,
|
|
status: "success",
|
|
jobs: [
|
|
{
|
|
id: 940,
|
|
name: "lint",
|
|
stage: "test",
|
|
status: "success",
|
|
allow_failure: false,
|
|
},
|
|
{
|
|
id: 941,
|
|
name: "optional",
|
|
stage: "test",
|
|
status: "failed",
|
|
allow_failure: true,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
const details = await service.getCheckDetails({ cwd: "/repo", checkRunId: 306 });
|
|
expect(details.pipeline?.stages[0]?.status).toBe("success");
|
|
});
|
|
|
|
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("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,
|
|
);
|
|
});
|
|
});
|