diff --git a/apps/server/src/pullRequest/GiteaConversation.test.ts b/apps/server/src/pullRequest/GiteaConversation.test.ts index c36ceecf1d94..23afa88ba6b3 100644 --- a/apps/server/src/pullRequest/GiteaConversation.test.ts +++ b/apps/server/src/pullRequest/GiteaConversation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; import { editableCommentId, @@ -23,10 +24,10 @@ describe("GiteaConversation", () => { it("groups supported Gitea reactions and names the signed-in viewer separately", () => { const rows: ReadonlyArray = [ - { reaction: "+1", user: { login: "Reader" } }, - { reaction: "+1", user: { login: "teammate" } }, - { reaction: "heart", user: { login: "friend" } }, - { reaction: "party", user: { login: "ignored" } }, + { content: "+1", user: { login: "Reader" } }, + { content: "+1", user: { login: "teammate" } }, + { content: "heart", user: { login: "friend" } }, + { content: "party", user: { login: "ignored" } }, ]; expect(reactionsForViewer(rows, "reader")).toEqual([ @@ -35,6 +36,19 @@ describe("GiteaConversation", () => { ]); }); + it("decodes and groups the native Gitea reaction response shape", () => { + const decodeReaction = Schema.decodeUnknownSync(RawGiteaReaction); + const row = decodeReaction({ + content: "+1", + created_at: "2026-09-05T00:00:00Z", + user: { id: 7, login: "kalvens", full_name: "Kalven" }, + }); + + expect(reactionsForViewer([row], "Kalvens")).toEqual([ + { content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }, + ]); + }); + it("uses Gitea's reaction spelling on writes", () => { expect(nativeReactionContent("thumbs-up")).toBe("+1"); expect(nativeReactionContent("heart")).toBe("heart"); diff --git a/apps/server/src/pullRequest/GiteaConversation.ts b/apps/server/src/pullRequest/GiteaConversation.ts index 2831c6cdd992..9fc61dad9d96 100644 --- a/apps/server/src/pullRequest/GiteaConversation.ts +++ b/apps/server/src/pullRequest/GiteaConversation.ts @@ -7,7 +7,7 @@ const RawReactionUser = Schema.Struct({ /** The shape returned by Gitea's issue and issue-comment reaction endpoints. */ export const RawGiteaReaction = Schema.Struct({ - reaction: Schema.optional(Schema.String), + content: Schema.String, user: Schema.optional(Schema.NullOr(RawReactionUser)), }); @@ -71,7 +71,7 @@ export function reactionsForViewer( { count: number; actors: Array; viewerHasReacted: boolean } >(); for (const row of rows) { - const content = row.reaction === undefined ? undefined : reactionContent.get(row.reaction); + const content = reactionContent.get(row.content); if (content === undefined) continue; const group = groups.get(content) ?? { count: 0, actors: [], viewerHasReacted: false }; group.count += 1; diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 64c1b9205a34..49540f2078ad 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -6,6 +6,7 @@ import * as Schema from "effect/Schema"; import * as GiteaApi from "../sourceControl/GiteaApi.ts"; import * as GiteaPullRequestApi from "./GiteaPullRequestApi.ts"; +import * as GiteaPullRequestProvider from "./GiteaPullRequestProvider.ts"; const mockedRequest = vi.fn(); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); @@ -144,6 +145,60 @@ it.effect("keeps a search hydration transport failure fatal", () => ); layer("GiteaPullRequestApi", (it) => { + it.effect("preserves tracking rows while keeping dependency reads lightweight", () => + Effect.gen(function* () { + mockedRequest.mockImplementation(() => + Effect.succeed( + response( + [ + rawPullRequest(1, { + head: { + ref: "feature", + label: "feature", + sha: "head-sha", + repo: { full_name: "acme/web", id: 1 }, + }, + review_decision: "approved", + checks_state: "failing", + }), + ], + { "x-total-count": "1" }, + ), + ), + ); + const provider = yield* GiteaPullRequestProvider.make.pipe( + Effect.provide(GiteaPullRequestApi.layer), + ); + const input = { + cwd: "/workspace", + host: "forge.example.test", + repository: "acme/web", + state: "open" as const, + involvement: "all" as const, + viewer: "", + limit: 200, + }; + const dependencies = yield* provider.listChangeRequests({ ...input, relationshipOnly: true }); + expect(dependencies.items).toHaveLength(1); + expect(dependencies.items[0]).toMatchObject({ + headBranch: "feature", + headRepositoryNameWithOwner: "acme/web", + headBranchAvailable: true, + }); + expect(dependencies.truncated).toBe(false); + expect(callAt(0).path).not.toContain("include_tracking"); + const listing = yield* provider.listChangeRequests(input); + expect(listing.items[0]).toMatchObject({ + reviewDecision: "approved", + checksState: "failing", + }); + expect(callAt(1).path).toContain("include_tracking=true"); + const api = yield* GiteaPullRequestApi.make; + yield* api.listPullRequests({ ...input, relationshipOnly: true, includeTracking: true }); + expect(callAt(2).path).not.toContain("include_tracking"); + expect(mockedRequest).toHaveBeenCalledTimes(3); + }), + ); it.effect("reconstructs auto-merge from the timeline when discovery is unavailable", () => Effect.gen(function* () { mockedRequest @@ -168,6 +223,39 @@ layer("GiteaPullRequestApi", (it) => { expect(callAt(1).path).toContain("/timeline?"); }), ); + it.effect("opens a native revert PR only on an advertising Gitea server", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response({ features: ["pull-revert"] }))) + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(8)))); + const api = yield* GiteaPullRequestApi.make; + yield* api.runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "revert", + }); + expect(callAt(1)).toMatchObject({ method: "POST", path: "/repos/acme/web/pulls/7/revert" }); + expect(mockedRequest.mock.calls).toHaveLength(2); + }), + ); + it.effect("does not attempt a revert on stock Gitea", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response({ features: [] }))); + const api = yield* GiteaPullRequestApi.make; + const error = yield* api + .runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "revert", + }) + .pipe(Effect.flip); + expect(error.detail).toContain("does not expose native pull request reverts"); + expect(mockedRequest.mock.calls.every(([call]) => call.method === "GET")).toBe(true); + }), + ); + it.effect("approves only the current pull request's waiting workflow runs", () => Effect.gen(function* () { const pull = rawPullRequest(7); @@ -318,6 +406,51 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("decodes nullable tracking summaries when explicitly requested", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(7, { + review_decision: "approved", + checks_state: "passing", + }), + ), + ), + ); + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(8, { + review_decision: null, + checks_state: null, + }), + ), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const pullRequest = yield* api.getPullRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + includeTracking: true, + }); + + expect(pullRequest.reviewDecision).toBe("approved"); + expect(pullRequest.checksState).toBe("passing"); + expect(callAt(0).path).toBe("/repos/acme/web/pulls/7?include_tracking=true"); + + const nullablePullRequest = yield* api.getPullRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 8, + includeTracking: true, + }); + expect(nullablePullRequest.reviewDecision).toBeNull(); + expect(nullablePullRequest.checksState).toBeNull(); + }), + ); + it.effect("keeps merged and closed pull requests distinct and counts malformed rows", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce( @@ -347,6 +480,7 @@ layer("GiteaPullRequestApi", (it) => { involvement: "all", viewer: "reviewer", limit: 2, + includeTracking: true, }); expect(page.items.map((item) => [item.number, item.state])).toEqual([ @@ -357,6 +491,7 @@ layer("GiteaPullRequestApi", (it) => { assert.isFalse(page.truncated); expect(callAt(0).path).toContain("state=closed"); expect(callAt(0).path).toContain("sort=recentupdate"); + expect(callAt(0).path).toContain("include_tracking=true"); }), ); @@ -584,6 +719,7 @@ layer("GiteaPullRequestApi", (it) => { it.effect("walks later pages until involvement filtering fills the requested slice", () => Effect.gen(function* () { mockedRequest + .mockReturnValueOnce(Effect.succeed(response([]))) .mockReturnValueOnce( Effect.succeed( response( @@ -609,7 +745,7 @@ layer("GiteaPullRequestApi", (it) => { expect(page.items.map((item) => item.number)).toEqual([51]); assert.strictEqual(page.consumed, 51); assert.isFalse(page.truncated); - expect(callAt(1).path).toContain("page=2"); + expect(callAt(2).path).toContain("page=2"); }), ); @@ -745,6 +881,41 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("passes tracking opt-in through native search and pull hydration", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response([{ number: 7 }]))) + .mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(7, { + review_decision: "review-required", + checks_state: "failing", + }), + ), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reviewer", + limit: 1, + query: "needs review", + includeTracking: true, + }); + + expect(page.items[0]).toMatchObject({ + reviewDecision: "review-required", + checksState: "failing", + }); + expect(callAt(0).path).toContain("include_tracking=true"); + expect(callAt(1).path).toBe("/repos/acme/web/pulls/7?include_tracking=true"); + }), + ); + it.effect("returns a page-boundary search match without requesting the page after the cap", () => Effect.gen(function* () { mockedRequest.mockImplementation((request) => { @@ -1016,7 +1187,7 @@ layer("GiteaPullRequestApi", (it) => { }), ); - it.effect("does not turn omitted repository permissions into a denial", () => + it.effect("does not advertise writes or merge methods from incomplete repository settings", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); const api = yield* GiteaPullRequestApi.make; @@ -1026,13 +1197,13 @@ layer("GiteaPullRequestApi", (it) => { }); expect(access).toEqual({ - canWrite: true, + canWrite: false, mergeCapabilities: { - merge: true, - squash: true, - rebase: true, + merge: false, + squash: false, + rebase: false, }, - updateMethods: ["merge", "rebase"], + updateMethods: [], }); }), ); @@ -1119,7 +1290,225 @@ layer("GiteaPullRequestApi", (it) => { ], }), ]); - expect(callAt(1).path).toBe("/repos/acme/web/pulls/7/reviews/21/comments"); + expect(callAt(1).path).toBe("/repos/acme/web/pulls/7/reviews/21/comments?page=1&limit=50"); + }), + ); + + it.effect( + "marks review activity truncated when nested review comments exceed the conversation bound", + () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([ + { id: 21, body: "Review", state: "COMMENT", submitted_at: "2026-09-03T11:00:00Z" }, + ]), + ), + ); + for (let page = 0; page < 4; page += 1) { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 50 }, (_, index) => ({ + id: 31 + page * 50 + index, + body: "Comment", + path: "src/a.ts", + position: 1, + created_at: "2026-09-03T11:01:00Z", + })), + { "x-total-count": "501" }, + ), + ), + ); + } + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + assert.isTrue(result.truncated); + expect(result.comments).toContainEqual( + expect.objectContaining({ id: "review-comment:31" }), + ); + expect(result.comments).toHaveLength(201); + expect(callAt(4).path).toContain("page=4"); + expect(mockedRequest).toHaveBeenCalledTimes(5); + }), + ); + + it.effect.each([1, 4])( + "marks inline comments incomplete when page %i has no pagination evidence", + (pageCount) => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([ + { id: 21, body: "Review", state: "COMMENT", submitted_at: "2026-09-03T11:00:00Z" }, + ]), + ), + ); + for (let page = 1; page <= pageCount; page += 1) { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 50 }, (_, index) => ({ + id: page * 50 + index, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 1, + created_at: "2026-09-03T11:01:00Z", + })), + page === pageCount ? {} : { "x-total-count": "201" }, + ), + ), + ); + } + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + assert.isTrue(result.truncated); + expect(result.comments.filter((comment) => comment.kind === "review-comment")).toHaveLength( + 50 * pageCount, + ); + expect(mockedRequest).toHaveBeenCalledTimes(1 + pageCount); + }), + ); + + it.effect("does not repeat an unpaginated native review-comment response at the page size", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { + id: 21, + body: "Review", + state: "COMMENT", + submitted_at: "2026-09-03T11:00:00Z", + }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 51 }, (_, index) => ({ + id: index + 31, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 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, + }); + + assert.isFalse(result.truncated); + assert.strictEqual( + result.comments.filter((comment) => comment.kind === "review-comment").length, + 51, + ); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + + it.effect("does not mark an exact unpaginated review-comment safety bound as truncated", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { + id: 21, + body: "Review", + state: "COMMENT", + submitted_at: "2026-09-03T11:00:00Z", + }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 200 }, (_, index) => ({ + id: index + 31, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 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, + }); + + assert.isFalse(result.truncated); + assert.strictEqual( + result.comments.filter((comment) => comment.kind === "review-comment").length, + 200, + ); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + + it.effect("shares the raw inline-comment budget across reviews", () => + 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" }, + { id: 23, body: "Third review", submitted_at: "2026-09-03T13:00:00Z" }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response([ + ...Array.from({ length: 199 }, (_, index) => ({ + id: index + 31, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 1, + created_at: "2026-09-03T11:01:00Z", + })), + { id: "malformed" }, + ]), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect( + result.comments.filter((comment) => comment.kind === "review").map((comment) => comment.id), + ).toEqual(["review:21", "review:22", "review:23"]); + expect(result.comments.filter((comment) => comment.kind === "review-comment")).toHaveLength( + 199, + ); + assert.isTrue(result.truncated); + expect(mockedRequest).toHaveBeenCalledTimes(2); + expect(callAt(1).path).toContain("/reviews/21/comments?"); }), ); @@ -1237,6 +1626,36 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("maps native warning statuses to failing checks", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response({ + total_count: 1, + statuses: [{ context: "scan", status: "warning", updated_at: "2026-09-03T11:00:00Z" }], + }), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const checks = yield* api.listChecks({ + host: "forge.example.test", + repository: "acme/web", + sha: "head-sha", + }); + expect(checks).toEqual([expect.objectContaining({ name: "scan", status: "failure" })]); + }), + ); + + it.effect("does not request commit statuses without a head revision", () => + Effect.gen(function* () { + const api = yield* GiteaPullRequestApi.make; + expect( + yield* api.listChecks({ host: "forge.example.test", repository: "acme/web", sha: "" }), + ).toEqual([]); + expect(mockedRequest).not.toHaveBeenCalled(); + }), + ); + it.effect("reads every capped page of commit statuses and keeps the newest context", () => Effect.gen(function* () { mockedRequest @@ -1562,13 +1981,17 @@ layer("GiteaPullRequestApi", (it) => { .mockReturnValueOnce( Effect.succeed( response([ - { reaction: "+1", user: { login: "reader" } }, - { reaction: "+1", user: { login: "teammate" } }, + { + content: "+1", + created_at: "2026-09-05T00:00:00Z", + user: { login: "reader" }, + }, + { content: "+1", user: { login: "teammate" } }, ]), ), ) .mockReturnValueOnce( - Effect.succeed(response([{ reaction: "heart", user: { login: "friend" } }])), + Effect.succeed(response([{ content: "heart", user: { login: "friend" } }])), ) .mockReturnValueOnce(Effect.succeed(response([]))); const api = yield* GiteaPullRequestApi.make; @@ -1597,17 +2020,41 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("treats a native null reaction list as empty without dropping other subjects", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response({ features: [] }))) + .mockReturnValueOnce(Effect.succeed(response(null))) + .mockReturnValueOnce( + Effect.succeed(response([{ content: "heart", user: { login: "friend" } }])), + ); + const api = yield* GiteaPullRequestApi.make; + const reactions = yield* api.listConversationReactions({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + viewer: "Reader", + subjectIds: ["issue:12"], + }); + + expect(reactions.pullRequest).toEqual([]); + expect(reactions.bySubjectId.get("issue:12")).toEqual([ + { content: "heart", count: 1, actors: ["friend"], viewerHasReacted: false }, + ]); + }), + ); + it.effect("follows a reaction list when Gitea caps a requested page below its limit", () => Effect.gen(function* () { mockedRequest.mockImplementation((input) => { if (input.path === "/settings/api") return Effect.succeed(response({ features: [] })); if (input.path === "/repos/acme/web/issues/7/reactions?page=1&limit=50") return Effect.succeed( - response([{ reaction: "heart", user: { login: "one" } }], { "x-total-count": "2" }), + response([{ content: "heart", user: { login: "one" } }], { "x-total-count": "2" }), ); if (input.path === "/repos/acme/web/issues/7/reactions?page=2&limit=50") return Effect.succeed( - response([{ reaction: "eyes", user: { login: "two" } }], { "x-total-count": "2" }), + response([{ content: "eyes", user: { login: "two" } }], { "x-total-count": "2" }), ); return Effect.die(`unexpected request: ${input.path}`); }); @@ -1850,6 +2297,7 @@ layer("GiteaPullRequestApi", (it) => { id, type: "comment", })), + { "x-total-count": "50" }, ), ), ) @@ -1867,17 +2315,20 @@ layer("GiteaPullRequestApi", (it) => { }), ); - it.effect("honors a server timeline page-size cap before reading the final merge state", () => + it.effect("follows a timeline next link before reading the final merge state", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce(Effect.succeed(response({ features: [] }))); mockedRequest.mockReturnValueOnce( Effect.succeed( - response([{ id: 1, type: "pull_scheduled_merge" }], { "x-total-count": "2" }), + response([{ id: 1, type: "pull_scheduled_merge" }], { + link: '; rel="next"', + "x-total-count": "1", + }), ), ); mockedRequest.mockReturnValueOnce( Effect.succeed( - response([{ id: 2, type: "pull_cancel_scheduled_merge" }], { "x-total-count": "2" }), + response([{ id: 2, type: "pull_cancel_scheduled_merge" }], { "x-total-count": "1" }), ), ); const api = yield* GiteaPullRequestApi.make; @@ -1888,7 +2339,134 @@ layer("GiteaPullRequestApi", (it) => { number: 7, }), ); - expect(callAt(2).path).toContain("page=2"); + expect(callAt(2).path).toBe("/repos/acme/web/issues/7/timeline?page=2&limit=1"); + }), + ); + + it.effect("includes requested native teams in reviewer candidates and sends their names", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path === "/repos/acme/web/pulls/7") + return Effect.succeed( + response( + rawPullRequest(7, { + requested_reviewers_teams: [ + { id: 41, name: "maintainers", organization: { username: "acme" } }, + ], + }), + ), + ); + if (input.path.startsWith("/repos/acme/web/reviewers?")) + return Effect.succeed(response([{ id: 2, login: "reviewer" }])); + if (input.path === "/repos/acme/web/teams") + return Effect.succeed( + response([{ id: 41, name: "maintainers", organization: { username: "acme" } }]), + ); + if (input.path === "/repos/acme/web/pulls/7/requested_reviewers") + return Effect.succeed(response({})); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const candidates = yield* api.listReviewerCandidates({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(candidates).toEqual({ + candidates: [ + expect.objectContaining({ id: "reviewer", kind: "user", isRequested: true }), + expect.objectContaining({ + id: "maintainers", + kind: "team", + login: "maintainers", + name: "acme", + isRequested: true, + }), + ], + truncated: false, + }); + yield* api.setReviewerRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + requested: true, + reviewers: [candidates.candidates[1]!], + }); + const request = callAt(3); + expect(decodeJson(request.body ?? "{}")).toEqual({ + reviewers: [], + team_reviewers: ["maintainers"], + }); + }), + ); + + it.effect("treats a native repository team 405 as a personal repository", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path === "/repos/acme/web/pulls/7") + return Effect.succeed(response(rawPullRequest(7))); + if (input.path.startsWith("/repos/acme/web/reviewers?")) + return Effect.succeed(response([{ id: 2, login: "reviewer" }])); + if (input.path === "/repos/acme/web/teams") + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "listTeamReviewerCandidates", + reason: "failed", + detail: "Gitea returned HTTP 405.", + status: 405, + }), + ); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const candidates = yield* api.listReviewerCandidates({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(candidates.candidates).toEqual([ + expect.objectContaining({ id: "reviewer", kind: "user" }), + ]); + }), + ); + + it.effect("includes pull requests requested from a viewer team in reviewing listings", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path === "/user/teams?page=1&limit=50") + return Effect.succeed(response([{ id: 4, name: "first" }], { "x-total-count": "2" })); + if (input.path === "/user/teams?page=2&limit=50") + return Effect.succeed( + response([{ id: 9, name: "maintainers" }], { "x-total-count": "2" }), + ); + if (input.path.startsWith("/repos/acme/web/pulls?")) + return Effect.succeed( + response( + [ + rawPullRequest(7, { + requested_reviewers: [], + requested_reviewers_teams: [{ id: 9, name: "maintainers" }], + }), + ], + { "x-total-count": "1" }, + ), + ); + 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: "viewer", + limit: 10, + }); + + expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([7]); + assert.strictEqual(mockedRequest.mock.calls.length, 3); }), ); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 236256e4e7c9..260b63d9cf8f 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -4,6 +4,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { + PullRequestChecksState as PullRequestChecksStateSchema, + PullRequestReviewDecision as PullRequestReviewDecisionSchema, +} from "@t3tools/contracts"; import type { PullRequestAction, PullRequestActor, @@ -19,7 +23,9 @@ import type { PullRequestMergeability, PullRequestReaction, PullRequestReactionContent, + PullRequestChecksState, PullRequestReviewCommentDraft, + PullRequestReviewDecision, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -87,6 +93,13 @@ const RawLabel = Schema.Struct({ color: Schema.optional(Schema.NullOr(Schema.String)), description: Schema.optional(Schema.NullOr(Schema.String)), }); +const RawTeam = Schema.Struct({ + id: Schema.optional(Schema.Int), + name: Schema.optional(Schema.String), + organization: Schema.optional( + Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.String) })), + ), +}); const RawPullRequest = Schema.Struct({ number: Schema.Int, title: Schema.String, @@ -95,6 +108,8 @@ const RawPullRequest = Schema.Struct({ merged: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.Boolean)), draft: Schema.optional(Schema.Boolean), + review_decision: Schema.optional(Schema.NullOr(PullRequestReviewDecisionSchema)), + checks_state: Schema.optional(Schema.NullOr(PullRequestChecksStateSchema)), auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), auto_merge_method: Schema.optional(Schema.NullOr(Schema.String)), html_url: Schema.String, @@ -111,6 +126,7 @@ const RawPullRequest = Schema.Struct({ base: RawBranch, head: RawBranch, requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUser))), + requested_reviewers_teams: Schema.optional(Schema.NullOr(Schema.Array(RawTeam))), labels: Schema.optional(Schema.NullOr(Schema.Array(RawLabel))), merge_base: Schema.optional(Schema.String), }); @@ -202,12 +218,14 @@ type RawCommitStatus = NonNullable<(typeof RawCombinedStatus.Type)["statuses"]>[ const decodeRow = Schema.decodeUnknownOption(RawPullRequest); const decodeUser = Schema.decodeUnknownOption(RawUser); +const decodeTeam = Schema.decodeUnknownOption(RawTeam); const decodeComment = Schema.decodeUnknownOption(RawComment); const decodeReview = Schema.decodeUnknownOption(RawReview); const decodeReviewComment = Schema.decodeUnknownOption(RawReviewComment); const decodeCommit = Schema.decodeUnknownOption(RawCommit); const decodeLabel = Schema.decodeUnknownOption(RawLabel); const decodeReaction = Schema.decodeUnknownOption(RawGiteaReaction); +const isGiteaApiError = Schema.is(GiteaApi.GiteaApiError); const encodeObject = Schema.encodeSync( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); @@ -240,11 +258,15 @@ export interface GiteaPullRequest { readonly mergedAt: string | null; readonly closedAt: string | null; readonly reviewRequestLogins: ReadonlyArray; + readonly reviewRequestTeamIDs: ReadonlyArray; + readonly reviewRequestTeamNames: ReadonlyArray; readonly reviewers: ReadonlyArray; readonly labels: ReadonlyArray; readonly commentCount: number; readonly autoMergeEnabled?: boolean; readonly autoMergeMethod?: PullRequestMergeMethod; + readonly reviewDecision?: PullRequestReviewDecision | null; + readonly checksState?: PullRequestChecksState | null; } export interface GiteaRepositoryAccess { @@ -332,6 +354,13 @@ function pullRequest(value: RawPullRequest): GiteaPullRequest | null { mergedAt: iso(value.merged_at), closedAt: iso(value.closed_at), reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewRequestTeamIDs: (value.requested_reviewers_teams ?? []).flatMap((team) => + team.id === undefined ? [] : [team.id], + ), + reviewRequestTeamNames: (value.requested_reviewers_teams ?? []).flatMap((team) => { + const name = team.name?.trim(); + return name ? [name] : []; + }), reviewers, labels: (value.labels ?? []).flatMap((label) => { const name = label.name?.trim(); @@ -344,6 +373,8 @@ function pullRequest(value: RawPullRequest): GiteaPullRequest | null { ...(["merge", "squash", "rebase"].includes(value.auto_merge_method ?? "") ? { autoMergeMethod: value.auto_merge_method as PullRequestMergeMethod } : {}), + ...(value.review_decision === undefined ? {} : { reviewDecision: value.review_decision }), + ...(value.checks_state === undefined ? {} : { checksState: value.checks_state }), }; } @@ -352,13 +383,15 @@ function matchesPullRequest( state: PullRequestListState, involvement: PullRequestInvolvement, viewer: string, + viewerTeamIDs: ReadonlySet = new Set(), ): boolean { if (state !== "all" && value.state !== state) return false; if (involvement === "authored" && value.author?.login.toLowerCase() !== viewer.toLowerCase()) return false; if ( involvement === "reviewing" && - !value.reviewRequestLogins.some((login) => login.toLowerCase() === viewer.toLowerCase()) + !value.reviewRequestLogins.some((login) => login.toLowerCase() === viewer.toLowerCase()) && + !value.reviewRequestTeamIDs.some((id) => viewerTeamIDs.has(id)) ) return false; return true; @@ -480,6 +513,7 @@ export class GiteaPullRequestApi extends Context.Service< readonly limit: number; readonly query?: string; readonly cursor?: ProviderListCursor; + readonly includeTracking?: boolean; readonly relationshipOnly?: boolean; }) => Effect.Effect< { @@ -493,6 +527,7 @@ export class GiteaPullRequestApi extends Context.Service< host: string; repository: string; number: number; + includeTracking?: boolean; }) => Effect.Effect; readonly getRepositoryAccess: (input: { host: string; @@ -722,6 +757,7 @@ export const make = Effect.gen(function* () { host: string; repository: string; number: number; + includeTracking?: boolean; }) { const operation = "getPullRequest"; const response = yield* request({ @@ -729,7 +765,9 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, method: "GET", - path: `${basePath(input.repository)}/pulls/${input.number}`, + path: query(`${basePath(input.repository)}/pulls/${input.number}`, { + include_tracking: input.includeTracking === true ? "true" : undefined, + }), }); const raw = yield* decode(operation, RawPullRequest, response); const mapped = pullRequest(raw); @@ -823,9 +861,23 @@ export const make = Effect.gen(function* () { host: string; repository: string; path: string; + nullAsEmpty?: boolean; }) { - const response = yield* request({ ...input, method: "GET" }); - const rows = yield* decode(input.operation, Schema.Array(Schema.Unknown), response); + const response = yield* request({ + operation: input.operation, + host: input.host, + repository: input.repository, + path: input.path, + method: "GET", + }); + const decoded = yield* decode( + input.operation, + input.nullAsEmpty + ? Schema.NullOr(Schema.Array(Schema.Unknown)) + : Schema.Array(Schema.Unknown), + response, + ); + const rows = decoded ?? []; return { rows, headers: response.headers } satisfies UnknownPage; }); @@ -839,6 +891,7 @@ export const make = Effect.gen(function* () { readonly limit: number; readonly query: string; readonly cursor?: ProviderListCursor; + readonly includeTracking?: boolean; }) { const wanted = Math.max(1, input.limit); const delivered = input.cursor?.delivered ?? 0; @@ -851,11 +904,14 @@ export const make = Effect.gen(function* () { viewer: input.viewer, page, limit: PAGE_SIZE, + includeTracking: input.includeTracking === true, }); let rowsSeen = 0; let rowsSkipped = 0; let consumed = 0; const collected: Array = []; + const viewerTeamIDs = + input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set(); while (page <= MAX_PAGINATION_PAGES) { const result = yield* readUnknownPage({ @@ -885,6 +941,7 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, number, + includeTracking: input.includeTracking === true, }); }, { concurrency: SEARCH_HYDRATION_CONCURRENCY }, @@ -893,7 +950,15 @@ export const make = Effect.gen(function* () { for (const [index, pullRequest] of hydrated.entries()) { consumed += 1; if (pullRequest === null) continue; - if (!matchesPullRequest(pullRequest, input.state, input.involvement, input.viewer)) + if ( + !matchesPullRequest( + pullRequest, + input.state, + input.involvement, + input.viewer, + viewerTeamIDs, + ) + ) continue; collected.push(pullRequest); if (collected.length === wanted) { @@ -947,12 +1012,47 @@ export const make = Effect.gen(function* () { "1 minute", ); + const getViewerTeamIDs = yield* Effect.cachedWithTTL( + Effect.suspend(() => + Effect.gen(function* () { + const teamIDs = new Set(); + let path = query("/user/teams", { page: 1, limit: PAGE_SIZE }); + let rowsSeen = 0; + for (let page = 1; page <= MAX_PAGINATION_PAGES; page += 1) { + const response = yield* gitea + .request({ operation: "getViewerTeams", method: "GET", path }) + .pipe(Effect.mapError((error) => failure("getViewerTeams", error))); + const teams = yield* decode("getViewerTeams", Schema.Array(RawTeam), response); + for (const team of teams) if (team.id !== undefined) teamIDs.add(team.id); + rowsSeen += teams.length; + const next = nextPagePath({ + path, + page, + pageRows: teams.length, + rowsSeen, + headers: response.headers, + }); + if (next === null) return teamIDs; + path = next; + } + return yield* new GiteaPullRequestApiError({ + operation: "getViewerTeams", + reason: "failed", + detail: "Gitea viewer team pagination exceeded the safe page limit.", + }); + }), + ), + "1 minute", + ); + const readUnknownSlice = Effect.fn("GiteaPullRequestApi.readUnknownSlice")(function* (input: { operation: string; host: string; repository: string; path: string; limit: number; + requirePaginationEvidence?: boolean; + nullAsEmpty?: boolean; }) { const rows: Array = []; let path = input.path; @@ -963,6 +1063,7 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, path, + ...(input.nullAsEmpty === undefined ? {} : { nullAsEmpty: input.nullAsEmpty }), }); rowsSeen += result.rows.length; const remaining = Math.max(0, input.limit - rows.length); @@ -974,14 +1075,25 @@ export const make = Effect.gen(function* () { rowsSeen, headers: result.headers, }); + const hasPaginationEvidence = + nextLink(result.headers) !== null || totalCount(result.headers) !== null; + const paginationNext = + input.requirePaginationEvidence && !hasPaginationEvidence ? null : next; + // Native unpaginated endpoints can return more than the requested page size. Exactly + // one requested page is ambiguous when headers do not establish whether more rows exist. + const paginationUncertain = + input.requirePaginationEvidence === true && + !hasPaginationEvidence && + result.rows.length === PAGE_SIZE; if (result.rows.length > remaining || rows.length >= input.limit) { return { rows, - truncated: result.rows.length > remaining || next !== null, + truncated: + result.rows.length > remaining || paginationNext !== null || paginationUncertain, }; } - if (next === null) return { rows, truncated: false }; - path = next; + if (paginationNext === null) return { rows, truncated: paginationUncertain }; + path = paginationNext; } return { rows, truncated: true }; }); @@ -1004,6 +1116,7 @@ export const make = Effect.gen(function* () { sort: relationshipOnly ? "oldest" : "recentupdate", page, limit: PAGE_SIZE, + include_tracking: !relationshipOnly && input.includeTracking === true ? "true" : undefined, ...(input.involvement === "authored" ? { poster: input.viewer } : {}), }); let rowsSeen = 0; @@ -1015,6 +1128,8 @@ export const make = Effect.gen(function* () { const repositoryIdsByName = new Map(); const expectedRepository = input.repository.trim().toLowerCase(); const collected: Array = []; + const viewerTeamIDs = + input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set(); const maxPages = relationshipOnly ? DEPENDENCY_PAGINATION_PAGES : MAX_PAGINATION_PAGES; let pagesRead = 0; let prefetchedPage: UnknownPage | null = null; @@ -1110,7 +1225,7 @@ export const make = Effect.gen(function* () { else if (observed !== id) relationshipEvidenceIncomplete = true; } } - if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer)) { + if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer, viewerTeamIDs)) { relationshipEvidenceIncomplete = relationshipOnly || relationshipEvidenceIncomplete; continue; } @@ -1160,20 +1275,15 @@ export const make = Effect.gen(function* () { }); const repo = yield* decode(operation, RawRepository, response); return { - // An omitted permission block is unknown rather than a denial. Gitea will still enforce - // the write, while hiding it here would leave an entitled viewer with no route to try. - canWrite: - repo.permissions == null || - repo.permissions.push === true || - repo.permissions.admin === true, + canWrite: repo.permissions?.push === true || repo.permissions?.admin === true, mergeCapabilities: { - merge: repo.allow_merge_commits ?? true, - squash: repo.allow_squash_merge ?? true, - rebase: repo.allow_rebase ?? true, + merge: repo.allow_merge_commits === true, + squash: repo.allow_squash_merge === true, + rebase: repo.allow_rebase === true, }, updateMethods: [ - ...(repo.allow_merge_update !== false ? (["merge"] as const) : []), - ...(repo.allow_rebase_update !== false ? (["rebase"] as const) : []), + ...(repo.allow_merge_update === true ? (["merge"] as const) : []), + ...(repo.allow_rebase_update === true ? (["rebase"] as const) : []), ], }; }, @@ -1264,7 +1374,8 @@ export const make = Effect.gen(function* () { } const comments: Array = []; const threads: Array = []; - const commentsTruncated = reviewsTruncated; + let commentsTruncated = reviewsTruncated; + let remainingReviewCommentRows = PAGE_SIZE * CONVERSATION_PAGES; for (const row of reviewRows) { const review = decodeReview(row); if (Option.isNone(review)) continue; @@ -1281,11 +1392,22 @@ export const make = Effect.gen(function* () { reviewState: review.value.state?.toLowerCase().replaceAll("_", " ") ?? null, }); } - const codeRows = yield* readUnknownArray({ + if (remainingReviewCommentRows === 0) { + commentsTruncated = true; + continue; + } + const codeRows = yield* readUnknownSlice({ operation: "listReviewComments", ...input, - path: `${basePath(input.repository)}/pulls/${input.number}/reviews/${review.value.id}/comments`, + path: query( + `${basePath(input.repository)}/pulls/${input.number}/reviews/${review.value.id}/comments`, + { page: 1, limit: PAGE_SIZE }, + ), + limit: remainingReviewCommentRows, + requirePaginationEvidence: true, }); + remainingReviewCommentRows -= codeRows.rows.length; + commentsTruncated ||= codeRows.truncated; const grouped = new Map< string, Array<{ @@ -1297,7 +1419,7 @@ export const make = Effect.gen(function* () { readonly comment: PullRequestReviewThread["comments"][number]; }> >(); - for (const codeRow of codeRows) { + for (const codeRow of codeRows.rows) { const decoded = decodeReviewComment(codeRow); if (Option.isNone(decoded)) continue; const mapped = decoded.value; @@ -1415,6 +1537,7 @@ export const make = Effect.gen(function* () { sha: string; }) { const operation = "listChecks"; + if (input.sha.trim() === "") return []; const statuses: Array = []; let path = query( `${basePath(input.repository)}/commits/${encodeURIComponent(input.sha)}/status`, @@ -1460,7 +1583,7 @@ export const make = Effect.gen(function* () { ? "success" : state === "pending" ? "pending" - : state === "failure" || state === "error" + : state === "failure" || state === "error" || state === "warning" ? "failure" : state === "skipped" ? "skipped" @@ -1527,7 +1650,9 @@ export const make = Effect.gen(function* () { const getAutoMergeEnabled = Effect.fn("GiteaPullRequestApi.getAutoMergeEnabled")( function* (input: { host: string; repository: string; number: number }) { - const features = yield* getFeatures.pipe(Effect.orElseSucceed(() => [])); + const features = yield* getFeatures.pipe( + Effect.orElseSucceed((): ReadonlyArray => []), + ); if (features.includes("pull-auto-merge-state")) { return (yield* getPullRequest(input)).autoMergeEnabled; } @@ -1551,13 +1676,11 @@ export const make = Effect.gen(function* () { response, ); events.push(...pageEvents); - const next = nextPagePath({ - path, - page, - pageRows: pageEvents.length, - rowsSeen: events.length, - headers: response.headers, - }); + // Gitea's timeline route reports the current page length in X-Total-Count rather than the + // total number of events, so that header cannot prove the timeline is complete. + const next = + nextLink(response.headers) ?? + (pageEvents.length >= PAGE_SIZE ? pathAtPage(path, page + 1) : null); if (next === null) return GiteaLifecycle.autoMergeEnabled(events); path = next; } @@ -1575,7 +1698,7 @@ export const make = Effect.gen(function* () { number: number; action: Extract; }) { - const features = yield* getFeatures.pipe(Effect.orElseSucceed(() => [])); + const features = yield* getFeatures.pipe(Effect.orElseSucceed((): ReadonlyArray => [])); if (features.includes("pull-draft")) { return yield* write({ operation: "runAction", @@ -1645,7 +1768,7 @@ export const make = Effect.gen(function* () { subjectIds: ReadonlyArray; }) { const supportsReviewReactions = (yield* getFeatures.pipe( - Effect.orElseSucceed(() => []), + Effect.orElseSucceed((): ReadonlyArray => []), )).includes("pull-review-reactions"); const targets: Array<{ readonly subjectId: string | undefined; @@ -1681,6 +1804,7 @@ export const make = Effect.gen(function* () { }, ), limit: PAGE_SIZE * MAX_PAGINATION_PAGES, + nullAsEmpty: true, }).pipe( Effect.map((result) => ({ subjectId: entry.subjectId, @@ -1709,12 +1833,25 @@ export const make = Effect.gen(function* () { }, ); - const unsupportedAction = (action: string) => - new GiteaPullRequestApiError({ - operation: "runAction", - reason: "failed", - detail: `Gitea does not expose a reliable ${action} operation through this API.`, + const revertPullRequest = Effect.fn("GiteaPullRequestApi.revertPullRequest")(function* (input: { + host: string; + repository: string; + number: number; + }) { + yield* validateHost(input.host); + if (!(yield* getFeatures).includes("pull-revert")) + return yield* new GiteaPullRequestApiError({ + operation: "revertPullRequest", + reason: "failed", + detail: "This Gitea server does not expose native pull request reverts.", + }); + return yield* write({ + operation: "revertPullRequest", + ...input, + method: "POST", + path: `${basePath(input.repository)}/pulls/${input.number}/revert`, }); + }); return GiteaPullRequestApi.of({ getFeatures: () => getFeatures, @@ -1911,7 +2048,7 @@ export const make = Effect.gen(function* () { case "approve-workflows": return approveWorkflows(input); case "revert": - return Effect.fail(unsupportedAction(input.action)); + return revertPullRequest(input); } }, updatePullRequest: (input) => @@ -1992,26 +2129,62 @@ export const make = Effect.gen(function* () { }), limit: PAGE_SIZE, }), + readUnknownArray({ + operation: "listTeamReviewerCandidates", + ...input, + path: `${basePath(input.repository)}/teams`, + }).pipe( + Effect.catch((error) => + isGiteaApiError(error.cause) && error.cause.status === 405 + ? Effect.succeed([]) + : Effect.fail(error), + ), + ), ], - { concurrency: 2 }, + { concurrency: 3 }, ).pipe( - Effect.map(([pr, result]) => { + Effect.map(([pr, result, teamRows]) => { const requested = new Set(pr.reviewRequestLogins.map((login) => login.toLowerCase())); + const requestedTeams = new Set( + pr.reviewRequestTeamNames.map((name) => name.toLowerCase()), + ); return { - candidates: result.rows.flatMap((row) => { - const raw = decodeUser(row); - if (Option.isNone(raw)) return []; - const mapped = actor(raw.value); - if (mapped === null || mapped.login === pr.author?.login) return []; - return [ - { - ...mapped, - id: mapped.login, - kind: "user" as const, - isRequested: requested.has(mapped.login.toLowerCase()), - }, - ]; - }), + candidates: [ + ...result.rows.flatMap((row) => { + const raw = decodeUser(row); + if (Option.isNone(raw)) return []; + const mapped = actor(raw.value); + if ( + mapped === null || + mapped.login.toLowerCase() === pr.author?.login.toLowerCase() + ) + return []; + return [ + { + ...mapped, + id: mapped.login, + kind: "user" as const, + isRequested: requested.has(mapped.login.toLowerCase()), + }, + ]; + }), + ...teamRows.flatMap((row) => { + const raw = decodeTeam(row); + if (Option.isNone(raw)) return []; + const name = raw.value.name?.trim(); + if (!name) return []; + return [ + { + id: name, + kind: "team" as const, + login: name, + name: raw.value.organization?.username?.trim() || null, + avatarUrl: null, + isRequested: requestedTeams.has(name.toLowerCase()), + }, + ]; + }), + ], truncated: result.truncated, }; }), @@ -2118,9 +2291,9 @@ export const make = Effect.gen(function* () { return Effect.gen(function* () { if ( target.kind === "review" && - !(yield* getFeatures.pipe(Effect.orElseSucceed(() => []))).includes( - "pull-review-reactions", - ) + !(yield* getFeatures.pipe( + Effect.orElseSucceed((): ReadonlyArray => []), + )).includes("pull-review-reactions") ) { return yield* new GiteaPullRequestApiError({ operation: "setReaction", diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts index 333def121618..b9b7d70dc1c5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts @@ -15,7 +15,9 @@ const response = (value: unknown) => ({ headers: {}, }); const failure = () => - Effect.fail(new GiteaApi.GiteaApiError({ operation: "test", reason: "failed", detail: "offline" })); + Effect.fail( + new GiteaApi.GiteaApiError({ operation: "test", reason: "failed", detail: "offline" }), + ); const pull = { number: 7, @@ -58,7 +60,7 @@ function route(viewerFails: boolean, reactionsFail: boolean) { return Effect.succeed( response([{ id: 2, body: "summary", submitted_at: "2026-01-01T00:00:00Z" }]), ); - if (input.path === "/repos/acme/web/pulls/7/reviews/2/comments") + if (input.path === "/repos/acme/web/pulls/7/reviews/2/comments?page=1&limit=50") return Effect.succeed( response([ { id: 3, body: "inline", created_at: "2026-01-01T00:00:00Z", path: "a.ts", position: 1 }, diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index 33493f55a7f5..5deff672fb18 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -1,11 +1,156 @@ -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as GiteaApi from "../sourceControl/GiteaApi.ts"; import { giteaBaseComparison, + giteaToChangeRequest, giteaProviderFailure, giteaViewerPermissions, + make as makeGiteaPullRequestProvider, } from "./GiteaPullRequestProvider.ts"; -import { GiteaPullRequestApiError } from "./GiteaPullRequestApi.ts"; +import * as GiteaPullRequestApi from "./GiteaPullRequestApi.ts"; +import { GiteaPullRequestApiError, type GiteaPullRequest } from "./GiteaPullRequestApi.ts"; + +const trackedPullRequest: GiteaPullRequest = { + number: 7, + title: "Tracking summary", + body: "", + url: "https://forge.example.test/acme/web/pulls/7", + author: null, + headBranch: "feature", + relationshipHeadBranch: "feature", + headBranchAvailable: true, + headRepositoryId: 1, + headSha: "head-sha", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "main", + baseRepositoryNameWithOwner: "acme/web", + baseRepositoryId: 1, + baseSha: "base-sha", + mergeBaseSha: "base-sha", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 1, + changedFiles: 1, + createdAt: "2026-09-04T00:00:00.000Z", + updatedAt: "2026-09-04T00:00:00.000Z", + mergedAt: null, + closedAt: null, + reviewRequestLogins: [], + reviewRequestTeamIDs: [], + reviewRequestTeamNames: [], + reviewers: [], + labels: [], + commentCount: 0, + reviewDecision: "approved", + checksState: "failing", +}; + +it("maps Gitea tracking summaries into the neutral change request", () => { + expect(giteaToChangeRequest(trackedPullRequest)).toMatchObject({ + reviewDecision: "approved", + checksState: "failing", + }); + expect( + giteaToChangeRequest({ + ...trackedPullRequest, + reviewDecision: null, + checksState: null, + }), + ).toMatchObject({ reviewDecision: null, checksState: null }); +}); + +function response(value: unknown) { + return { body: JSON.stringify(value), truncated: false, headers: {} }; +} + +function rawPullRequest() { + return { + number: 7, + title: "Pull request 7", + body: "Body", + state: "open", + merged: false, + mergeable: true, + draft: false, + html_url: "https://forge.example.test/gitea/acme/web/pulls/7", + created_at: "2026-09-01T10:00:00Z", + updated_at: "2026-09-02T10:00:00Z", + additions: 4, + deletions: 2, + changed_files: 1, + comments: 1, + review_comments: 2, + merge_base: "base-sha", + user: { id: 1, login: "author", full_name: "Author" }, + base: { ref: "main", sha: "base-sha", repo: { full_name: "acme/web" } }, + head: { ref: "feature", sha: "head-sha", repo: { full_name: "fork/web" } }, + requested_reviewers: [], + labels: [], + }; +} + +describe("GiteaPullRequestProvider", () => { + it.effect("keeps pull request detail when auto-merge state cannot be read", () => + Effect.gen(function* () { + const request = vi.fn((input) => { + switch (input.path) { + case "/settings/api": + return Effect.succeed(response({ features: [] })); + case "/repos/acme/web/pulls/7": + case "/repos/acme/web/pulls/7?include_tracking=true": + return Effect.succeed(response(rawPullRequest())); + case "/repos/acme/web": + return Effect.succeed(response({ permissions: { push: true } })); + case "/user": + return Effect.succeed(response({ login: "reader" })); + case "/repos/acme/web/issues/7/timeline?page=1&limit=50": + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getAutoMergeEnabled", + reason: "failed", + detail: "timeline unavailable", + }), + ); + case "/repos/acme/web/commits/head-sha/status?page=1&limit=50": + return Effect.succeed(response({ statuses: [], total_count: 0 })); + default: + 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 detail = yield* provider.getChangeRequest({ + cwd: "/workspace", + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(detail.number).toBe(7); + expect(detail.checks).toEqual([]); + expect("autoMergeEnabled" in detail).toBe(false); + }), + ); +}); describe("giteaViewerPermissions", () => { it("offers workflow approval only when the server supports it and the viewer can write", () => { @@ -121,3 +266,19 @@ describe("giteaProviderFailure", () => { ).toEqual({ reason: "rate-limited", retryAt: 1234 }); }); }); + +describe("native revert permission", () => { + it("requires write access and the advertised native endpoint", () => { + const input = { canWrite: true, ownsPullRequest: false, updateMethods: [] as const }; + expect(giteaViewerPermissions(input).actions).not.toContain("revert"); + expect(giteaViewerPermissions({ ...input, revertSupported: true }).actions).toContain("revert"); + expect( + giteaViewerPermissions({ + ...input, + canWrite: false, + ownsPullRequest: true, + revertSupported: true, + }).actions, + ).not.toContain("revert"); + }); +}); diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index f970c0786905..b4ee21a13ef9 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -25,6 +25,7 @@ const CAPABILITIES: PullRequestCapabilities = { "enable-auto-merge", "disable-auto-merge", "approve-workflows", + "revert", ], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], @@ -66,11 +67,13 @@ export function giteaProviderFailure( export function giteaViewerPermissions(input: { readonly canWrite: boolean; readonly workflowApprovalSupported?: boolean; + readonly revertSupported?: boolean; readonly ownsPullRequest: boolean; readonly updateMethods: ReadonlyArray<"merge" | "rebase">; }): PullRequestViewerPermissions { return { actions: CAPABILITIES.actions.filter((action) => { + if (action === "revert") return input.canWrite && input.revertSupported === true; if (action === "approve-workflows") return input.canWrite && input.workflowApprovalSupported === true; if (action === "ready" || action === "draft" || action === "close" || action === "reopen") @@ -93,7 +96,7 @@ export function giteaBaseComparison( return pullRequest.baseSha === pullRequest.mergeBaseSha ? "up-to-date" : "behind"; } -function toChangeRequest( +export function giteaToChangeRequest( pullRequest: GiteaPullRequestApi.GiteaPullRequest, relationshipOnly = false, ): ProviderChangeRequest { @@ -115,6 +118,10 @@ function toChangeRequest( updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, labels: pullRequest.labels, + ...(pullRequest.reviewDecision === undefined + ? {} + : { reviewDecision: pullRequest.reviewDecision }), + ...(pullRequest.checksState === undefined ? {} : { checksState: pullRequest.checksState }), }; } @@ -135,10 +142,12 @@ export const make = Effect.gen(function* () { readonly viewer: string; readonly author: string | undefined; readonly workflowApprovalSupported?: boolean; + readonly revertSupported?: boolean; }) => giteaViewerPermissions({ canWrite: input.access.canWrite, - workflowApprovalSupported: input.workflowApprovalSupported, + workflowApprovalSupported: input.workflowApprovalSupported === true, + revertSupported: input.revertSupported === true, ownsPullRequest: input.author !== undefined && input.author.toLowerCase() === input.viewer.toLowerCase(), updateMethods: input.access.updateMethods, @@ -164,6 +173,7 @@ export const make = Effect.gen(function* () { involvement: input.involvement, viewer: input.viewer, limit: input.limit, + includeTracking: input.relationshipOnly !== true, ...(input.query === undefined ? {} : { query: input.query }), ...(input.cursor === undefined ? {} : { cursor: input.cursor }), ...(input.relationshipOnly === undefined @@ -174,7 +184,7 @@ export const make = Effect.gen(function* () { Effect.mapError(fail("listChangeRequests")), Effect.map((page) => ({ items: page.items.map((pullRequest) => - toChangeRequest(pullRequest, input.relationshipOnly === true), + giteaToChangeRequest(pullRequest, input.relationshipOnly === true), ), truncated: page.truncated, cursorAdvance: page.consumed, @@ -185,21 +195,22 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => Effect.all( [ - api.getPullRequest(input), + api.getPullRequest({ ...input, includeTracking: true }), api.getRepositoryAccess(input), api.getViewer(), - api.getAutoMergeEnabled(input), + api.getAutoMergeEnabled(input).pipe(Effect.orElseSucceed(() => undefined)), api .getWorkflowApprovals(input) .pipe(Effect.orElseSucceed(() => ({ supported: false, runs: [] }))), + api.getFeatures().pipe(Effect.orElseSucceed((): ReadonlyArray => [])), ], { concurrency: 4 }, ).pipe( - Effect.flatMap(([pullRequest, access, viewer, autoMergeEnabled, workflows]) => + Effect.flatMap(([pullRequest, access, viewer, autoMergeEnabled, workflows, features]) => api.listChecks({ ...input, sha: pullRequest.headSha }).pipe( Effect.orElseSucceed(() => []), Effect.map((checks): ProviderChangeRequestDetail => ({ - ...toChangeRequest(pullRequest), + ...giteaToChangeRequest(pullRequest), body: pullRequest.body, changedFiles: pullRequest.changedFiles, mergedAt: pullRequest.mergedAt, @@ -226,6 +237,7 @@ export const make = Effect.gen(function* () { viewer, author: pullRequest.author?.login, workflowApprovalSupported: workflows.supported, + revertSupported: features.includes("pull-revert"), }), })), ), @@ -321,7 +333,7 @@ export const make = Effect.gen(function* () { api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer(), - api.getFeatures().pipe(Effect.orElseSucceed(() => [])), + api.getFeatures().pipe(Effect.orElseSucceed((): ReadonlyArray => [])), ], { concurrency: 3, @@ -334,6 +346,7 @@ export const make = Effect.gen(function* () { viewer, author: pullRequest.author?.login, workflowApprovalSupported: features.includes("actions-run-approve"), + revertSupported: features.includes("pull-revert"), }), ), ), diff --git a/apps/server/src/pullRequest/GiteaSearch.ts b/apps/server/src/pullRequest/GiteaSearch.ts index 949ffe4be7ff..f9459e667fa4 100644 --- a/apps/server/src/pullRequest/GiteaSearch.ts +++ b/apps/server/src/pullRequest/GiteaSearch.ts @@ -32,6 +32,7 @@ export function giteaSearchPath(input: { readonly viewer: string; readonly page: number; readonly limit: number; + readonly includeTracking?: boolean; }): string { const search = new URLSearchParams({ type: "pulls", @@ -39,6 +40,7 @@ export function giteaSearchPath(input: { state: endpointState(input.state), page: String(input.page), limit: String(input.limit), + ...(input.includeTracking === true ? { include_tracking: "true" } : {}), ...(input.involvement === "authored" ? { created_by: input.viewer } : {}), }); return `${input.repositoryPath}/issues?${search}`; diff --git a/apps/server/src/pullRequest/GiteaWorkflows.test.ts b/apps/server/src/pullRequest/GiteaWorkflows.test.ts index 594ec5435eba..1ca1b9a0230d 100644 --- a/apps/server/src/pullRequest/GiteaWorkflows.test.ts +++ b/apps/server/src/pullRequest/GiteaWorkflows.test.ts @@ -1,10 +1,13 @@ import { assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as GiteaApi from "../sourceControl/GiteaApi.ts"; import { isCurrentPullWorkflow, list } from "./GiteaWorkflows.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const run = { id: 7, needs_approval: true, @@ -33,7 +36,7 @@ it.effect("reads capped pages completely and selects only this PR's current bloc const request = vi.fn(); request.mockReturnValueOnce( Effect.succeed({ - body: JSON.stringify({ + body: encodeJson({ total_count: 2, workflow_runs: [{ ...run, pull_request_head_sha: "old" }], }), @@ -43,7 +46,7 @@ it.effect("reads capped pages completely and selects only this PR's current bloc ); request.mockReturnValueOnce( Effect.succeed({ - body: JSON.stringify({ total_count: 2, workflow_runs: [run] }), + body: encodeJson({ total_count: 2, workflow_runs: [run] }), truncated: false, headers: {}, }), @@ -62,7 +65,7 @@ it.effect("fails incomplete pagination instead of reporting no approvals", () => Effect.gen(function* () { const request = vi.fn(() => Effect.succeed({ - body: JSON.stringify({ total_count: 1, workflow_runs: [] }), + body: encodeJson({ total_count: 1, workflow_runs: [] }), truncated: false, headers: {}, }), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 56b16f103cfe..124c4e84f261 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4257,6 +4257,53 @@ it.effect("judges the review filter only on a host that summarises its reviews", }), ); +it.effect("judges checks from provider rows while preserving rows without a check summary", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitea", + host: "forge.example.test", + }), + ], + providers: [ + // Gitea can carry its check rollup on a row, but older or unconfigured hosts may leave it + // undefined. A null rollup is different: it says the host checked and found no checks. + fakeProvider("gitea", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-04T00:00:00Z"), checksState: "passing" }, + { ...changeRequest(2, "2026-07-03T00:00:00Z"), checksState: "failing" }, + { ...changeRequest(3, "2026-07-02T00:00:00Z"), checksState: "pending" }, + { ...changeRequest(4, "2026-07-01T00:00:00Z"), checksState: null }, + changeRequest(5, "2026-06-30T00:00:00Z"), + ], + truncated: false, + continues: false, + }), + }), + ], + }); + + const passing = yield* service.list({ state: "open", filters: { checks: "passing" } }); + assert.deepStrictEqual( + passing.entries.map((entry) => entry.number), + [1, 5], + ); + + const failing = yield* service.list({ state: "open", filters: { checks: "failing" } }); + assert.deepStrictEqual( + failing.entries.map((entry) => entry.number), + [2, 5], + ); + }), +); + it.effect("sends only the words a rewrite carries", () => Effect.gen(function* () { const received: Array<{ title?: string | undefined; body?: string | undefined }> = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 445f267fc7a5..77843e75427e 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -855,8 +855,9 @@ export const make = Effect.gen(function* () { * answers unnarrowed, and without this pass a draft filter or a label filter would be sent, * accepted and quietly ignored. Idempotent for the hosts that did narrow. * - * `checks` is absent because no listed row carries its check state: that one filter is the - * host's alone, and a row nobody narrowed stays rather than being guessed at. + * `checks` is judged when a row carries its check state. A host that leaves the field undefined + * cannot be judged, so that row stays rather than being guessed at; null means the host answered + * that no checks are present and therefore matches neither passing nor failing. */ const matchesRowFilters = ( item: ProviderChangeRequest, @@ -878,6 +879,9 @@ export const make = Effect.gen(function* () { (filters.review === "none" ? item.reviewDecision === null : item.reviewDecision === filters.review)) && + (filters.checks === undefined || + item.checksState === undefined || + item.checksState === filters.checks) && (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && (filters.author === undefined || diff --git a/apps/server/src/sourceControl/giteaRepository.test.ts b/apps/server/src/sourceControl/giteaRepository.test.ts index 48412b6c1cc8..5015416281e6 100644 --- a/apps/server/src/sourceControl/giteaRepository.test.ts +++ b/apps/server/src/sourceControl/giteaRepository.test.ts @@ -49,6 +49,10 @@ it("accepts PR numbers and URLs only from the selected repository", () => { giteaPullRequestNumber(`${base}/TEAM/REPO/pulls/42/files`, "team/repo", base), 42, ); + assert.strictEqual( + giteaPullRequestNumber(`${base}/%C3%A9quipe/r%C3%A9po/pulls/43/files`, "équipe/répo", base), + 43, + ); for (const ref of [ "0", "-1", diff --git a/apps/server/src/sourceControl/giteaRepository.ts b/apps/server/src/sourceControl/giteaRepository.ts index 12596e76689b..0d449e76f4a3 100644 --- a/apps/server/src/sourceControl/giteaRepository.ts +++ b/apps/server/src/sourceControl/giteaRepository.ts @@ -59,7 +59,8 @@ export function giteaPullRequestNumber( try { const url = new URL(reference); const base = new URL(baseUrl); - const expected = `${base.pathname.replace(/\/+$/u, "")}/${repository}/pulls/`; + const encodedRepository = repository.split("/").map(encodeURIComponent).join("/"); + const expected = `${base.pathname.replace(/\/+$/u, "")}/${encodedRepository}/pulls/`; if ( url.origin !== base.origin || !url.pathname.toLowerCase().startsWith(expected.toLowerCase()) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index beb33374b59d..497237915c77 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -119,9 +119,9 @@ For Azure DevOps, use the host website to view diffs or change comments. Bitbuck reopening a declined pull request. Gitea supports PR tracking, comments, reviews, diffs, reviewer and label updates, merge methods, -branch updates, and close/reopen. Draft status is shown when Gitea reports it, but draft/ready -changes, auto-merge controls, reactions, comment editing, workflow approval, and revert PRs are -not currently available in T3. Use your Gitea website for those tasks. +branch updates, close/reopen, draft/ready changes, auto-merge controls, comment editing, and +reactions. Workflow approval and revert PRs are available when your Gitea server advertises +support for them. ## Troubleshooting diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index d17258352eda..e02aa490bdd0 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -44,8 +44,9 @@ const PullRequestQualifierValues = Schema.Array(PullRequestQualifierValue).check * nothing, which is what every listing did before there were any. Optional as a whole so a page * and a server of different ages still speak to each other. * - * `checks` is host-side only: no row carries its own check state, so a host that cannot match it - * answers unnarrowed rather than the page pretending to know. + * `checks` is host-side where supported, and is also judged from a row's own state when the + * provider carries one. A host that cannot match it answers unnarrowed rather than the page + * pretending to know. */ export const PullRequestListFilters = Schema.Struct({ draft: Schema.optional(Schema.Literals(["only", "hide"])),