Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
6681622
fix(gitea): preserve reviews when viewer teams are unavailable
kalvenschraut Sep 8, 2026
6068bea
fix(server): normalize optional Gitea tracking flags
kalvenschraut Sep 8, 2026
a714159
fix(server): preserve Gitea revert capability types
kalvenschraut Sep 8, 2026
4f71faf
fix(pull-requests): reconcile tracking with bounded relationship reads
kalvenschraut Sep 8, 2026
7f8eff1
fix(pull-requests): preserve tracking and workflow repairs in revert
kalvenschraut Sep 8, 2026
3db03bf
fix(pull-requests): retain complete Gitea guard regressions
kalvenschraut Sep 8, 2026
d581105
Merge branch 'repair/pr-16' into repair/pr-17
kalvenschraut Sep 8, 2026
200680d
Merge branch 'repair/pr-17' into repair/pr-18
kalvenschraut Sep 8, 2026
9475b40
fix(pull-requests): retain shared comment bounds and truncation evidence
kalvenschraut Sep 8, 2026
6b18365
fix(pull-requests): preserve bounded reads with optional team enrichment
kalvenschraut Sep 8, 2026
77fba06
Merge branch 'repair/pr-21' into repair/pr-22
kalvenschraut Sep 8, 2026
d629781
Merge branch 'repair/pr-22' into repair/pr-23
kalvenschraut Sep 8, 2026
6af98d2
fix(pull-requests): preserve repaired native Gitea stack
kalvenschraut Sep 8, 2026
e8dcbbe
fix(pull-requests): integrate scoped refresh and repository navigatio…
kalvenschraut Sep 8, 2026
d3f4d68
fix(gitea): preserve reviews when viewer teams are unavailable
kalvenschraut Sep 8, 2026
03271ac
fix(server): omit tracking from Gitea dependency reads
kalvenschraut Sep 8, 2026
82672e8
Merge branch 'repair/pr-14' into repair/pr-15
kalvenschraut Sep 8, 2026
c8b8f84
Merge branch 'repair/pr-15' into repair/pr-16
kalvenschraut Sep 8, 2026
6e51f95
Merge branch 'repair/pr-16' into repair/pr-17
kalvenschraut Sep 8, 2026
0b6db6a
Merge branch 'repair/pr-17' into repair/pr-18
kalvenschraut Sep 8, 2026
9d21f1c
Merge branch 'repair/pr-18' into repair/pr-20
kalvenschraut Sep 8, 2026
8a0efa3
Merge branch 'repair/pr-20' into repair/pr-21
kalvenschraut Sep 8, 2026
8f11e31
Merge branch 'repair/pr-21' into repair/pr-22
kalvenschraut Sep 8, 2026
b9631f5
Merge branch 'repair/pr-22' into repair/pr-23
kalvenschraut Sep 8, 2026
6d81c31
fix(client): retain scoped dependency refresh after stack integration
kalvenschraut Sep 8, 2026
ca97a94
fix(server): restore Gitea team reviewer reads
kalvenschraut Sep 8, 2026
7931a18
Merge branch 'repair/pr-23' into repair/pr-32
kalvenschraut Sep 8, 2026
23db7d2
Merge branch 'repair/pr-21' into repair/pr-22
kalvenschraut Sep 8, 2026
896abfb
Merge branch 'repair/pr-22' into repair/pr-23
kalvenschraut Sep 8, 2026
192f071
fix(server): remove duplicate Gitea pagination option
kalvenschraut Sep 8, 2026
e011236
test(pull-requests): retain repository evidence in tracking fixture
kalvenschraut Sep 8, 2026
ef87852
Merge branch 'repair/pr-12' into repair/pr-14
kalvenschraut Sep 8, 2026
15392b5
Merge branch 'repair/pr-14' into repair/pr-15
kalvenschraut Sep 8, 2026
51126d3
Merge branch 'repair/pr-15' into repair/pr-16
kalvenschraut Sep 8, 2026
d6ba095
Merge branch 'repair/pr-16' into repair/pr-17
kalvenschraut Sep 8, 2026
5a66765
Merge branch 'repair/pr-17' into repair/pr-18
kalvenschraut Sep 8, 2026
ef42388
Merge branch 'repair/pr-18' into repair/pr-20
kalvenschraut Sep 8, 2026
146d2c6
Merge branch 'repair/pr-20' into repair/pr-21
kalvenschraut Sep 8, 2026
08cce87
Merge branch 'repair/pr-21' into repair/pr-22
kalvenschraut Sep 8, 2026
a1b46cd
Merge branch 'repair/pr-22' into repair/pr-23
kalvenschraut Sep 8, 2026
09848f4
Merge branch 'repair/pr-23' into repair/pr-32
kalvenschraut Sep 8, 2026
8e1c66d
test(pull-requests): retain typed lifecycle regression in integration
kalvenschraut Sep 8, 2026
8a521a0
fix(pull-requests): reconcile all repaired Gitea predecessors
kalvenschraut Sep 8, 2026
ed56479
Merge branch 'repair/pr-30' into repair/pr-36
kalvenschraut Sep 8, 2026
73dd9c3
Merge branch 'repair/pr-30' into repair/pr-36
kalvenschraut Sep 8, 2026
f2720c8
fix(gitea): reconcile tracking with workflow state repair
kalvenschraut Sep 8, 2026
dc10073
Merge branch 'repair/pr-12' into repair/pr-14
kalvenschraut Sep 8, 2026
78b43f3
Merge branch 'repair/pr-14' into repair/pr-15
kalvenschraut Sep 8, 2026
c9a774d
Merge branch 'repair/pr-15' into repair/pr-16
kalvenschraut Sep 8, 2026
bf73ebb
Merge branch 'repair/pr-12' into repair/pr-14
kalvenschraut Sep 8, 2026
07e86e1
Merge branch 'repair/pr-14' into repair/pr-15
kalvenschraut Sep 8, 2026
c206af2
Merge branch 'repair/pr-15' into repair/pr-16
kalvenschraut Sep 8, 2026
4dac54f
Merge branch 'repair/pr-16' into repair/pr-17
kalvenschraut Sep 8, 2026
cee6f83
Merge branch 'repair/pr-17' into repair/pr-18
kalvenschraut Sep 8, 2026
cfb7c08
Merge branch 'repair/pr-18' into repair/pr-20
kalvenschraut Sep 8, 2026
7f45a19
fix(gitea): report partial reviewing coverage when teams fail
kalvenschraut Sep 8, 2026
c5f5e62
test(gitea): retain remaining inline review budget coverage
kalvenschraut Sep 8, 2026
d64735f
Merge branch 'repair/pr-20' into repair/pr-21
kalvenschraut Sep 8, 2026
411bcab
Merge branch 'repair/pr-20' into repair/pr-21
kalvenschraut Sep 8, 2026
133d81c
Merge branch 'repair/pr-21' into repair/pr-22
kalvenschraut Sep 8, 2026
e8d78f6
Merge branch 'repair/pr-22' into repair/pr-23
kalvenschraut Sep 8, 2026
468963e
Merge branch 'repair/pr-23' into repair/pr-32
kalvenschraut Sep 8, 2026
bf4b4ec
fix(pull-requests): retain coverage warnings across continuation pages
kalvenschraut Sep 8, 2026
21ee583
Merge branch 'repair/pr-21' into repair/pr-22
kalvenschraut Sep 8, 2026
ad4b1fb
Merge branch 'repair/pr-22' into repair/pr-23
kalvenschraut Sep 8, 2026
63b543d
Merge branch 'repair/pr-23' into repair/pr-32
kalvenschraut Sep 8, 2026
520f30e
Merge branch 'repair/pr-30' into repair/pr-36
kalvenschraut Sep 8, 2026
2e156ad
Merge branch 'repair/pr-32' into repair/pr-36
kalvenschraut Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,12 +512,14 @@ layer("GiteaPullRequestApi", (it) => {
viewer: "",
limit: 200,
relationshipOnly: true,
includeTracking: true,
});

