From ee08c2327ddc7d99a929831eca44138c292d5116 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 23:32:39 -0500 Subject: [PATCH 1/2] feat(pull-requests): surface Gitea tracking summaries Assisted-by: Codex:GPT-6 --- .../pullRequest/GiteaPullRequestApi.test.ts | 82 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 21 ++++- .../GiteaPullRequestProvider.test.ts | 47 ++++++++++- .../pullRequest/GiteaPullRequestProvider.ts | 21 +++-- apps/server/src/pullRequest/GiteaSearch.ts | 2 + 5 files changed, 164 insertions(+), 9 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 81c201979066..5f150c432a1c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -300,6 +300,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.GiteaPullRequestApi; + 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( @@ -329,6 +374,7 @@ layer("GiteaPullRequestApi", (it) => { involvement: "all", viewer: "reviewer", limit: 2, + includeTracking: true, }); expect(page.items.map((item) => [item.number, item.state])).toEqual([ @@ -339,6 +385,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"); }), ); @@ -435,6 +482,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.GiteaPullRequestApi; + 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) => { diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 9885b7f44879..77b8546a3c1b 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -19,7 +19,9 @@ import type { PullRequestMergeability, PullRequestReaction, PullRequestReactionContent, + PullRequestChecksState, PullRequestReviewCommentDraft, + PullRequestReviewDecision, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -91,6 +93,10 @@ 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(Schema.Literals(["approved", "changes-requested", "review-required"])), + ), + checks_state: Schema.optional(Schema.NullOr(Schema.Literals(["passing", "failing", "pending"]))), auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), auto_merge_method: Schema.optional(Schema.NullOr(Schema.String)), html_url: Schema.String, @@ -236,6 +242,8 @@ export interface GiteaPullRequest { readonly commentCount: number; readonly autoMergeEnabled?: boolean; readonly autoMergeMethod?: PullRequestMergeMethod; + readonly reviewDecision?: PullRequestReviewDecision | null; + readonly checksState?: PullRequestChecksState | null; } export interface GiteaRepositoryAccess { @@ -329,6 +337,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 }), }; } @@ -465,6 +475,7 @@ export class GiteaPullRequestApi extends Context.Service< readonly limit: number; readonly query?: string; readonly cursor?: ProviderListCursor; + readonly includeTracking?: boolean; }) => Effect.Effect< { items: ReadonlyArray; @@ -477,6 +488,7 @@ export class GiteaPullRequestApi extends Context.Service< host: string; repository: string; number: number; + includeTracking?: boolean; }) => Effect.Effect; readonly getRepositoryAccess: (input: { host: string; @@ -706,6 +718,7 @@ export const make = Effect.gen(function* () { host: string; repository: string; number: number; + includeTracking?: boolean; }) { const operation = "getPullRequest"; const response = yield* request({ @@ -713,7 +726,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,6 +838,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; @@ -835,6 +851,7 @@ export const make = Effect.gen(function* () { viewer: input.viewer, page, limit: PAGE_SIZE, + includeTracking: input.includeTracking, }); let rowsSeen = 0; let rowsSkipped = 0; @@ -869,6 +886,7 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, number, + includeTracking: input.includeTracking, }); }, { concurrency: SEARCH_HYDRATION_CONCURRENCY }, @@ -987,6 +1005,7 @@ export const make = Effect.gen(function* () { sort: "recentupdate", page, limit: PAGE_SIZE, + include_tracking: input.includeTracking === true ? "true" : undefined, ...(input.involvement === "authored" ? { poster: input.viewer } : {}), }); let rowsSeen = 0; diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index 33493f55a7f5..d4fc6095d9b5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -2,10 +2,55 @@ import { describe, expect, it } from "@effect/vitest"; import { giteaBaseComparison, + giteaToChangeRequest, giteaProviderFailure, giteaViewerPermissions, } from "./GiteaPullRequestProvider.ts"; -import { GiteaPullRequestApiError } 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", + headSha: "head-sha", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "main", + 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: [], + 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 }); +}); describe("giteaViewerPermissions", () => { it("offers workflow approval only when the server supports it and the viewer can write", () => { diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index 108aa3ab9022..e94af4d80fb5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -93,7 +93,9 @@ export function giteaBaseComparison( return pullRequest.baseSha === pullRequest.mergeBaseSha ? "up-to-date" : "behind"; } -function toChangeRequest(pullRequest: GiteaPullRequestApi.GiteaPullRequest): ProviderChangeRequest { +export function giteaToChangeRequest( + pullRequest: GiteaPullRequestApi.GiteaPullRequest, +): ProviderChangeRequest { return { number: pullRequest.number, title: pullRequest.title, @@ -111,6 +113,10 @@ function toChangeRequest(pullRequest: GiteaPullRequestApi.GiteaPullRequest): Pro updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, labels: pullRequest.labels, + ...(pullRequest.reviewDecision === undefined + ? {} + : { reviewDecision: pullRequest.reviewDecision }), + ...(pullRequest.checksState === undefined ? {} : { checksState: pullRequest.checksState }), }; } @@ -160,13 +166,14 @@ export const make = Effect.gen(function* () { involvement: input.involvement, viewer: input.viewer, limit: input.limit, + includeTracking: true, ...(input.query === undefined ? {} : { query: input.query }), ...(input.cursor === undefined ? {} : { cursor: input.cursor }), }) .pipe( Effect.mapError(fail("listChangeRequests")), Effect.map((page) => ({ - items: page.items.map(toChangeRequest), + items: page.items.map(giteaToChangeRequest), truncated: page.truncated, cursorAdvance: page.consumed, continues: true, @@ -176,7 +183,7 @@ 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), @@ -190,7 +197,7 @@ export const make = Effect.gen(function* () { 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, @@ -225,7 +232,7 @@ export const make = Effect.gen(function* () { ), getChangeRequestSummary: (input) => - api.getPullRequest(input).pipe( + api.getPullRequest({ ...input, includeTracking: true }).pipe( Effect.mapError(fail("getChangeRequestSummary")), Effect.map((pullRequest) => ({ number: pullRequest.number, @@ -242,7 +249,7 @@ export const make = Effect.gen(function* () { getChangeRequestActivity: (input) => Effect.all( [ - api.getPullRequest(input), + api.getPullRequest({ ...input, includeTracking: true }), api .listComments(input) .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), @@ -309,7 +316,7 @@ export const make = Effect.gen(function* () { getViewerPermissions: (input) => Effect.all( [ - api.getPullRequest(input), + api.getPullRequest({ ...input, includeTracking: true }), api.getRepositoryAccess(input), api.getViewer(), api.getFeatures().pipe(Effect.orElseSucceed(() => [])), 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}`; From 911169f7118984b739bfcf41bca680b64d954ceb Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 23:38:02 -0500 Subject: [PATCH 2/2] fix(pull-requests): limit Gitea tracking reads to displayed summaries --- apps/server/src/pullRequest/GiteaPullRequestApi.test.ts | 4 ++-- apps/server/src/pullRequest/GiteaPullRequestProvider.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 5f150c432a1c..b3e5d519cbb2 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -322,7 +322,7 @@ layer("GiteaPullRequestApi", (it) => { ), ), ); - const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const api = yield* GiteaPullRequestApi.make; const pullRequest = yield* api.getPullRequest({ host: "forge.example.test", repository: "acme/web", @@ -496,7 +496,7 @@ layer("GiteaPullRequestApi", (it) => { ), ), ); - const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const api = yield* GiteaPullRequestApi.make; const page = yield* api.listPullRequests({ host: "forge.example.test", repository: "acme/web", diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index e94af4d80fb5..fbe4b0d0dc42 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -232,7 +232,7 @@ export const make = Effect.gen(function* () { ), getChangeRequestSummary: (input) => - api.getPullRequest({ ...input, includeTracking: true }).pipe( + api.getPullRequest(input).pipe( Effect.mapError(fail("getChangeRequestSummary")), Effect.map((pullRequest) => ({ number: pullRequest.number, @@ -249,7 +249,7 @@ export const make = Effect.gen(function* () { getChangeRequestActivity: (input) => Effect.all( [ - api.getPullRequest({ ...input, includeTracking: true }), + api.getPullRequest(input), api .listComments(input) .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), @@ -316,7 +316,7 @@ export const make = Effect.gen(function* () { getViewerPermissions: (input) => Effect.all( [ - api.getPullRequest({ ...input, includeTracking: true }), + api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer(), api.getFeatures().pipe(Effect.orElseSucceed(() => [])),