Skip to content

Commit c3a5387

Browse files
Bil0000juliusmarminge
authored andcommitted
feat(web): add compact PR checks to the workspace card (#11981)
1 parent bcffdcb commit c3a5387

29 files changed

Lines changed: 945 additions & 174 deletions

‎apps/server/src/auth/RpcAuthorization.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ describe("RPC authorization scopes", () => {
7979
it("reads the reviewer menu under the same scope as the pull request it belongs to", () => {
8080
// The candidate list is a read like the detail beside it, and asking somebody for a review is
8181
// a write like every other pull request operation.
82+
expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsChecks)).toBe(
83+
AuthOrchestrationReadScope,
84+
);
8285
expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsReviewerCandidates)).toBe(
8386
requiredScopeForRpcMethod(WS_METHODS.pullRequestsDetail),
8487
);

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export const RPC_REQUIRED_SCOPES = {
9595
[WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
9696
[WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
9797
[WS_METHODS.pullRequestsPreview]: AuthOrchestrationReadScope,
98+
[WS_METHODS.pullRequestsChecks]: AuthOrchestrationReadScope,
9899
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
99100
[WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
100101
[WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export const make = Effect.gen(function* () {
221221
questionAttachments: true,
222222
fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
223223
pullRequests: true,
224+
pullRequestChecks: true,
224225
inlineMessageContext: true,
225226
requiredWorktreeBootstrap: true,
226227
threadSettlement: true,

‎apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,50 @@ for (const operation of [
8585
);
8686
}
8787

88+
it.effect("reads checks and PR state without diff, mergeability, or permission requests", () =>
89+
Effect.gen(function* () {
90+
const pullRequest = Result.getOrThrow(
91+
decodePullRequestJson(`{
92+
"id": 1, "title": "Checks", "state": "OPEN",
93+
"source": { "branch": { "name": "feature" } },
94+
"destination": { "branch": { "name": "main" } },
95+
"created_on": "2026-09-16T00:00:00Z", "updated_on": "2026-09-16T00:00:00Z",
96+
"links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } }
97+
}`),
98+
);
99+
let limited = false;
100+
const provider = yield* make.pipe(
101+
Effect.provide(
102+
Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({
103+
getPullRequest: () => Effect.succeed(pullRequest),
104+
listChecks: () =>
105+
limited
106+
? Effect.fail(
107+
new BitbucketApi.BitbucketResponseError({
108+
operation: "request",
109+
status: 429,
110+
responseBodyLength: 0,
111+
retryAt: 120_000,
112+
}),
113+
)
114+
: Effect.succeed([
115+
{ name: "build", status: "failure" as const, description: null, url: null },
116+
]),
117+
}),
118+
),
119+
);
120+
const read = provider.getChangeRequestChecks;
121+
if (read === undefined) return yield* Effect.die("checks read missing");
122+
const input = { cwd: "/repo", repository: "acme/web", host: "bitbucket.org", number: 1 };
123+
expect((yield* read(input)).checks[0]?.status).toBe("failure");
124+
limited = true;
125+
expect(yield* Effect.flip(read(input))).toMatchObject({
126+
reason: "rate-limited",
127+
retryAt: 120_000,
128+
});
129+
}),
130+
);
131+
88132
describe("bitbucketProviderFailure", () => {
89133
it("treats only an HTTP 401 as unusable credentials", () => {
90134
const responseError = (status: number) =>

‎apps/server/src/pullRequest/BitbucketPullRequestProvider.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ export const make = Effect.gen(function* () {
178178
})),
179179
),
180180

181+
getChangeRequestChecks: (input) =>
182+
Effect.all([api.getPullRequest(input), api.listChecks(input)], { concurrency: 2 }).pipe(
183+
Effect.map(([pullRequest, checks]) => ({ state: pullRequest.state, checks })),
184+
Effect.mapError(fail("getChangeRequestChecks")),
185+
),
186+
181187
getChangeRequest: (input) => {
182188
const target = { repository: input.repository, number: input.number };
183189
return Effect.all(

‎apps/server/src/pullRequest/ForgejoPullRequestProvider.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,19 @@ export const make = Effect.gen(function* () {
235235
},
236236
),
237237
getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)),
238+
getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")(
239+
function* (input) {
240+
const pr = yield* getPull(input);
241+
const statuses = yield* page(
242+
{
243+
...input,
244+
path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
245+
},
246+
ForgejoStatus,
247+
);
248+
return { state: forgejoChangeRequest(pr).state, checks: forgejoChecks(statuses.items) };
249+
},
250+
),
238251
getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) {
239252
const [pr, repo, viewer] = yield* Effect.all(
240253
[getPull(input), getRepo(input), getViewer(input)],

‎apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { describe, expect, it } from "@effect/vitest";
22
import * as Effect from "effect/Effect";
33
import * as Layer from "effect/Layer";
44
import * as Redacted from "effect/Redacted";
5+
import * as Result from "effect/Result";
56
import type { PullRequestReaction } from "@t3tools/contracts";
67

8+
import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
79
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
810
import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
911
import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
@@ -59,6 +61,54 @@ it.effect("maps credential verification failures without relabeling operation fa
5961
}),
6062
);
6163

64+
it.effect("refreshes checks without permissions or comparison reads", () =>
65+
Effect.gen(function* () {
66+
let reads = 0;
67+
const snapshot = Result.getOrThrow(
68+
decodePullRequestDetailJson(`{
69+
"number": 7, "title": "Checks", "url": "https://github.com/acme/web/pull/7",
70+
"headRefName": "feature", "baseRefName": "main", "state": "OPEN",
71+
"createdAt": "2026-07-01T00:00:00Z", "updatedAt": "2026-07-01T00:00:00Z"
72+
}`),
73+
);
74+
const provider = yield* make.pipe(
75+
Effect.provide(
76+
Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
77+
getPullRequestDetail: () =>
78+
Effect.sync(() => {
79+
reads++;
80+
return {
81+
...snapshot,
82+
state: reads === 3 ? ("merged" as const) : ("open" as const),
83+
checks: [
84+
{
85+
name: "build",
86+
status: reads === 1 ? ("pending" as const) : ("success" as const),
87+
description: null,
88+
url: null,
89+
},
90+
],
91+
};
92+
}),
93+
}),
94+
),
95+
);
96+
const read = provider.getChangeRequestChecks;
97+
if (read === undefined) return yield* Effect.die("checks read missing");
98+
for (let tick = 1; tick <= 3; tick++) {
99+
const result = yield* read({
100+
cwd: "/w",
101+
repository: "acme/web",
102+
host: "github.com",
103+
number: 7,
104+
});
105+
expect(result.checks[0]?.status).toBe(tick === 1 ? "pending" : "success");
106+
expect(result.state).toBe(tick === 3 ? "merged" : "open");
107+
}
108+
expect(reads).toBe(3);
109+
}),
110+
);
111+
62112
it.effect("uses one narrow read for a linked pull request summary", () =>
63113
Effect.gen(function* () {
64114
let summaryReads = 0;
@@ -350,6 +400,11 @@ describe("gitHubViewerPermissions", () => {
350400
number: 7,
351401
});
352402

403+
const readChecks = provider.getChangeRequestChecks;
404+
if (readChecks === undefined) return yield* Effect.die("checks read missing");
405+
expect(
406+
yield* readChecks({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }),
407+
).toEqual({ state: detail.state, checks: detail.checks });
353408
expect(detail.workflowApprovalsRequired).toBe(1);
354409
expect(detail.checks).toEqual([
355410
{

‎apps/server/src/pullRequest/GitHubPullRequestProvider.ts‎

Lines changed: 81 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type ProviderChangeRequestActivity,
1515
type ProviderChangeRequestDetail,
1616
type PullRequestProviderApi,
17+
type ProviderRepositoryRef,
1718
} from "./PullRequestProvider.ts";
1819
import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
1920

@@ -198,6 +199,56 @@ export const make = Effect.gen(function* () {
198199
cause: error,
199200
});
200201

202+
const readChecks = (input: ProviderRepositoryRef & { readonly number: number }) =>
203+
cli.getPullRequestDetail(input).pipe(
204+
Effect.flatMap((pullRequest) =>
205+
(pullRequest.state !== "open" || pullRequest.isCrossRepository !== true
206+
? Effect.succeed({
207+
runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
208+
unavailable: false,
209+
})
210+
: pullRequest.headSha == null || pullRequest.headRepositoryOwner == null
211+
? Effect.succeed({
212+
runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
213+
unavailable: true,
214+
})
215+
: cli
216+
.listWorkflowRunsRequiringApproval({
217+
...input,
218+
headSha: pullRequest.headSha,
219+
headBranch: pullRequest.headBranch,
220+
headRepositoryOwner: pullRequest.headRepositoryOwner,
221+
isCrossRepository: true,
222+
})
223+
.pipe(
224+
Effect.matchEffect({
225+
onFailure: (error) =>
226+
error._tag === "GitHubCliRateLimitError" ||
227+
error._tag === "SourceControlRateLimitPausedError"
228+
? Effect.fail(error)
229+
: Effect.succeed({
230+
runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
231+
unavailable: true,
232+
}),
233+
onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }),
234+
}),
235+
)
236+
).pipe(
237+
Effect.map((workflowApprovals) => ({
238+
...pullRequest,
239+
checks: withWorkflowApprovals(
240+
pullRequest.checks,
241+
workflowApprovals.runs,
242+
workflowApprovals.unavailable,
243+
),
244+
...(workflowApprovals.unavailable
245+
? {}
246+
: { workflowApprovalsRequired: workflowApprovals.runs.length }),
247+
})),
248+
),
249+
),
250+
);
251+
201252
const provider: PullRequestProviderApi = {
202253
kind: "github",
203254
capabilities: CAPABILITIES,
@@ -310,76 +361,37 @@ export const make = Effect.gen(function* () {
310361
getChangeRequestPreview: (input) =>
311362
cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))),
312363

364+
getChangeRequestChecks: (input) =>
365+
readChecks(input).pipe(
366+
Effect.map(({ state, checks }) => ({ state, checks })),
367+
Effect.mapError(fail("getChangeRequestChecks")),
368+
),
369+
313370
getChangeRequest: (input) =>
314-
cli.getPullRequestDetail(input).pipe(
315-
Effect.flatMap((pullRequest) => {
316-
// Fork workflows awaiting approval are absent from the normal check rollup.
317-
const approvals =
318-
pullRequest.state !== "open" || pullRequest.isCrossRepository !== true
319-
? Effect.succeed({
320-
runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
321-
unavailable: false,
322-
})
323-
: pullRequest.headSha == null || pullRequest.headRepositoryOwner == null
324-
? Effect.succeed({
325-
runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
326-
unavailable: true,
327-
})
328-
: cli
329-
.listWorkflowRunsRequiringApproval({
330-
...input,
331-
headSha: pullRequest.headSha,
332-
headBranch: pullRequest.headBranch,
333-
headRepositoryOwner: pullRequest.headRepositoryOwner,
334-
isCrossRepository: true,
335-
})
336-
.pipe(
337-
Effect.matchEffect({
338-
onFailure: (error) =>
339-
error._tag === "GitHubCliRateLimitError" ||
340-
error._tag === "SourceControlRateLimitPausedError"
341-
? Effect.fail(error)
342-
: Effect.succeed({
343-
runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
344-
unavailable: true,
345-
}),
346-
onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }),
347-
}),
348-
);
349-
return approvals.pipe(
350-
Effect.map((workflowApprovals): ProviderChangeRequestDetail => ({
351-
...pullRequest,
352-
author: withAvatar(pullRequest.author, new Map<string, string>(), input.host),
353-
checks: withWorkflowApprovals(
354-
pullRequest.checks,
355-
workflowApprovals.runs,
356-
workflowApprovals.unavailable,
357-
),
358-
...(workflowApprovals.unavailable
359-
? {}
360-
: { workflowApprovalsRequired: workflowApprovals.runs.length }),
361-
reviewers: pullRequest.reviewRequestLogins.map((login) => ({
362-
login,
363-
name: null,
364-
avatarUrl: null,
365-
})),
366-
mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities,
367-
viewerPermissions: gitHubViewerPermissions({
368-
...pullRequest.viewerAccess,
369-
canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true,
370-
}),
371-
baseComparison:
372-
pullRequest.comparison === null || pullRequest.comparison.behindBy === null
373-
? "unknown"
374-
: pullRequest.comparison.behindBy > 0
375-
? "behind"
376-
: "up-to-date",
377-
...(pullRequest.comparison?.behindBy == null
378-
? {}
379-
: { behindBy: pullRequest.comparison.behindBy }),
380-
})),
381-
);
382-
}),
371+
readChecks(input).pipe(
372+
Effect.map((pullRequest): ProviderChangeRequestDetail => ({
373+
...pullRequest,
374+
author: withAvatar(pullRequest.author, new Map<string, string>(), input.host),
375+
reviewers: pullRequest.reviewRequestLogins.map((login) => ({
376+
login,
377+
name: null,
378+
avatarUrl: null,
379+
})),
380+
mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities,
381+
viewerPermissions: gitHubViewerPermissions({
382+
...pullRequest.viewerAccess,
383+
canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true,
384+
}),
385+
baseComparison:
386+
pullRequest.comparison === null || pullRequest.comparison.behindBy === null
387+
? "unknown"
388+
: pullRequest.comparison.behindBy > 0
389+
? "behind"
390+
: "up-to-date",
391+
...(pullRequest.comparison?.behindBy == null
392+
? {}
393+
: { behindBy: pullRequest.comparison.behindBy }),
394+
})),
383395
Effect.mapError(fail("getChangeRequest")),
384396
),
385397

‎apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ describe("getChangeRequest base freshness", () => {
8888
reviewerIds: [],
8989
};
9090

91+
it.effect("reads checks without fetching project settings", () =>
92+
Effect.gen(function* () {
93+
const provider = yield* make.pipe(
94+
Effect.provide(
95+
Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({
96+
getMergeRequestDetail: () => Effect.succeed(detail),
97+
}),
98+
),
99+
);
100+
const read = provider.getChangeRequestChecks;
101+
if (read === undefined) return yield* Effect.die("checks read missing");
102+
expect(
103+
yield* read({ cwd: "/w", repository: "group/subgroup/web", host: "gitlab.com", number: 7 }),
104+
).toEqual({ state: "open", checks: [] });
105+
}),
106+
);
107+
91108
const readWith = (divergence: { readonly divergedCommits?: number }) =>
92109
Effect.gen(function* () {
93110
const provider = yield* make;

‎apps/server/src/pullRequest/GitLabPullRequestProvider.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ export const make = Effect.gen(function* () {
143143
Effect.map((batch) => ({ ...batch, continues: true })),
144144
),
145145

146+
getChangeRequestChecks: (input) =>
147+
cli.getMergeRequestDetail(input).pipe(
148+
Effect.map(({ state, checks }) => ({ state, checks })),
149+
Effect.mapError(fail("getChangeRequestChecks")),
150+
),
151+
146152
getChangeRequest: (input) =>
147153
Effect.all(
148154
[

0 commit comments

Comments
 (0)