expect(page.items.map((item) => item.number)).toEqual([1]);
assert.strictEqual(page.consumed, 2);
assert.isTrue(page.truncated);
assert.strictEqual(mockedRequest.mock.calls.length, 1);
expect(callAt(0).path).not.toContain("include_tracking");
}),
);

Expand Down Expand Up @@ -1567,6 +1569,49 @@ layer("GiteaPullRequestApi", (it) => {
}),
);

it.effect("limits later reviews to the remaining shared inline-comment budget", () =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(
Effect.succeed(
response([
{ id: 21, body: "First review", submitted_at: "2026-09-03T11:00:00Z" },
{ id: 22, body: "Second review", submitted_at: "2026-09-03T12:00:00Z" },
]),
),
);
for (const [offset, length] of [
[0, 150],
[150, 100],
] as const) {
mockedRequest.mockReturnValueOnce(
Effect.succeed(
response(
Array.from({ length }, (_, index) => ({
id: offset + index + 31,
body: "Comment",
path: "src/a.ts",
position: 1,
created_at: "2026-09-03T11:01:00Z",
})),
),
),
);
}
const api = yield* GiteaPullRequestApi.make;
const result = yield* api.listReviews({
host: "forge.example.test",
repository: "acme/web",
number: 7,
});

const inlineComments = result.comments.filter((comment) => comment.kind === "review-comment");
expect(inlineComments).toHaveLength(200);
expect(inlineComments.at(-1)?.id).toBe("review-comment:230");
expect(result.truncated).toBe(true);
expect(mockedRequest).toHaveBeenCalledTimes(3);
}),
);

