Skip to content

Commit b37c7f6

Browse files
fix(forgejo): find merged pull requests whose head branch was deleted (#33675)
1 parent 5dcf69e commit b37c7f6

5 files changed

Lines changed: 138 additions & 4 deletions

File tree

‎packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,77 @@ it.effect("loads Forgejo pull request references from files and commits views",
961961
),
962962
);
963963

964+
it.effect("finds a merged Forgejo pull request whose head branch was deleted", () =>
965+
Effect.gen(function* () {
966+
const provider = yield* ForgejoSourceControlProvider.make;
967+
const found = yield* provider.listChangeRequests({
968+
cwd: "/repo",
969+
headSelector: "feature",
970+
state: "merged",
971+
});
972+
assert.deepStrictEqual(
973+
found.map((pr) => [pr.number, pr.headRefName]),
974+
[[7, "feature"]],
975+
);
976+
}).pipe(
977+
Effect.provide(
978+
Layer.mergeAll(
979+
Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
980+
TestSourceControlHost.layer({ process: {} }),
981+
Layer.mock(ForgejoCli.ForgejoCli)({
982+
resolveRepository: () =>
983+
Effect.succeed({
984+
login: "work",
985+
repository: "maria/project",
986+
baseUrl: "https://forgejo.test",
987+
}),
988+
api: (input) => {
989+
const repo = { full_name: "maria/project", owner: { login: "maria" } };
990+
const page = new URLSearchParams(input.path.split("?")[1]).get("page");
991+
return encodeJsonEffect(
992+
page === "1"
993+
? [
994+
{
995+
number: 7,
996+
title: "Merged feature",
997+
html_url: "https://forgejo.test/maria/project/pulls/7",
998+
state: "closed",
999+
merged: true,
1000+
merged_at: "2026-10-12T00:00:00Z",
1001+
base: { ref: "main", label: "main", sha: "base", repo },
1002+
head: { ref: "refs/pull/7/head", label: "feature", sha: "head", repo },
1003+
},
1004+
{
1005+
number: 8,
1006+
title: "Merged from a deleted fork",
1007+
html_url: "https://forgejo.test/maria/project/pulls/8",
1008+
state: "closed",
1009+
merged: true,
1010+
merged_at: "2026-10-11T00:00:00Z",
1011+
base: { ref: "main", label: "main", sha: "base", repo },
1012+
head: { ref: "refs/pull/8/head", label: "feature", sha: "fork", repo: null },
1013+
},
1014+
{
1015+
number: 9,
1016+
title: "AGit topic",
1017+
html_url: "https://forgejo.test/maria/project/pulls/9",
1018+
state: "closed",
1019+
merged: true,
1020+
merged_at: "2026-10-10T00:00:00Z",
1021+
flow: 1,
1022+
base: { ref: "main", label: "main", sha: "base", repo },
1023+
head: { ref: "refs/pull/9/head", label: "feature", sha: "topic", repo },
1024+
},
1025+
]
1026+
: [],
1027+
).pipe(Effect.orDie, Effect.map(processOutput));
1028+
},
1029+
}),
1030+
),
1031+
),
1032+
),
1033+
);
1034+
9641035
it.effect(
9651036
"loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations",
9661037
() => {

‎packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,13 @@ export const make = Effect.gen(function* () {
343343
Schema.Array(ForgejoPullRequestSchema),
344344
);
345345
for (const item of items) {
346+
const normalized = toForgejoChangeRequest(item);
346347
if (
347-
item.head.ref !== branch ||
348+
normalized.headRefName !== branch ||
348349
(source?.repository && item.head.repo?.full_name !== source.repository) ||
349350
(source?.owner && item.head.repo?.owner.login !== source.owner)
350351
)
351352
continue;
352-
const normalized = toForgejoChangeRequest(item);
353353
if (input.state === "all" || normalized.state === input.state) results.push(normalized);
354354
}
355355
if (items.length === 0) break;

‎packages/source-control-forgejo/src/server/forgejoPullRequestJson.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import * as Schema from "effect/Schema";
33

44
import {
55
ForgejoComment,
6+
ForgejoPullRequest,
67
ForgejoReview,
8+
forgejoChangeRequest,
79
forgejoComment,
810
forgejoReview,
911
} from "./forgejoPullRequestJson.ts";
1012

1113
const decodeForgejoComment = Schema.decodeSync(ForgejoComment);
14+
const decodeForgejoPullRequest = Schema.decodeSync(ForgejoPullRequest);
1215
const decodeForgejoReview = Schema.decodeSync(ForgejoReview);
1316

1417
describe("forgejoComment", () => {
@@ -41,6 +44,46 @@ describe("forgejoComment", () => {
4144
});
4245
});
4346

47+
describe("forgejoChangeRequest", () => {
48+
const headBranch = (
49+
head: { ref: string; label?: string; repo: { full_name: string } | null },
50+
flow?: number,
51+
) =>
52+
forgejoChangeRequest(
53+
decodeForgejoPullRequest({
54+
number: 7,
55+
title: "Merged feature",
56+
body: null,
57+
html_url: "https://forgejo.test/maria/project/pulls/7",
58+
user: null,
59+
state: "closed",
60+
merged: true,
61+
...(flow === undefined ? {} : { flow }),
62+
head: { ...head, sha: "head" },
63+
base: { ref: "main", label: "main", sha: "base", repo: null },
64+
created_at: "2026-10-10T19:52:40Z",
65+
updated_at: "2026-10-12T00:00:00Z",
66+
closed_at: "2026-10-12T00:00:00Z",
67+
merged_at: "2026-10-12T00:00:00Z",
68+
labels: null,
69+
}),
70+
).headBranch;
71+
72+
it("reads a deleted head branch from its label", () => {
73+
const head = { ref: "refs/pull/7/head", label: "feature" };
74+
const repo = { full_name: "maria/project" };
75+
assert.strictEqual(headBranch({ ...head, repo }, 0), "feature");
76+
// A deleted fork leaves no repository to say whose `feature` that was.
77+
assert.strictEqual(headBranch({ ...head, repo: null }), "refs/pull/7/head");
78+
// An AGit label is a `user/topic` name, not a branch.
79+
assert.strictEqual(headBranch({ ...head, label: "alice/topic", repo }, 1), "refs/pull/7/head");
80+
});
81+
82+
it("keeps the ref when the server sends no label", () => {
83+
assert.strictEqual(headBranch({ ref: "feature", repo: null }), "feature");
84+
});
85+
});
86+
4487
describe("forgejoReview", () => {
4588
it.each([
4689
["REQUEST_CHANGES", false, "CHANGES_REQUESTED"],

‎packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
} from "@t3tools/contracts";
1313
import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider";
1414
import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
15+
import { forgejoHeadBranch } from "./forgejoPullRequests.ts";
1516

1617
export const ForgejoUser = Schema.Struct({
1718
login: Schema.String,
@@ -36,6 +37,7 @@ export const ForgejoRepository = Schema.Struct({
3637
});
3738
const Branch = Schema.Struct({
3839
ref: Schema.String,
40+
label: Schema.optional(Schema.String),
3941
sha: Schema.String,
4042
repo: Schema.NullOr(ForgejoRepository),
4143
});
@@ -47,6 +49,7 @@ export const ForgejoPullRequest = Schema.Struct({
4749
user: Schema.NullOr(ForgejoUser),
4850
state: Schema.String,
4951
draft: Schema.optional(Schema.Boolean),
52+
flow: Schema.optional(Schema.Int),
5053
merged: Schema.Boolean,
5154
mergeable: Schema.optional(Schema.Boolean),
5255
is_locked: Schema.optional(Schema.Boolean),
@@ -130,7 +133,7 @@ export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) {
130133
title: pr.title,
131134
url: pr.html_url,
132135
author: forgejoActor(pr.user),
133-
headBranch: pr.head.ref,
136+
headBranch: forgejoHeadBranch(pr),
134137
baseBranch: pr.base.ref,
135138
headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null,
136139
state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open",

‎packages/source-control-forgejo/src/server/forgejoPullRequests.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,33 @@ const Repository = Schema.Struct({
99
});
1010
const Branch = Schema.Struct({
1111
ref: Schema.String,
12+
label: Schema.optional(Schema.String),
1213
sha: Schema.String,
1314
repo: Schema.NullOr(Repository),
1415
});
16+
// Once a pull request's head branch is deleted, Forgejo reports `ref` as `refs/pull/<N>/head`
17+
// and keeps the branch name only in `label`. An AGit pull request (`flow` 1) has no branch, only a
18+
// `user/topic` label, and without `repo` (a deleted fork) the label could name any repository's
19+
// branch, so the ref stays in both cases.
20+
export function forgejoHeadBranch(pr: {
21+
readonly flow?: number | undefined;
22+
readonly head: {
23+
readonly ref: string;
24+
readonly label?: string | undefined;
25+
readonly repo: unknown;
26+
};
27+
}) {
28+
const { ref, label, repo } = pr.head;
29+
return ref.startsWith("refs/pull/") && label && repo !== null && pr.flow !== 1 ? label : ref;
30+
}
1531
export const ForgejoPullRequestSchema = Schema.Struct({
1632
number: Schema.Int,
1733
title: Schema.String,
1834
html_url: Schema.String,
1935
state: Schema.String,
2036
merged: Schema.Boolean,
2137
draft: Schema.optional(Schema.Boolean),
38+
flow: Schema.optional(Schema.Int),
2239
base: Branch,
2340
head: Branch,
2441
closed_at: Schema.optional(Schema.NullOr(Schema.String)),
@@ -34,7 +51,7 @@ export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type
3451
state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open",
3552
isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title),
3653
baseRefName: raw.base.ref,
37-
headRefName: raw.head.ref,
54+
headRefName: forgejoHeadBranch(raw),
3855
...(raw.head.sha.trim().length > 0 ? { headSha: raw.head.sha.trim() } : {}),
3956
closedAt: raw.closed_at ?? null,
4057
mergedAt: raw.merged_at ?? null,

0 commit comments

Comments
 (0)