mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
* fix(gitea): prefix fork PR branches with the owner Two forks with the same branch name (say patch-1) ended up on the same local branch. The gitea service had no buildPrLocalBranchName or defaultCheckoutRefs, github has both. getPullRequestCheckoutTarget also fell back to refs/heads/<name> on origin for fork PRs. Thats the base repo's branch, not the fork's, so a failed refs/pull fetch checked out the wrong branch without an error. fixes #3515 * refactor(forge): share fork branch naming with github moved the owner normalizer and the branch name builder to utils/change-request-checkout.ts, gitea and github both use it. github changes a bit: a fork PR with an unknown or unusable owner (eg deleted fork) now gets pr-N/<branch> instead of the bare branch name, which could collide with a local branch the same way #3515 did. * fix(gitea): PRs from a deleted fork are still cross repo the API drops head.repo once the fork is gone, we read that as a same repo PR and fetched refs/heads/<name> from origin * fix(gitea): handle AGit PRs and deleted head branches neither has a branch on origin. forgejo reports refs/pull/N/head as head.ref for both, so skip the refs/heads fallback and dont track origin. also try refs/pull/N/head on upstream for clones where origin is your fork, like github does. that list lives in the shared util now. * test: resolver harness uses the real branch helper plus deleted forks and same repo PRs without an origin branch * fix(gitea): collapse pull-ref-only heads to pr-N in local branch names buildForkLocalBranchName still used the raw headRef even when it was just the pull ref (refs/pull/N/head), which agit prs and deleted head branches report as their headRefName. that gave local branches literal names like refs/heads/refs/pull/3/head. * fix(gitea): surface hasHeadBranch on the pr checkout target resolveForkCheckoutFacts already knew whether a pr had a real head branch (agit flow, or head.ref == the pull ref both mean no). now that signal reaches buildPrLocalBranchName so worktree names and local branches stop being named after the raw pull ref. covers same-repo, fork with owner known, and deleted fork all with no real head branch. added an end to end gitea-service test plus resolver cases since the worktree slug follows localBranchName too. * refactor(forge): one head ref kind on the checkout target hasOriginHeadBranch and hasHeadBranch could describe contradictory states (cross-repo checkout claiming an origin head branch). collapse both into headRefKind: "branch" | "pull-ref" and derive origin tracking in the resolver instead of storing it separately. Co-authored-by: Alexander Adam <alexander.adam@vade.io> * fix(worktrees): avoid branch namespace conflicts in fork checkouts Fork owner prefixes can name an existing branch as a parent, which Git rejects even when the full proposed name is unused. Allocate names against parent and child refs, suffixing the conflicting segment while preserving existing branches and fetched commits. * test(worktrees): make fork fixture line endings deterministic Windows Git converts the fixture checkout to CRLF when core.autocrlf is enabled. Set the repository fixture to preserve its written LF bytes so the content assertion checks the fetched patch on every platform. Reproduced the failure locally with inherited core.autocrlf=true and verified the corrected setup against that same configuration. --------- Co-authored-by: Alexander Adam <alexander.adam@vade.io> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
1604 lines
51 KiB
TypeScript
1604 lines
51 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { PullRequestCommandStatus } 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<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" },
|
|
};
|
|
|
|
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/<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,
|
|
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,
|
|
);
|
|
});
|
|
});
|