Files
paseo/packages/server/src/services/gitea-service.test.ts
T

2401 lines
80 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { PullRequestCommandStatus, PullRequestTimelineItem } from "./forge-service.js";
import {
type CreateGiteaServiceOptions,
createGiteaService,
detectGiteaFamilySoftware,
TeaAuthenticationError,
TeaCommandError,
type TeaCommandResult,
type TeaCommandRunner,
} from "./gitea-service.js";
type Responder = (args: string[]) => TeaCommandResult | Promise<TeaCommandResult>;
function ok(stdout: string): TeaCommandResult {
return { stdout, stderr: "" };
}
function makeService(responder: Responder, overrides: Partial<CreateGiteaServiceOptions> = {}) {
const calls: string[][] = [];
const runner: TeaCommandRunner = async (args) => {
calls.push(args);
return responder(args);
};
const service = createGiteaService({
runner,
resolveTeaPath: async () => "/usr/bin/tea",
resolveRemoteUrl: async () => "https://gitea.com/example-user/sample-repo.git",
...overrides,
});
return { service, calls };
}
function argValue(args: string[], flag: string): string | undefined {
const index = args.indexOf(flag);
return index === -1 ? undefined : args[index + 1];
}
function locatedCommentThreadId(
item: PullRequestTimelineItem,
): [string, string | undefined] | null {
if (item.kind !== "comment" || !item.location) {
return null;
}
return [item.id, item.location.threadId];
}
// `tea pr list -o json` shape: every value is a string, including
// numeric/boolean fields (index, mergeable, comments, ci).
const OPEN_PR = {
index: "5",
state: "open",
author: "example-user",
url: "https://gitea.com/example-user/sample-repo/pulls/5",
title: "Add sample feature",
body: "Implements the sample feature",
mergeable: "true",
base: "main",
head: "feat/sample-change",
created: "2026-06-26T09:00:00Z",
updated: "2026-06-26T10:00:00Z",
labels: "enhancement,review",
comments: "2",
ci: "success",
};
const CONFLICTING_PR = {
...OPEN_PR,
index: "6",
url: "https://gitea.com/example-user/sample-repo/pulls/6",
head: "feat/conflict",
mergeable: "false",
ci: "failure",
};
function currentPullRequestApi(input: {
number: number;
state: "open" | "closed";
headRef: string;
headSha: string;
merged?: boolean;
}) {
return {
number: input.number,
html_url: `https://gitea.com/example-user/sample-repo/pulls/${input.number}`,
title: "Historical pull request",
body: "",
state: input.state,
merged: input.merged ?? false,
mergeable: true,
updated_at: "2026-06-26T10:00:00Z",
labels: [],
head: {
ref: input.headRef,
sha: input.headSha,
repo: { id: 1, owner: { login: "example-user" } },
},
base: {
ref: "main",
repo: { id: 1, owner: { login: "example-user" } },
},
};
}
const STATUS_PR_VIEW = {
id: 161482,
index: 5,
title: "Add sample feature",
state: "open",
created: "2026-06-28T16:25:00Z",
updated: "2026-06-28T16:25:04Z",
labels: [],
user: "example-user",
body: "Implements the sample feature",
assignees: [],
url: "https://gitea.com/example-user/sample-repo/pulls/5",
base: "main",
head: "feat/sample-change",
headSha: "3333333333333333333333333333333333333333",
diffUrl: "https://gitea.com/example-user/sample-repo/pulls/5.diff",
mergeable: true,
hasMerged: false,
mergedAt: null,
closedAt: null,
reviews: [],
comments: [],
};
const STATUS_CREATOR = {
id: 213843,
login: "example-user",
login_name: "",
source_id: 0,
full_name: "",
email: "1+example-user@noreply.gitea.com",
avatar_url:
"https://gitea.com/avatars/0000000000000000000000000000000000000000000000000000000000000000",
html_url: "https://gitea.com/example-user",
language: "",
is_admin: false,
last_login: "0001-01-01T00:00:00Z",
created: "2026-06-27T18:29:03Z",
restricted: false,
active: false,
prohibit_login: false,
location: "",
website: "",
description: "",
visibility: "public",
followers_count: 0,
following_count: 0,
starred_repos_count: 0,
username: "example-user",
};
const SAMPLE_STATUS_REPO = "sample-repo";
const SAMPLE_COMBINED_STATUS = {
state: "pending",
sha: "3333333333333333333333333333333333333333",
total_count: 2,
statuses: [
{
id: 1,
status: "success",
target_url: "https://example.invalid/ci/test",
description: "Tests passed",
url: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}/statuses/3333333333333333333333333333333333333333`,
context: "ci/test",
creator: STATUS_CREATOR,
created_at: "2026-06-28T16:25:03Z",
updated_at: "2026-06-28T16:25:03Z",
},
{
id: 2,
status: "pending",
target_url: "https://example.invalid/ci/lint",
description: "Lint running",
url: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}/statuses/3333333333333333333333333333333333333333`,
context: "ci/lint",
creator: STATUS_CREATOR,
created_at: "2026-06-28T16:25:04Z",
updated_at: "2026-06-28T16:25:04Z",
},
],
repository: {
id: 135972,
owner: STATUS_CREATOR,
name: SAMPLE_STATUS_REPO,
full_name: `example-user/${SAMPLE_STATUS_REPO}`,
description: "",
empty: false,
private: true,
fork: false,
template: false,
mirror: false,
size: 27,
language: "",
languages_url: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}/languages`,
html_url: `https://gitea.com/example-user/${SAMPLE_STATUS_REPO}`,
url: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}`,
ssh_url: `git@gitea.com:example-user/${SAMPLE_STATUS_REPO}.git`,
clone_url: `https://gitea.com/example-user/${SAMPLE_STATUS_REPO}.git`,
default_branch: "main",
has_actions: true,
permissions: { admin: true, push: true, pull: true },
object_format_name: "sha1",
},
commit_url: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}/commits/3333333333333333333333333333333333333333`,
url: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}/commits/3333333333333333333333333333333333333333/status`,
};
const SAMPLE_COMMIT_STATUSES = [
SAMPLE_COMBINED_STATUS.statuses[1],
SAMPLE_COMBINED_STATUS.statuses[0],
];
const SAMPLE_ACTIONS_TASKS = {
workflow_runs: [
{
id: 6979709,
name: "verify",
head_branch: "main",
head_sha: "2222222222222222222222222222222222222222",
run_number: 3,
event: "push",
display_title: "chore: add MIT license",
status: "success",
workflow_id: "ci.yml",
url: "https://codeberg.org/example-user/sample-repo/actions/runs/3",
created_at: "2026-06-28T18:48:20+02:00",
updated_at: "2026-06-28T18:48:38+02:00",
run_started_at: "2026-06-28T18:48:20+02:00",
},
{
id: 6979634,
name: "verify",
head_branch: "main",
head_sha: "4444444444444444444444444444444444444444",
run_number: 2,
event: "push",
display_title: "ci: trigger run",
status: "success",
workflow_id: "ci.yml",
url: "https://codeberg.org/example-user/sample-repo/actions/runs/2",
created_at: "2026-06-28T18:43:50+02:00",
updated_at: "2026-06-28T18:44:06+02:00",
run_started_at: "2026-06-28T18:43:50+02:00",
},
],
total_count: 2,
};
const OPEN_ISSUE = {
index: "3",
state: "open",
author: "example-user",
url: "https://gitea.com/example-user/sample-repo/issues/3",
title: "Login button misaligned",
body: "On mobile the button overflows",
labels: "bug",
comments: "1",
created: "2026-06-24T08:00:00Z",
updated: "2026-06-24T09:00:00Z",
};
const TIMELINE_USER = {
id: 213843,
login: "example-user",
login_name: "",
source_id: 0,
full_name: "",
email: "1+example-user@noreply.gitea.com",
avatar_url:
"https://gitea.com/avatars/0000000000000000000000000000000000000000000000000000000000000000",
html_url: "https://gitea.com/example-user",
language: "",
is_admin: false,
last_login: "0001-01-01T00:00:00Z",
created: "2026-06-27T18:29:03Z",
restricted: false,
active: false,
prohibit_login: false,
location: "",
website: "",
description: "",
visibility: "public",
followers_count: 0,
following_count: 0,
starred_repos_count: 0,
username: "example-user",
};
// Shape of a `tea pr 1 -o json` response.
const TIMELINE_PR_VIEW = {
id: 161481,
index: 1,
title: "Timeline fixture PR",
state: "open",
created: "2026-06-28T16:15:10Z",
updated: "2026-06-28T16:16:18Z",
labels: [],
user: "example-user",
body: "Sample pull request body.",
assignees: [],
url: "https://gitea.com/example-user/sample-repo/pulls/1",
base: "main",
head: "timeline-fixture",
headSha: "5555555555555555555555555555555555555555",
diffUrl: "https://gitea.com/example-user/sample-repo/pulls/1.diff",
mergeable: true,
hasMerged: false,
mergedAt: null,
closedAt: null,
reviews: [
{
id: 2001,
reviewer: "example-user",
state: "COMMENT",
body: "Timeline fixture general review comment.",
created: "2026-06-28T16:15:28Z",
},
{
id: 2002,
reviewer: "example-user",
state: "COMMENT",
body: "Timeline fixture inline review.",
created: "2026-06-28T16:16:18Z",
},
],
comments: [],
};
// `tea api repos/:owner/:repo/issues/:index/comments` shape.
const TIMELINE_ISSUE_COMMENTS = [
{
id: 1001,
html_url: "https://gitea.com/example-user/sample-repo/pulls/1#issuecomment-1001",
pull_request_url: "https://gitea.com/example-user/sample-repo/pulls/1",
issue_url: "",
user: TIMELINE_USER,
original_author: "",
original_author_id: 0,
body: "Timeline fixture issue comment from tea api.",
assets: [],
created_at: "2026-06-28T16:15:18Z",
updated_at: "2026-06-28T16:15:18Z",
},
];
function makeTimelineIssueComments(count: number, startId = 4000) {
return Array.from({ length: count }, (_, index) => ({
...TIMELINE_ISSUE_COMMENTS[0],
id: startId + index,
html_url: `https://gitea.com/example-user/sample-repo/pulls/1#issuecomment-${startId + index}`,
body: `comment ${index}`,
}));
}
// `tea api repos/:owner/:repo/pulls/:index/reviews` shape.
const TIMELINE_REVIEWS = [
{
id: 2001,
user: { ...TIMELINE_USER, email: "dev@example.com", language: "en-US", active: true },
team: null,
state: "COMMENT",
body: "Timeline fixture general review comment.",
commit_id: "5555555555555555555555555555555555555555",
stale: false,
official: false,
dismissed: false,
comments_count: 0,
submitted_at: "2026-06-28T16:15:28Z",
updated_at: "2026-06-28T16:15:28Z",
html_url: "https://gitea.com/example-user/sample-repo/pulls/1#issuecomment-1002",
pull_request_url: "https://gitea.com/example-user/sample-repo/pulls/1",
},
{
id: 2002,
user: { ...TIMELINE_USER, email: "dev@example.com", language: "en-US", active: true },
team: null,
state: "COMMENT",
body: "Timeline fixture inline review.",
commit_id: "5555555555555555555555555555555555555555",
stale: false,
official: false,
dismissed: false,
comments_count: 1,
submitted_at: "2026-06-28T16:16:18Z",
updated_at: "2026-06-28T16:16:18Z",
html_url: "https://gitea.com/example-user/sample-repo/pulls/1#issuecomment-1004",
pull_request_url: "https://gitea.com/example-user/sample-repo/pulls/1",
},
];
// `tea api repos/:owner/:repo/pulls/:index/reviews/:reviewId/comments` shape.
const TIMELINE_REVIEW_COMMENTS = [
{
id: 1003,
body: "Timeline fixture inline review comment.",
user: { ...TIMELINE_USER, email: "dev@example.com", language: "en-US", active: true },
resolver: null,
pull_request_review_id: 2002,
created_at: "2026-06-28T16:16:18Z",
updated_at: "2026-06-28T16:16:18Z",
path: "README.md",
commit_id: "5555555555555555555555555555555555555555",
original_commit_id: "",
diff_hunk:
"@@ -2,2 +2,3 @@\n-Sample timeline fixture\n\\ No newline at end of file\n+Sample timeline fixture.\n+",
position: 4,
original_position: 0,
html_url: "https://gitea.com/example-user/sample-repo/pulls/1#issuecomment-1003",
pull_request_url: "https://gitea.com/example-user/sample-repo/pulls/1",
},
];
const LOGINS = [
{ name: "gitea.com", url: "https://gitea.com", ssh_host: "gitea.com", user: "example-user" },
];
function giteaMergeStatus(
overrides: Partial<PullRequestCommandStatus> = {},
): PullRequestCommandStatus {
return {
forgeSpecific: { forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "success" },
...overrides,
};
}
describe("createGiteaService", () => {
it("maps a tea pr list item to the neutral current PR status by head branch", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(JSON.stringify([OPEN_PR, CONFLICTING_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status).toMatchObject({
number: 5,
url: "https://gitea.com/example-user/sample-repo/pulls/5",
title: "Add sample feature",
state: "open",
baseRefName: "main",
headRefName: "feat/sample-change",
isMerged: false,
mergeable: "MERGEABLE",
checksStatus: "pending",
reviewDecision: null,
repoOwner: "example-user",
repoName: "sample-repo",
projectPath: "example-user/sample-repo",
});
expect(status?.checks).toEqual([
{
name: "ci/test",
status: "success",
url: "https://example.invalid/ci/test",
checkRunId: 1,
},
{
name: "ci/lint",
status: "pending",
url: "https://example.invalid/ci/lint",
checkRunId: 2,
},
]);
expect(status?.forgeSpecific).toEqual({
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
});
// Requests the explicit field set; tea's default omits url/mergeable/base/head/ci.
expect(calls[0]).toContain("--fields");
expect(calls[0]).toContain("-o");
expect(calls[0]).toContain("json");
expect(calls[1]).toEqual(["pr", "5", "-o", "json"]);
expect(calls[2]).toEqual([
"api",
"repos/example-user/sample-repo/commits/3333333333333333333333333333333333333333/status",
]);
});
it("maps Gitea combined commit status contexts to flat checks", async () => {
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checksStatus).toBe("pending");
expect(status?.checks).toEqual([
{
name: "ci/test",
status: "success",
url: "https://example.invalid/ci/test",
checkRunId: 1,
},
{
name: "ci/lint",
status: "pending",
url: "https://example.invalid/ci/lint",
checkRunId: 2,
},
]);
});
it("maps matching Gitea Actions workflow runs to flat checks", async () => {
const actionsHeadSha = "2222222222222222222222222222222222222222";
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") {
return ok(JSON.stringify({ ...STATUS_PR_VIEW, headSha: actionsHeadSha }));
}
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 }));
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
return ok(JSON.stringify(SAMPLE_ACTIONS_TASKS));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checksStatus).toBe("success");
expect(status?.checks).toEqual([
{
name: "verify",
status: "success",
url: "https://codeberg.org/example-user/sample-repo/actions/runs/3",
workflowRunId: 6979709,
},
]);
});
it("keeps only the latest Gitea Actions rerun for the same workflow", async () => {
const headSha = STATUS_PR_VIEW.headSha;
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 }));
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
return ok(
JSON.stringify({
workflow_runs: [
{
id: 41,
name: "verify",
head_sha: headSha,
run_number: 4,
status: "failure",
workflow_id: "ci.yml",
url: "https://example.invalid/actions/runs/4",
created_at: "2026-06-28T18:00:00+02:00",
},
{
id: 42,
name: "verify",
head_sha: headSha,
run_number: 5,
status: "success",
workflow_id: "ci.yml",
url: "https://example.invalid/actions/runs/5",
created_at: "2026-06-28T18:10:00+02:00",
},
],
total_count: 2,
}),
);
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checksStatus).toBe("success");
expect(status?.checks).toEqual([
{
name: "verify",
status: "success",
url: "https://example.invalid/actions/runs/5",
workflowRunId: 42,
},
]);
});
it("reports failure when Actions fail even if the commit-status aggregate is success", async () => {
const headSha = STATUS_PR_VIEW.headSha;
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(
JSON.stringify({
state: "success",
total_count: 1,
statuses: [SAMPLE_COMBINED_STATUS.statuses[0]],
}),
);
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
return ok(
JSON.stringify({
workflow_runs: [
{
id: 71,
name: "e2e",
head_sha: headSha,
run_number: 3,
status: "failure",
workflow_id: "e2e.yml",
url: "https://example.invalid/actions/runs/71",
created_at: "2026-06-28T19:00:00+02:00",
},
],
total_count: 1,
}),
);
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
// The commit-status combined state is "success", but a failed Actions run
// must pull the aggregate to failure — never green on a failing PR.
expect(status?.checksStatus).toBe("failure");
expect(status?.checks.map((check) => check.status)).toContain("failure");
});
it("keeps separate Gitea Actions workflows for the same head SHA", async () => {
const headSha = STATUS_PR_VIEW.headSha;
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 }));
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
return ok(
JSON.stringify({
workflow_runs: [
{
id: 51,
name: "verify",
head_sha: headSha,
run_number: 7,
status: "success",
workflow_id: "ci.yml",
url: "https://example.invalid/actions/runs/7",
},
{
id: 52,
name: "release",
head_sha: headSha,
run_number: 3,
status: "pending",
workflow_id: "release.yml",
url: "https://example.invalid/actions/runs/3",
},
],
total_count: 2,
}),
);
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checksStatus).toBe("pending");
expect(status?.checks).toEqual([
{
name: "verify",
status: "success",
url: "https://example.invalid/actions/runs/7",
workflowRunId: 51,
},
{
name: "release",
status: "pending",
url: "https://example.invalid/actions/runs/3",
workflowRunId: 52,
},
]);
});
it("maps Gitea Actions run status values", async () => {
const headSha = STATUS_PR_VIEW.headSha;
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 }));
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
return ok(
JSON.stringify({
workflow_runs: [
{
id: 11,
name: "success-run",
head_sha: headSha,
status: "success",
workflow_id: "success.yml",
url: "https://example.invalid/actions/runs/11",
},
{
id: 12,
name: "failure-run",
head_sha: headSha,
status: "failure",
workflow_id: "failure.yml",
url: "https://example.invalid/actions/runs/12",
},
{
id: 13,
name: "cancelled-run",
head_sha: headSha,
status: "cancelled",
workflow_id: "cancelled.yml",
url: "https://example.invalid/actions/runs/13",
},
{
id: 14,
name: "running-run",
head_sha: headSha,
status: "running",
workflow_id: "running.yml",
url: "https://example.invalid/actions/runs/14",
},
{
id: 15,
name: "pending-run",
head_sha: headSha,
status: "pending",
workflow_id: "pending.yml",
url: "https://example.invalid/actions/runs/15",
},
{
id: 16,
name: "skipped-run",
head_sha: headSha,
status: "skipped",
workflow_id: "skipped.yml",
url: "https://example.invalid/actions/runs/16",
},
],
total_count: 6,
}),
);
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checksStatus).toBe("failure");
expect(status?.checks.map((check) => check.status)).toEqual([
"success",
"failure",
"cancelled",
"pending",
"pending",
"skipped",
]);
});
it("combines Gitea commit statuses with matching Actions runs", async () => {
const headSha = STATUS_PR_VIEW.headSha;
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
return ok(
JSON.stringify({
workflow_runs: [
{
id: 6979709,
name: "verify",
head_sha: headSha,
status: "success",
workflow_id: "ci.yml",
url: "https://codeberg.org/example-user/sample-repo/actions/runs/3",
},
],
total_count: 1,
}),
);
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checksStatus).toBe("pending");
expect(status?.checks).toEqual([
{
name: "ci/test",
status: "success",
url: "https://example.invalid/ci/test",
checkRunId: 1,
},
{
name: "ci/lint",
status: "pending",
url: "https://example.invalid/ci/lint",
checkRunId: 2,
},
{
name: "verify",
status: "success",
url: "https://codeberg.org/example-user/sample-repo/actions/runs/3",
workflowRunId: 6979709,
},
]);
});
it.each([
["failure", "failure", "failure"],
["error", "error", "failure"],
["success", "success", "success"],
] as const)("maps Gitea commit status state %s", async (aggregate, state, expected) => {
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(
JSON.stringify({
...SAMPLE_COMBINED_STATUS,
state: aggregate,
statuses: [{ ...SAMPLE_COMBINED_STATUS.statuses[0], status: state }],
}),
);
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checksStatus).toBe(expected);
expect(status?.checks[0]?.status).toBe(expected);
});
it("maps a Gitea 'warning' commit status to failure (terminal, non-passing), not stuck pending", async () => {
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(
JSON.stringify({
...SAMPLE_COMBINED_STATUS,
state: "warning",
statuses: [{ ...SAMPLE_COMBINED_STATUS.statuses[0], status: "warning" }],
}),
);
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
// Gitea's "warning" is terminal and non-passing (IsSuccess()===false, blocks
// merge). Our enum has no yellow bucket, so it surfaces as failure — never
// green success, never a never-resolving pending.
expect(status?.checks[0]?.status).toBe("failure");
expect(status?.checksStatus).toBe("failure");
});
it("falls back to the tea PR ci aggregate when combined status is empty", async () => {
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify({ state: "pending", statuses: [], total_count: 0 }));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checks).toEqual([]);
expect(status?.checksStatus).toBe("success");
});
it("maps a tea PR ci aggregate of 'warning' to failure, not stuck pending", async () => {
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(JSON.stringify([{ ...OPEN_PR, ci: "warning" }]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify({ state: "warning", statuses: [], total_count: 0 }));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checks).toEqual([]);
expect(status?.checksStatus).toBe("failure");
});
it("falls back to the tea PR ci aggregate when combined status returns 404", async () => {
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
throw { code: 1, stderr: "404 Not Found" };
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checks).toEqual([]);
expect(status?.checksStatus).toBe("success");
});
it("falls back to commit statuses when Gitea Actions are unavailable", async () => {
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
throw { code: 1, stderr: "404 Not Found" };
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
});
expect(status?.checksStatus).toBe("pending");
expect(status?.checks).toEqual([
{
name: "ci/test",
status: "success",
url: "https://example.invalid/ci/test",
checkRunId: 1,
},
{
name: "ci/lint",
status: "pending",
url: "https://example.invalid/ci/lint",
checkRunId: 2,
},
]);
});
it("returns flat Gitea check details from a commit status entry", async () => {
const localHeadSha = "1111111111111111111111111111111111111111";
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (
args[0] === "api" &&
args[1] === `repos/example-user/sample-repo/commits/${STATUS_PR_VIEW.headSha}/status`
) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
if (
args[0] === "api" &&
args[1] === `repos/example-user/sample-repo/commits/${localHeadSha}/status`
) {
return ok(JSON.stringify({ state: "success", statuses: [], total_count: 0 }));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{
resolveCurrentBranch: async () => "feat/sample-change",
},
);
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
checkRunId: 2,
});
expect(details).toEqual({
checkRunId: 2,
name: "ci/lint",
status: "pending",
conclusion: "pending",
url: "https://example.invalid/ci/lint",
detailsUrl: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}/statuses/3333333333333333333333333333333333333333`,
output: {
title: "ci/lint",
summary: "Lint running",
text: null,
},
annotations: [],
failedJobs: [],
truncated: false,
});
expect(calls).toEqual([
[
"pr",
"list",
"--fields",
"index,state,author,url,title,body,mergeable,base,head,created,updated,labels,comments,ci",
"--state",
"open",
"-o",
"json",
"--limit",
"50",
"--page",
"1",
],
["pr", "5", "-o", "json"],
["api", `repos/example-user/sample-repo/commits/${STATUS_PR_VIEW.headSha}/status`],
]);
});
it("resolves terminal PR check details by explicit change request number", async () => {
const headSha = "8888888888888888888888888888888888888888";
const terminalView = {
...STATUS_PR_VIEW,
index: 8,
state: "closed",
head: "feat/recently-closed",
headSha,
hasMerged: true,
mergedAt: "2026-06-28T17:00:00Z",
};
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(terminalView));
if (args[0] === "api" && args[1].endsWith(`/commits/${headSha}/status`)) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveCurrentBranch: async () => "feat/recently-closed" },
);
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
checkRunId: 2,
changeRequestNumber: 8,
});
expect(details).toMatchObject({ checkRunId: 2, name: "ci/lint", status: "pending" });
expect(calls).toEqual([
["pr", "8", "-o", "json"],
["api", `repos/example-user/sample-repo/commits/${headSha}/status`],
]);
});
it("resolves Gitea Actions check details addressed only by workflowRunId", async () => {
const { service } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].endsWith("/status")) {
return ok(JSON.stringify({ state: "success", statuses: [], total_count: 0 }));
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
return ok(
JSON.stringify({
workflow_runs: [
{
id: 7001,
name: "e2e",
head_sha: STATUS_PR_VIEW.headSha,
run_number: 3,
status: "failure",
workflow_id: "e2e.yml",
url: "https://gitea.com/example-user/sample-repo/actions/runs/7001",
},
],
total_count: 1,
}),
);
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveCurrentBranch: async () => "feat/sample-change" },
);
// No checkRunId — only the Actions workflowRunId, as the PR pane sends for a
// Gitea Actions row. It must still resolve instead of throwing.
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
workflowRunId: 7001,
});
expect(details).toMatchObject({
workflowRunId: 7001,
name: "e2e",
conclusion: "failure",
url: "https://gitea.com/example-user/sample-repo/actions/runs/7001",
});
});
it("caches the resolved PR head SHA across polls within the TTL", async () => {
let currentTime = 0;
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (
args[0] === "api" &&
args[1] === `repos/example-user/sample-repo/commits/${STATUS_PR_VIEW.headSha}/status`
) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{
resolveCurrentBranch: async () => "feat/sample-change",
now: () => currentTime,
},
);
const request = {
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
checkRunId: 2,
};
await service.getCheckDetails(request);
const prLookupCallsAfterFirstPoll = calls.filter((args) => args[0] === "pr").length;
currentTime += 5_000;
await service.getCheckDetails(request);
expect(calls.filter((args) => args[0] === "pr").length).toBe(prLookupCallsAfterFirstPoll);
});
it("re-resolves the PR head SHA once the cache TTL expires", async () => {
let currentTime = 0;
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (
args[0] === "api" &&
args[1] === `repos/example-user/sample-repo/commits/${STATUS_PR_VIEW.headSha}/status`
) {
return ok(JSON.stringify(SAMPLE_COMBINED_STATUS));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{
resolveCurrentBranch: async () => "feat/sample-change",
now: () => currentTime,
},
);
const request = {
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
checkRunId: 2,
};
await service.getCheckDetails(request);
const prLookupCallsAfterFirstPoll = calls.filter((args) => args[0] === "pr").length;
currentTime += 60_000;
await service.getCheckDetails(request);
expect(calls.filter((args) => args[0] === "pr").length).toBeGreaterThan(
prLookupCallsAfterFirstPoll,
);
});
it("throws when check details cannot find a PR for the current branch", async () => {
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([]));
if (args[0] === "api" && args[1].includes("/pulls?state=all")) {
return ok(JSON.stringify([]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{
resolveCurrentBranch: async () => "feat/nonexistent",
},
);
await expect(
service.getCheckDetails({
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
checkRunId: 2,
}),
).rejects.toThrow("Gitea pull request for branch feat/nonexistent was not found");
expect(calls[1]).toEqual([
"api",
"repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
]);
});
it("throws when check details address neither a checkRunId nor a workflowRunId", async () => {
const { service, calls } = makeService(() => {
throw new Error("no CLI call expected for an unaddressable check details request");
});
await expect(
service.getCheckDetails({
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
}),
).rejects.toThrow("requires a checkRunId or workflowRunId");
expect(calls).toEqual([]);
});
it("returns flat Gitea check details from an Actions workflow run", async () => {
const actionsHeadSha = "2222222222222222222222222222222222222222";
const { service } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([OPEN_PR]));
if (args[0] === "pr" && args[1] === "5") {
return ok(JSON.stringify({ ...STATUS_PR_VIEW, headSha: actionsHeadSha }));
}
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 }));
}
if (args[0] === "api" && args[1].includes("/actions/tasks")) {
return ok(JSON.stringify(SAMPLE_ACTIONS_TASKS));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{
resolveCurrentBranch: async () => "feat/sample-change",
},
);
const details = await service.getCheckDetails({
cwd: "/repo",
repoOwner: "example-user",
repoName: "sample-repo",
checkRunId: 6979709,
workflowRunId: 6979709,
});
expect(details).toEqual({
checkRunId: 6979709,
workflowRunId: 6979709,
name: "verify",
status: "success",
conclusion: "success",
url: "https://codeberg.org/example-user/sample-repo/actions/runs/3",
detailsUrl: "https://codeberg.org/example-user/sample-repo/actions/runs/3",
output: {
title: "chore: add MIT license",
summary: "ci.yml",
text: null,
},
annotations: [],
failedJobs: [],
truncated: false,
});
});
it("keeps the commit-statuses endpoint shape as a fixture", () => {
expect(SAMPLE_COMMIT_STATUSES).toEqual([
{
id: 2,
status: "pending",
target_url: "https://example.invalid/ci/lint",
description: "Lint running",
url: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}/statuses/3333333333333333333333333333333333333333`,
context: "ci/lint",
creator: STATUS_CREATOR,
created_at: "2026-06-28T16:25:04Z",
updated_at: "2026-06-28T16:25:04Z",
},
{
id: 1,
status: "success",
target_url: "https://example.invalid/ci/test",
description: "Tests passed",
url: `https://gitea.com/api/v1/repos/example-user/${SAMPLE_STATUS_REPO}/statuses/3333333333333333333333333333333333333333`,
context: "ci/test",
creator: STATUS_CREATOR,
created_at: "2026-06-28T16:25:03Z",
updated_at: "2026-06-28T16:25:03Z",
},
]);
});
it("keeps the Codeberg Actions tasks endpoint shape as a fixture", () => {
expect(SAMPLE_ACTIONS_TASKS).toEqual({
workflow_runs: [
{
id: 6979709,
name: "verify",
head_branch: "main",
head_sha: "2222222222222222222222222222222222222222",
run_number: 3,
event: "push",
display_title: "chore: add MIT license",
status: "success",
workflow_id: "ci.yml",
url: "https://codeberg.org/example-user/sample-repo/actions/runs/3",
created_at: "2026-06-28T18:48:20+02:00",
updated_at: "2026-06-28T18:48:38+02:00",
run_started_at: "2026-06-28T18:48:20+02:00",
},
{
id: 6979634,
name: "verify",
head_branch: "main",
head_sha: "4444444444444444444444444444444444444444",
run_number: 2,
event: "push",
display_title: "ci: trigger run",
status: "success",
workflow_id: "ci.yml",
url: "https://codeberg.org/example-user/sample-repo/actions/runs/2",
created_at: "2026-06-28T18:43:50+02:00",
updated_at: "2026-06-28T18:44:06+02:00",
run_started_at: "2026-06-28T18:43:50+02:00",
},
],
total_count: 2,
});
});
it("reports a conflicting PR as CONFLICTING with a failing CI", async () => {
const { service } = makeService(() => ok(JSON.stringify([CONFLICTING_PR])));
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/conflict",
});
expect(status?.mergeable).toBe("CONFLICTING");
expect(status?.checksStatus).toBe("failure");
});
it("finds the open current-branch PR even when its remote head SHA differs", async () => {
const firstPage = Array.from({ length: 50 }, (_, index) => ({
...OPEN_PR,
index: String(100 + index),
url: `https://gitea.com/example-user/sample-repo/pulls/${100 + index}`,
head: `feat/other-${index}`,
}));
const secondPage = [
...Array.from({ length: 5 }, (_, index) => ({
...OPEN_PR,
index: String(200 + index),
url: `https://gitea.com/example-user/sample-repo/pulls/${200 + index}`,
head: `feat/later-${index}`,
})),
OPEN_PR,
];
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list" && argValue(args, "--state") === "open") {
return ok(JSON.stringify(argValue(args, "--page") === "2" ? secondPage : firstPage));
}
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/")) {
return ok(JSON.stringify({ state: "", statuses: [], total_count: 0 }));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/sample-change",
headSha: "9999999999999999999999999999999999999999",
});
expect(status?.number).toBe(5);
expect(
calls
.filter((args) => args[0] === "pr" && args[1] === "list")
.map((args) => [argValue(args, "--state"), argValue(args, "--page")]),
).toEqual([
["open", "1"],
["open", "2"],
]);
});
it("falls back to the recent all-state PR window for a recently closed current branch PR", async () => {
const headSha = "8888888888888888888888888888888888888888";
const closedPr = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "feat/recently-closed",
headSha,
});
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list" && argValue(args, "--state") === "open") {
return ok(JSON.stringify([]));
}
if (args[0] === "api" && args[1].includes("/pulls?state=all")) {
return ok(JSON.stringify([closedPr]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/recently-closed",
headSha,
});
expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" });
expect(calls[1]).toEqual([
"api",
"repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
]);
});
it("uses tea repository context when origin identity is unavailable", async () => {
const headSha = "8888888888888888888888888888888888888888";
const closedPr = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "feat/recently-closed",
headSha,
});
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].startsWith("repos/{owner}/{repo}/pulls?")) {
return ok(JSON.stringify([closedPr]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => null },
);
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/recently-closed",
headSha,
});
expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" });
expect(calls[1]).toEqual([
"api",
"repos/{owner}/{repo}/pulls?state=all&sort=recentupdate&page=1&limit=50",
]);
});
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
const stale = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "dev",
headSha: "1111111111111111111111111111111111111111",
merged: true,
});
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls?state=all")) {
return ok(JSON.stringify([stale]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
});
it("returns null when no PR matches the current branch", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([]));
if (args[0] === "api" && args[1].includes("/pulls?state=all")) {
return ok(JSON.stringify([]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feat/nonexistent",
});
expect(status).toBeNull();
expect(calls[1]).toEqual([
"api",
"repos/example-user/sample-repo/pulls?state=all&sort=recentupdate&page=1&limit=50",
]);
});
it("lists open pull requests", async () => {
const { service, calls } = makeService(() => ok(JSON.stringify([OPEN_PR])));
const prs = await service.listPullRequests({ cwd: "/repo" });
expect(prs).toHaveLength(1);
expect(prs[0]).toMatchObject({
number: 5,
title: "Add sample feature",
baseRefName: "main",
headRefName: "feat/sample-change",
labels: ["enhancement", "review"],
state: "open",
});
expect(calls[0]).toEqual(
expect.arrayContaining(["pr", "list", "--state", "open", "-o", "json"]),
);
});
it("resolves the tea CLI path once per service instance, not per invocation", async () => {
let resolveCalls = 0;
const { service } = makeService(() => ok(JSON.stringify([OPEN_PR])), {
resolveTeaPath: async () => {
resolveCalls += 1;
return "/usr/bin/tea";
},
});
await service.listPullRequests({ cwd: "/repo" });
await service.listPullRequests({ cwd: "/repo" });
expect(resolveCalls).toBe(1);
});
it("lists issues", async () => {
const { service, calls } = makeService(() => ok(JSON.stringify([OPEN_ISSUE])));
const issues = await service.listIssues({ cwd: "/repo" });
expect(issues[0]).toMatchObject({
number: 3,
title: "Login button misaligned",
url: "https://gitea.com/example-user/sample-repo/issues/3",
labels: ["bug"],
state: "open",
});
expect(calls[0]).toEqual([
"issue",
"list",
"--fields",
"index,state,author,url,title,body,labels,comments,created,updated",
"--state",
"open",
"-o",
"json",
]);
});
it("fetches a single pull request by number", async () => {
const { service, calls } = makeService(() =>
ok(
JSON.stringify({
index: 6,
url: "https://gitea.com/example-user/sample-repo/pulls/6",
state: "open",
title: "Fix conflict",
base: "main",
head: "feat/conflict",
mergeable: false,
hasMerged: false,
}),
),
);
const pr = await service.getPullRequest({ cwd: "/repo", number: 6 });
expect(pr.number).toBe(6);
expect(pr.headRefName).toBe("feat/conflict");
// Fetched by number directly, not by scanning the recent-PR list.
expect(calls).toContainEqual(["pr", "6", "-o", "json"]);
});
it("maps a same-repo pull request to a checkout target", async () => {
const { service } = makeService((args) =>
args[0] === "api"
? ok(JSON.stringify({ head: { repo: { id: 1 } }, base: { repo: { id: 1 } } }))
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
await expect(
service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }),
).resolves.toEqual({
number: 5,
baseRefName: "main",
headRefName: "feat/sample-change",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
],
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
});
});
it("maps a fork pull request to a cross-repository checkout target", async () => {
const { service, calls } = makeService((args) =>
args[0] === "api"
? ok(
JSON.stringify({
head: {
repo: {
id: 2,
owner: { login: "contributor" },
ssh_url: "git@gitea.com:contributor/sample-repo.git",
html_url: "https://gitea.com/contributor/sample-repo",
},
},
base: { repo: { id: 1 } },
}),
)
: ok(JSON.stringify(STATUS_PR_VIEW)),
);
await expect(
service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }),
).resolves.toEqual({
number: 5,
baseRefName: "main",
headRefName: "feat/sample-change",
checkoutRefs: [
{ remoteName: "origin", remoteRef: "refs/pull/5/head" },
{ remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" },
],
headOwnerLogin: "contributor",
headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git",
headRepositoryUrl: "https://gitea.com/contributor/sample-repo",
isCrossRepository: true,
});
expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
});
it("creates a pull request and parses the resulting URL and index", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "create") {
return ok("Created #7\nhttps://gitea.com/example-user/sample-repo/pulls/7");
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const result = await service.createPullRequest({
cwd: "/repo",
repo: "example-user/sample-repo",
title: "New feature",
head: "feat/new",
base: "main",
body: "Body",
});
expect(result).toEqual({
url: "https://gitea.com/example-user/sample-repo/pulls/7",
number: 7,
});
expect(calls[0]).toEqual(
expect.arrayContaining(["pr", "create", "--head", "feat/new", "--base", "main"]),
);
});
it("merges a mergeable pull request with the requested style", async () => {
const { service, calls } = makeService(() => ok(""));
const result = await service.mergePullRequest({
cwd: "/repo",
prNumber: 5,
mergeMethod: "squash",
status: giteaMergeStatus(),
});
expect(result).toEqual({ success: true });
expect(calls[0]).toEqual(["pr", "merge", "5", "--style", "squash"]);
});
it("refuses to merge a pull request Gitea does not report as mergeable", async () => {
const { service, calls } = makeService(() => ok(""));
await expect(
service.mergePullRequest({
cwd: "/repo",
prNumber: 6,
mergeMethod: "merge",
status: giteaMergeStatus({
forgeSpecific: {
forge: "gitea",
mergeable: false,
hasMerged: false,
ciStatus: "failure",
},
}),
}),
).rejects.toThrow(/ready for direct merge/);
expect(calls).toHaveLength(0);
});
it("maps Gitea PR comments and reviews to a neutral timeline", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify(TIMELINE_ISSUE_COMMENTS));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?"))
return ok(JSON.stringify(TIMELINE_REVIEWS));
if (args[0] === "api" && args[1].includes("/reviews/2002/comments"))
return ok(JSON.stringify(TIMELINE_REVIEW_COMMENTS));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(calls).toEqual([
["pr", "1", "-o", "json"],
["api", "repos/example-user/sample-repo/issues/1/comments?page=1&limit=50"],
["api", "repos/example-user/sample-repo/pulls/1/reviews?page=1&limit=50"],
["api", "repos/example-user/sample-repo/pulls/1/reviews/2002/comments?page=1&limit=50"],
]);
expect(timeline.error).toBeNull();
expect(timeline.truncated).toBe(false);
expect(timeline.items.map((item) => item.id)).toEqual(["1001", "2001", "1003", "2002"]);
expect(timeline.items[0]).toMatchObject({
kind: "comment",
author: "example-user",
authorUrl: "https://gitea.com/example-user",
avatarUrl:
"https://gitea.com/avatars/0000000000000000000000000000000000000000000000000000000000000000",
body: "Timeline fixture issue comment from tea api.",
createdAt: Date.parse("2026-06-28T16:15:18Z"),
url: "https://gitea.com/example-user/sample-repo/pulls/1#issuecomment-1001",
});
expect(timeline.items[1]).toMatchObject({
kind: "review",
id: "2001",
reviewState: "commented",
body: "Timeline fixture general review comment.",
});
expect(timeline.items[2]).toMatchObject({
kind: "comment",
id: "1003",
reviewId: "2002",
location: { path: "README.md", line: 4, threadId: "README.md#pos-4" },
});
});
it("keeps distinct inline locations in separate threads within one review", async () => {
const reviews = [{ ...TIMELINE_REVIEWS[1], id: 2002, comments_count: 3 }];
// One review, three inline comments: two share a location (a reply chain),
// the third sits on a different line. All carry the same review id.
const reviewComments = [
{ ...TIMELINE_REVIEW_COMMENTS[0], id: 3001, path: "README.md", position: 4 },
{ ...TIMELINE_REVIEW_COMMENTS[0], id: 3002, path: "README.md", position: 4 },
{ ...TIMELINE_REVIEW_COMMENTS[0], id: 3003, path: "README.md", position: 9 },
];
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify([]));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?"))
return ok(JSON.stringify(reviews));
if (args[0] === "api" && args[1].includes("/reviews/2002/comments"))
return ok(JSON.stringify(reviewComments));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
const threadIds = new Map(
timeline.items
.map(locatedCommentThreadId)
.filter((entry): entry is [string, string | undefined] => entry !== null),
);
// Same location means same thread (reply chain); different line means different thread.
expect(threadIds.get("3001")).toBe("README.md#pos-4");
expect(threadIds.get("3002")).toBe("README.md#pos-4");
expect(threadIds.get("3003")).toBe("README.md#pos-9");
expect(threadIds.get("3003")).not.toBe(threadIds.get("3001"));
});
it("maps the Gitea review-comment resolver into location.isResolved", async () => {
const reviews = [{ ...TIMELINE_REVIEWS[1], id: 2002, comments_count: 3 }];
// One inline comment per resolution state: resolved (resolver is a user),
// unresolved (resolver explicitly null), and unknown (resolver field absent).
const withoutResolver: Record<string, unknown> = { ...TIMELINE_REVIEW_COMMENTS[0], id: 3103 };
delete withoutResolver.resolver;
const reviewComments = [
{ ...TIMELINE_REVIEW_COMMENTS[0], id: 3101, resolver: { ...TIMELINE_USER } },
{ ...TIMELINE_REVIEW_COMMENTS[0], id: 3102, resolver: null },
withoutResolver,
];
const { service } = makeService((args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify([]));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?"))
return ok(JSON.stringify(reviews));
if (args[0] === "api" && args[1].includes("/reviews/2002/comments"))
return ok(JSON.stringify(reviewComments));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
const locations = new Map(
timeline.items
.filter((item) => item.kind === "comment" && item.location)
.map((item) => [item.id, item.kind === "comment" ? item.location : undefined]),
);
expect(locations.get("3101")).toMatchObject({ isResolved: true });
expect(locations.get("3102")).toMatchObject({ isResolved: false });
expect(locations.get("3103")).toBeDefined();
expect(locations.get("3103")).not.toHaveProperty("isResolved");
});
it("bounds concurrent review-comment fetches during the timeline fan-out", async () => {
const reviewCount = 12;
const reviews = Array.from({ length: reviewCount }, (_, index) => ({
...TIMELINE_REVIEWS[0],
id: 5000 + index,
comments_count: 1,
}));
let inFlight = 0;
let maxInFlight = 0;
const { service } = makeService(async (args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify([]));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?"))
return ok(JSON.stringify(reviews));
if (args[0] === "api" && args[1].includes("/comments")) {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await Promise.resolve();
await Promise.resolve();
inFlight -= 1;
return ok(JSON.stringify([]));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(maxInFlight).toBeLessThanOrEqual(5);
expect(maxInFlight).toBeGreaterThan(1);
});
it("keeps issue comments when the Gitea reviews endpoint fails", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify(TIMELINE_ISSUE_COMMENTS));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?"))
throw { code: 1, stderr: "404 reviews endpoint not found" };
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(calls).toHaveLength(3);
expect(timeline).toMatchObject({
error: null,
truncated: false,
items: [
{
kind: "comment",
id: "1001",
body: "Timeline fixture issue comment from tea api.",
},
],
});
});
it("keeps review summaries and other comments when one inline review comment fetch fails", async () => {
const reviews = TIMELINE_REVIEWS.map((review) => ({ ...review, comments_count: 1 }));
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify(TIMELINE_ISSUE_COMMENTS));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?"))
return ok(JSON.stringify(reviews));
if (args[0] === "api" && args[1].includes("/reviews/2001/comments"))
throw { code: 1, stderr: "500 failed to fetch inline comments" };
if (args[0] === "api" && args[1].includes("/reviews/2002/comments"))
return ok(JSON.stringify(TIMELINE_REVIEW_COMMENTS));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(calls).toHaveLength(5);
expect(timeline.error).toBeNull();
expect(timeline.items.map((item) => item.id)).toEqual(["1001", "2001", "1003", "2002"]);
expect(timeline.items).toContainEqual(
expect.objectContaining({
kind: "comment",
id: "1003",
reviewId: "2002",
}),
);
});
it("fetches a second Gitea comments page when the first page is full", async () => {
const firstPage = makeTimelineIssueComments(50, 4000);
const secondPage = makeTimelineIssueComments(10, 4050);
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments?page=1"))
return ok(JSON.stringify(firstPage));
if (args[0] === "api" && args[1].includes("/issues/1/comments?page=2"))
return ok(JSON.stringify(secondPage));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?")) return ok(JSON.stringify([]));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(
calls.filter((call) => call[1]?.includes("/issues/1/comments?")).map((call) => call[1]),
).toEqual([
"repos/example-user/sample-repo/issues/1/comments?page=1&limit=50",
"repos/example-user/sample-repo/issues/1/comments?page=2&limit=50",
]);
expect(timeline.items).toHaveLength(60);
expect(timeline.truncated).toBe(false);
});
it("flags Gitea timeline truncation after the bounded comments page count", async () => {
const fullPage = makeTimelineIssueComments(50, 5000);
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify(fullPage));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?")) return ok(JSON.stringify([]));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(
calls.filter((call) => call[1]?.includes("/issues/1/comments?")).map((call) => call[1]),
).toEqual([
"repos/example-user/sample-repo/issues/1/comments?page=1&limit=50",
"repos/example-user/sample-repo/issues/1/comments?page=2&limit=50",
"repos/example-user/sample-repo/issues/1/comments?page=3&limit=50",
"repos/example-user/sample-repo/issues/1/comments?page=4&limit=50",
]);
expect(timeline.items).toHaveLength(200);
expect(timeline.truncated).toBe(true);
});
it("uses a single Gitea timeline fetch for a short comments page", async () => {
const comments = makeTimelineIssueComments(3, 6000);
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "1") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify(comments));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?")) return ok(JSON.stringify([]));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(
calls.filter((call) => call[1]?.includes("/issues/1/comments?")).map((call) => call[1]),
).toEqual(["repos/example-user/sample-repo/issues/1/comments?page=1&limit=50"]);
expect(timeline.items).toHaveLength(3);
expect(timeline.truncated).toBe(false);
});
it("maps Gitea review verdict states", async () => {
const reviews = [
{ ...TIMELINE_REVIEWS[0], id: 1, state: "APPROVED", body: "approved", comments_count: 0 },
{
...TIMELINE_REVIEWS[1],
id: 2,
state: "REQUEST_CHANGES",
body: "needs work",
comments_count: 0,
},
];
const { service } = makeService((args) => {
if (args[0] === "pr") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments")) return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?"))
return ok(JSON.stringify(reviews));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(timeline.items).toMatchObject([
{ kind: "review", id: "1", reviewState: "approved" },
{ kind: "review", id: "2", reviewState: "changes_requested" },
]);
});
it("drops Gitea system comments from the neutral timeline", async () => {
const { service } = makeService((args) => {
if (args[0] === "pr") return ok(JSON.stringify(TIMELINE_PR_VIEW));
if (args[0] === "api" && args[1].includes("/issues/1/comments"))
return ok(JSON.stringify([{ ...TIMELINE_ISSUE_COMMENTS[0], type: "pull_ref" }]));
if (args[0] === "api" && args[1].includes("/pulls/1/reviews?")) return ok("[]");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 1,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(timeline.items).toEqual([]);
});
it.each([
["404 pull request not found", "not_found"],
["403 Forbidden", "forbidden"],
["401 Unauthorized", "forbidden"],
] as const)("returns a neutral %s timeline error", async (stderr, kind) => {
const { service } = makeService(() => {
throw { code: 1, stderr };
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 99,
repoOwner: "example-user",
repoName: "sample-repo",
});
expect(timeline).toMatchObject({
prNumber: 99,
repoOwner: "example-user",
repoName: "sample-repo",
items: [],
truncated: false,
error: { kind },
});
});
it("reports authenticated when a tea login matches the remote host", async () => {
const { service } = makeService((args) => {
if (args[0] === "login" && args[1] === "list") return ok(JSON.stringify(LOGINS));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(service.isAuthenticated({ cwd: "/repo" })).resolves.toBe(true);
});
it("reports unauthenticated when no tea login matches the remote host", async () => {
const { service } = makeService(
(args) => {
if (args[0] === "login" && args[1] === "list") return ok(JSON.stringify(LOGINS));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => "https://git.other.example/team/repo.git" },
);
await expect(service.isAuthenticated({ cwd: "/repo" })).resolves.toBe(false);
});
it("maps an authentication failure from tea onto TeaAuthenticationError", async () => {
const { service } = makeService(() => {
throw { code: 1, stderr: "401 Unauthorized" };
});
await expect(service.listPullRequests({ cwd: "/repo" })).rejects.toBeInstanceOf(
TeaAuthenticationError,
);
});
it("searches issues and pull 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] === "pr") return ok(JSON.stringify([OPEN_PR]));
throw new Error(`unexpected tea args: ${args.join(" ")}`);
});
const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: "", limit: 10 });
expect(result.githubFeaturesEnabled).toBe(true);
expect(result.items).toEqual([
{
kind: "change_request",
number: 5,
title: "Add sample feature",
url: "https://gitea.com/example-user/sample-repo/pulls/5",
state: "open",
body: "Implements the sample feature",
labels: ["enhancement", "review"],
projectPath: "example-user/sample-repo",
baseRefName: "main",
headRefName: "feat/sample-change",
updatedAt: "2026-06-26T10:00:00Z",
},
{
kind: "issue",
number: 3,
title: "Login button misaligned",
url: "https://gitea.com/example-user/sample-repo/issues/3",
state: "open",
body: "On mobile the button overflows",
labels: ["bug"],
projectPath: "example-user/sample-repo",
baseRefName: null,
headRefName: null,
updatedAt: "2026-06-24T09:00:00Z",
},
]);
expect(calls.find((args) => args[0] === "issue")).toEqual([
"issue",
"list",
"--fields",
"index,state,author,url,title,body,labels,comments,created,updated",
"--state",
"open",
"-o",
"json",
"--limit",
"10",
]);
expect(calls.find((args) => args[0] === "pr")).toEqual([
"pr",
"list",
"--fields",
"index,state,author,url,title,body,mergeable,base,head,created,updated,labels,comments,ci",
"--state",
"open",
"-o",
"json",
"--limit",
"10",
]);
});
it("sorts issue and pull request search results by update time", async () => {
const { service } = makeService((args) => {
if (args[0] === "issue") return ok(JSON.stringify([OPEN_ISSUE]));
if (args[0] === "pr") return ok(JSON.stringify([OPEN_PR]));
throw new Error(`unexpected tea args: ${args.join(" ")}`);
});
const result = await service.searchIssuesAndPrs({ cwd: "/repo", query: "" });
expect(result).toEqual({
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
items: [
{
kind: "change_request",
number: 5,
title: "Add sample feature",
url: "https://gitea.com/example-user/sample-repo/pulls/5",
state: "open",
body: "Implements the sample feature",
labels: ["enhancement", "review"],
projectPath: "example-user/sample-repo",
baseRefName: "main",
headRefName: "feat/sample-change",
updatedAt: "2026-06-26T10:00:00Z",
},
{
kind: "issue",
number: 3,
title: "Login button misaligned",
url: "https://gitea.com/example-user/sample-repo/issues/3",
state: "open",
body: "On mobile the button overflows",
labels: ["bug"],
projectPath: "example-user/sample-repo",
baseRefName: null,
headRefName: null,
updatedAt: "2026-06-24T09:00:00Z",
},
],
});
});
it("restricts search to pull requests when only the PR kind is requested", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr") return ok(JSON.stringify([OPEN_PR]));
throw new Error(`unexpected tea args: ${args.join(" ")}`);
});
const result = await service.searchIssuesAndPrs({
cwd: "/repo",
query: "sample",
kinds: ["github-pr"],
});
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({ kind: "change_request", number: 5 });
expect(calls).toEqual([
[
"pr",
"list",
"--fields",
"index,state,author,url,title,body,mergeable,base,head,created,updated,labels,comments,ci",
"--state",
"open",
"-o",
"json",
],
]);
});
it("reports forge features disabled when tea is unavailable or unauthenticated", async () => {
const missing = makeService(() => ok("[]"), { resolveTeaPath: 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 Gitea API failure" };
}
return ok(JSON.stringify([OPEN_PR]));
});
await expect(service.searchIssuesAndPrs({ cwd: "/repo", query: "sample" })).rejects.toThrow(
TeaCommandError,
);
});
});
describe("detectGiteaFamilySoftware", () => {
it("classifies a 404 Forgejo namespace from authenticated tea as gitea", async () => {
const runTea = async (args: string[]): Promise<{ stdout: string; stderr: string }> => {
if (args[0] === "login") {
return {
stdout: JSON.stringify([{ name: "git.acme.it", url: "https://git.acme.it" }]),
stderr: "",
};
}
return { stdout: "Not found.", stderr: "HTTP/1.1 404 Not Found\nServer: Caddy\n" };
};
const software = await detectGiteaFamilySoftware("git.acme.it", {
runTea,
});
expect(software).toBe("gitea");
});
it("reads forgejo from the authenticated tea status line when anonymous access is blocked", async () => {
const runTea = async (args: string[]): Promise<{ stdout: string; stderr: string }> => {
if (args[0] === "login") {
return {
stdout: JSON.stringify([{ name: "forge.acme.it", url: "https://forge.acme.it" }]),
stderr: "",
};
}
return { stdout: '{"version":"1.0+gitea"}', stderr: "HTTP/1.1 200 OK\nServer: Caddy\n" };
};
const software = await detectGiteaFamilySoftware("forge.acme.it", {
runTea,
});
expect(software).toBe("forgejo");
});
it("defaults to gitea when every tier is inconclusive", async () => {
const software = await detectGiteaFamilySoftware("offline.example.com", {
runTea: async () => ({ stdout: "[]", stderr: "" }),
});
expect(software).toBe("gitea");
});
});