it.effect("follows pagination links when Gitea caps comment pages below the limit", () =>
Effect.gen(function* () {
mockedRequest
Expand Down Expand Up @@ -2555,6 +2600,60 @@ layer("GiteaPullRequestApi", (it) => {
}),
);

it.effect.each(
[undefined, "fix"].flatMap((query) => [403, 503].map((status) => ({ query, status }))),
)("preserves individual reviewing matches with partial team coverage (%j)", ({ query, status }) =>
Effect.gen(function* () {
const pullRequests = [
rawPullRequest(7),
rawPullRequest(8, {
requested_reviewers: [],
requested_reviewers_teams: [{ id: 9, name: "maintainers" }],
}),
];
mockedRequest.mockImplementation((input) => {
if (input.path.startsWith("/user/teams?"))
return Effect.fail(
new GiteaApi.GiteaApiError({
operation: "getViewerTeams",
reason: "failed",
detail: "Viewer teams could not be read.",
status,
}),
);
if (input.path.startsWith("/repos/acme/web/pulls?"))
return Effect.succeed(response(pullRequests));
if (input.path.startsWith("/repos/acme/web/issues?"))
return Effect.succeed(response([{ number: 7 }, { number: 8 }]));
const pullRequest = pullRequests.find(
(pullRequest) => input.path === `/repos/acme/web/pulls/${pullRequest.number}`,
);
if (pullRequest !== undefined) return Effect.succeed(response(pullRequest));
return Effect.die(`unexpected request: ${input.path}`);
});
const api = yield* GiteaPullRequestApi.make;
const page = yield* api.listPullRequests({
host: "forge.example.test",
repository: "acme/web",
state: "open",
involvement: "reviewing",
viewer: "reviewer",
limit: 10,
...(query === undefined ? {} : { query }),
});

expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([7]);
expect(page.consumed).toBe(2);
expect(page.truncated).toBe(false);
expect(page.coverageWarning).toBe(
"Some team review requests may be missing because Gitea team membership could not be read.",
);
expect(
mockedRequest.mock.calls.filter(([input]) => input.path.startsWith("/user/teams?")),
).toHaveLength(1);
}),
);

it.effect("includes pull requests requested from a viewer team in reviewing listings", () =>
Effect.gen(function* () {
mockedRequest.mockImplementation((input) => {
Expand Down
46 changes: 37 additions & 9 deletions apps/server/src/pullRequest/GiteaPullRequestApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ export class GiteaPullRequestApi extends Context.Service<
items: ReadonlyArray<GiteaPullRequest>;
truncated: boolean;
consumed: number;
coverageWarning?: string;
},
GiteaPullRequestApiError
>;
Expand Down Expand Up @@ -910,8 +911,16 @@ export const make = Effect.gen(function* () {
let rowsSkipped = 0;
let consumed = 0;
const collected: Array<GiteaPullRequest> = [];
const viewerTeamIDs =
input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set<number>();
const viewerTeams =
input.involvement === "reviewing"
? yield* getViewerTeams
: { ids: new Set<number>(), available: true };
const coverage = viewerTeams.available
? {}
: {
coverageWarning:
"Some team review requests may be missing because Gitea team membership could not be read.",
};

while (page <= MAX_PAGINATION_PAGES) {
const result = yield* readUnknownPage({
Expand Down Expand Up @@ -956,7 +965,7 @@ export const make = Effect.gen(function* () {
input.state,
input.involvement,
input.viewer,
viewerTeamIDs,
viewerTeams.ids,
)
)
continue;
Expand All @@ -971,6 +980,7 @@ export const make = Effect.gen(function* () {
}
return {
items: collected,
...coverage,
truncated: index < pageRows.length - 1 || next !== null,
consumed,
};
Expand All @@ -989,6 +999,7 @@ export const make = Effect.gen(function* () {
}
return {
items: collected,
...coverage,
truncated: false,
consumed,
};
Expand Down Expand Up @@ -1017,7 +1028,7 @@ export const make = Effect.gen(function* () {
"1 minute",
);

const getViewerTeamIDs = yield* Effect.cachedWithTTL(
const getViewerTeams = yield* Effect.cachedWithTTL(
Effect.suspend(() =>
Effect.gen(function* () {
const teamIDs = new Set<number>();
Expand All @@ -1037,7 +1048,7 @@ export const make = Effect.gen(function* () {
rowsSeen,
headers: response.headers,
});
if (next === null) return teamIDs;
if (next === null) return { ids: teamIDs, available: true };
path = next;
}
return yield* new GiteaPullRequestApiError({
Expand All @@ -1046,6 +1057,10 @@ export const make = Effect.gen(function* () {
detail: "Gitea viewer team pagination exceeded the safe page limit.",
});
}),
).pipe(
// Public-only tokens can read pull requests while Gitea rejects their team lookup.
// Team membership enriches individual review requests and must not hide those matches.
Effect.orElseSucceed(() => ({ ids: new Set<number>(), available: false })),
),
"1 minute",
);
Expand Down Expand Up @@ -1133,8 +1148,16 @@ export const make = Effect.gen(function* () {
const repositoryIdsByName = new Map<string, number>();
const expectedRepository = input.repository.trim().toLowerCase();
const collected: Array<GiteaPullRequest> = [];
const viewerTeamIDs =
input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set<number>();
const viewerTeams =
input.involvement === "reviewing"
? yield* getViewerTeams
: { ids: new Set<number>(), available: true };
const coverage = viewerTeams.available
? {}
: {
coverageWarning:
"Some team review requests may be missing because Gitea team membership could not be read.",
};
const maxPages = relationshipOnly ? DEPENDENCY_PAGINATION_PAGES : MAX_PAGINATION_PAGES;
let pagesRead = 0;
let prefetchedPage: UnknownPage | null = null;
Expand Down Expand Up @@ -1230,7 +1253,9 @@ export const make = Effect.gen(function* () {
else if (observed !== id) relationshipEvidenceIncomplete = true;
}
}
if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer, viewerTeamIDs)) {
if (
!matchesPullRequest(pr, input.state, input.involvement, input.viewer, viewerTeams.ids)
) {
relationshipEvidenceIncomplete = relationshipOnly || relationshipEvidenceIncomplete;
continue;
}
Expand All @@ -1245,6 +1270,7 @@ export const make = Effect.gen(function* () {
}
return {
items: collected,
...coverage,
truncated:
relationshipEvidenceIncomplete || index < pageRows.length - 1 || next !== null,
consumed,
Expand All @@ -1264,6 +1290,7 @@ export const make = Effect.gen(function* () {
}
return {
items: collected,
...coverage,
truncated: relationshipOnly && (relationshipEvidenceIncomplete || next !== null),
consumed,
};
Expand Down Expand Up @@ -2133,11 +2160,12 @@ export const make = Effect.gen(function* () {
}),
limit: PAGE_SIZE,
}),
readUnknownArray({
readUnknownPage({
operation: "listTeamReviewerCandidates",
...input,
path: `${basePath(input.repository)}/teams`,
}).pipe(
Effect.map((page) => page.rows),
Effect.catch((error) =>
isGiteaApiError(error.cause) && error.cause.status === 405
? Effect.succeed([])
Expand Down
54 changes: 54 additions & 0 deletions apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,60 @@ describe("GiteaPullRequestProvider", () => {
expect("autoMergeEnabled" in detail).toBe(false);
}),
);
it.effect.each([undefined, "fix"])(
"retains Reviewing coverage warnings in provider listings (%s)",
(query) =>
Effect.gen(function* () {
const pull = { ...rawPullRequest(), requested_reviewers: [{ login: "reader" }] };
const request = vi.fn<GiteaApi.GiteaApi["Service"]["request"]>((input) => {
if (input.path.startsWith("/user/teams?"))
return Effect.fail(
new GiteaApi.GiteaApiError({
operation: "getViewerTeams",
reason: "failed",
status: 403,
detail: "Teams unavailable",
}),
);
if (input.path.startsWith("/repos/acme/web/pulls?"))
return Effect.succeed(response([pull]));
if (input.path.startsWith("/repos/acme/web/issues?"))
return Effect.succeed(response([{ number: 7 }]));
if (input.path.startsWith("/repos/acme/web/pulls/7"))
return Effect.succeed(response(pull));
return Effect.die(`Unexpected Gitea request: ${input.path}`);
});
const apiLayer = GiteaPullRequestApi.layer.pipe(
Layer.provide(
Layer.succeed(
GiteaApi.GiteaApi,
GiteaApi.GiteaApi.of({
baseUrl: Option.some("https://forge.example.test/gitea"),
sshHosts: [],
request,
probeAuth: Effect.die("not used"),
}),
),
),
);
const provider = yield* makeGiteaPullRequestProvider.pipe(Effect.provide(apiLayer));
const page = yield* provider.listChangeRequests({
cwd: "/repo",
host: "forge.example.test",
repository: "acme/web",
state: "open",
involvement: "reviewing",
viewer: "reader",
limit: 10,
...(query === undefined ? {} : { query }),
});
expect(page.items.map((item) => item.number)).toEqual([7]);
expect(page.coverageWarning).toBe(
"Some team review requests may be missing because Gitea team membership could not be read.",
);
expect(page.truncated).toBe(false);
}),
);
for (const discovery of ["failed", "incomplete"] as const) {
it.effect(
`keeps workflow approval state unknown when supported discovery is ${discovery}`,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/pullRequest/GiteaPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ export const make = Effect.gen(function* () {
items: page.items.map((pullRequest) =>
giteaToChangeRequest(pullRequest, input.relationshipOnly === true),
),
...(page.coverageWarning === undefined
? {}
: { coverageWarning: page.coverageWarning }),
truncated: page.truncated,
cursorAdvance: page.consumed,
continues: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/pullRequest/PullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ export interface ProviderChangeRequestSummary {
}

export interface ProviderChangeRequestPage {
/** Optional enrichment failed; pagination still describes only the rows the host returned. */
readonly coverageWarning?: string;
readonly items: ReadonlyArray<ProviderChangeRequest>;
/** True when the host has more rows than the page size asked for. */
readonly truncated: boolean;
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,41 @@ it.effect("keeps healthy repositories when one of them cannot be read", () =>
}),
);

it.effect(
"returns partial listing coverage with readable rows and no fabricated continuation",
() =>
Effect.gen(function* () {
const service = yield* makeService({
projects: [
project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
],
providers: [
fakeProvider("github", {
listChangeRequests: () =>
Effect.succeed({
items: [changeRequest(1, "2026-07-02T00:00:00Z")],
coverageWarning: "Team review requests may be missing.",
truncated: false,
continues: true,
}),
}),
],
});
const result = yield* service.list({ state: "open", involvement: "reviewing" });
assert.strictEqual(result.entries.length, 1);
assert.deepStrictEqual(result.errors, [
{
projectId: "p1" as ProjectId,
projectTitle: "web",
message: "Team review requests may be missing.",
partial: true,
},
]);
assert.isFalse(result.truncated);
assert.deepStrictEqual(result.nextCursors, {});
}),
);

it.effect("tries another workspace on the same host for the viewer", () =>
Effect.gen(function* () {
const service = yield* makeService({
Expand Down
Loading