diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 5a0c6fb6fda7..b000063652bc 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, @@ -93,10 +97,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(Schema.Literals(["approved", "changes-requested", "review-required"])), - ), - checks_state: Schema.optional(Schema.NullOr(Schema.Literals(["passing", "failing", "pending"]))), + 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, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 0dd5a30fb396..53cd8676b640 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3840,6 +3840,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 f6a0405ec9b2..60ac6ed6d0e5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -833,8 +833,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, @@ -856,6 +857,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/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 74ea49ceb9e2..449c2a4c1824 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"])),