History

fix(pull-requests): missing features & better behaviour (#9188)

maria committed 2 months ago

Commit 443b4eb1 parent 4ba39a634 files · +2241 −205Browse files

Unified · Split

34 files
  1. apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +1 −1
  2. apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +11 −1
  3. apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +3 −0
  4. apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +384 −1
  5. apps/server/src/pullRequest/GitHubPullRequestCli.ts +342 −28
  6. apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +256 −30
  7. apps/server/src/pullRequest/GitHubPullRequestProvider.ts +106 −16
  8. apps/server/src/pullRequest/GitLabPullRequestCli.ts +4 −0
  9. apps/server/src/pullRequest/PullRequestProvider.ts +4 −0
  10. apps/server/src/pullRequest/PullRequestService.ts +9 −0
  11. apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +28 −0
  12. apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +30 −0
  13. apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +75 −6
  14. apps/server/src/pullRequest/gitHubPullRequestJson.ts +115 −11
  15. apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +22 −0
  16. apps/server/src/pullRequest/gitLabMergeRequestJson.ts +9 −0
  17. apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +2 −1
  18. apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +181 −43
  19. apps/web/src/components/pullRequest/PullRequestListFilters.tsx +1 −1
  20. apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +67 −11
  21. apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +30 −3
  22. apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +53 −1
  23. apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +45 −5
  24. apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +167 −0
  25. apps/web/src/components/pullRequest/pullRequestList.logic.ts +30 −1
  26. apps/web/src/components/pullRequest/pullRequestListPreferences.ts +122 −0
  27. apps/web/src/components/pullRequest/pullRequestPresentation.tsx +35 −6
  28. apps/web/src/components/sidebar/SidebarChrome.tsx +5 −1
  29. apps/web/src/components/ui/badge.tsx +2 −0
  30. apps/web/src/components/ui/button.tsx +2 −0
  31. apps/web/src/routes/_chat.pull-requests.tsx +49 −34
  32. docs/user/source-control.md +14 −2
  33. packages/contracts/src/pullRequest.test.ts +28 −2
  34. packages/contracts/src/pullRequest.ts +9 −0
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts+1−1
@@ -414,7 +414,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { · 413 unmodified lines
414414 );
415415
416416 it.effect.each([
417− { action: "enable-auto-merge", expected: ["--auto-complete", "true", "--squash", "false"] },
417+ { action: "enable-auto-merge", expected: ["--auto-complete", "true"] },
418418 { action: "disable-auto-merge", expected: ["--auto-complete", "false"] },
419419 { action: "draft", expected: ["--draft", "true"] },
420420 { action: "ready", expected: ["--draft", "false"] },
apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts+11−1
@@ -224,7 +224,13 @@ function actionArgs( · 223 unmodified lines
224224 // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes
225225 // it once its policies pass. The squash choice is stored with it, as it is for a merge now.
226226 case "enable-auto-merge":
227− return ["--auto-complete", "true", "--squash", mergeMethod === "squash" ? "true" : "false"];
227+ return [
228+ "--auto-complete",
229+ "true",
230+ ...(mergeMethod === undefined
231+ ? []
232+ : ["--squash", mergeMethod === "squash" ? "true" : "false"]),
233+ ];
228234 case "disable-auto-merge":
229235 return ["--auto-complete", "false"];
230236 case "ready":
@@ -238,6 +244,10 @@ function actionArgs( · 7 unmodified lines
238244 return [];
239245 case "reopen":
240246 return ["--status", "active"];
247+ // Never reached: this host does not declare the action, so the service refuses it first.
248+ case "revert":
249+ case "approve-workflows":
250+ throw new Error(`Azure DevOps pull request action ${action} is unsupported`);
241251 }
242252 }
243253
apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts+3−0
@@ -169,6 +169,9 @@ export const make = Effect.gen(function* () { · 168 unmodified lines
169169 mergeCapabilities: { merge: true, squash: true, rebase: false },
170170 viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS,
171171 autoMergeEnabled: pullRequest.autoMergeEnabled,
172+ ...(pullRequest.autoMergeMethod === undefined
173+ ? {}
174+ : { autoMergeMethod: pullRequest.autoMergeMethod }),
172175 }),
173176 ),
174177 ),
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+384−1
@@ -1200,6 +1200,389 @@ layer("GitHubPullRequestCli.layer", (it) => { · 1199 unmodified lines
12001200 }),
12011201 );
12021202
1203+ it.effect("opens a pull request that reverts a merged pull request", () =>
1204+ Effect.gen(function* () {
1205+ mockedExecute.mockReturnValueOnce(
1206+ Effect.succeed(
1207+ output(
1208+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh GraphQL response.
1209+ JSON.stringify({
1210+ data: { repository: { pullRequest: { id: "PR_7" } } },
1211+ }),
1212+ ),
1213+ ),
1214+ );
1215+ mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}")));
1216+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1217+
1218+ yield* cli.runPullRequestAction({
1219+ cwd: "/w",
1220+ repository: "acme/web",
1221+ host: "github.com",
1222+ number: 7,
1223+ action: "revert",
1224+ });
1225+
1226+ expect(callAt(0).args).toContain("owner=acme");
1227+ expect(callAt(0).args).toContain("name=web");
1228+ expect(callAt(0).args).toContain("number=7");
1229+ expect(callAt(1).args).toEqual([
1230+ "api",
1231+ "graphql",
1232+ "--hostname",
1233+ "github.com",
1234+ "--input",
1235+ "-",
1236+ ]);
1237+ expect(callAt(1).stdin).toContain("revertPullRequest");
1238+ expect(callAt(1).stdin).toContain('"pullRequestId":"PR_7"');
1239+ }),
1240+ );
1241+
1242+ it.effect("does not approve action-required runs for a same-repository pull request", () =>
1243+ Effect.gen(function* () {
1244+ mockedExecute.mockReturnValueOnce(
1245+ Effect.succeed(
1246+ output(
1247+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1248+ JSON.stringify({
1249+ number: 7,
1250+ title: "Pull request 7",
1251+ url: "https://github.com/acme/web/pull/7",
1252+ headRefName: "feat/page",
1253+ headRefOid: "abc123",
1254+ isCrossRepository: false,
1255+ headRepositoryOwner: { login: "acme" },
1256+ baseRefName: "main",
1257+ createdAt: "2026-07-01T00:00:00Z",
1258+ updatedAt: "2026-07-02T00:00:00Z",
1259+ }),
1260+ ),
1261+ ),
1262+ );
1263+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1264+
1265+ yield* cli.runPullRequestAction({
1266+ cwd: "/w",
1267+ repository: "acme/web",
1268+ host: "github.com",
1269+ number: 7,
1270+ action: "approve-workflows",
1271+ });
1272+
1273+ expect(mockedExecute).toHaveBeenCalledTimes(1);
1274+ }),
1275+ );
1276+
1277+ it.effect("finds and approves every workflow waiting on a maintainer", () =>
1278+ Effect.gen(function* () {
1279+ const detail = output(
1280+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1281+ JSON.stringify({
1282+ number: 7,
1283+ title: "Pull request 7",
1284+ url: "https://github.com/acme/web/pull/7",
1285+ headRefName: "feat/page",
1286+ headRefOid: "abc123",
1287+ isCrossRepository: true,
1288+ headRepositoryOwner: { login: "octocat" },
1289+ baseRefName: "main",
1290+ createdAt: "2026-07-01T00:00:00Z",
1291+ updatedAt: "2026-07-02T00:00:00Z",
1292+ }),
1293+ );
1294+ const heads = output(
1295+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1296+ JSON.stringify([
1297+ {
1298+ number: 7,
1299+ headRefOid: "abc123",
1300+ isCrossRepository: true,
1301+ headRepositoryOwner: { login: "octocat" },
1302+ },
1303+ ]),
1304+ );
1305+ const runs = output(
1306+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1307+ JSON.stringify([
1308+ { databaseId: 10, workflowName: "build", url: "https://example.com/10" },
1309+ { databaseId: 11, workflowName: "test", url: "https://example.com/11" },
1310+ ]),
1311+ );
1312+ for (const result of [
1313+ detail,
1314+ heads,
1315+ runs,
1316+ detail,
1317+ heads,
1318+ runs,
1319+ output(""),
1320+ detail,
1321+ heads,
1322+ runs,
1323+ output(""),
1324+ ]) {
1325+ mockedExecute.mockReturnValueOnce(Effect.succeed(result));
1326+ }
1327+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1328+
1329+ yield* cli.runPullRequestAction({
1330+ cwd: "/w",
1331+ repository: "acme/web",
1332+ host: "github.com",
1333+ number: 7,
1334+ action: "approve-workflows",
1335+ });
1336+
1337+ expect(callAt(1).args).toEqual([
1338+ "pr",
1339+ "list",
1340+ "--repo",
1341+ "github.com/acme/web",
1342+ "--state",
1343+ "open",
1344+ "--head",
1345+ "feat/page",
1346+ "--limit",
1347+ "1001",
1348+ "--json",
1349+ "number,headRefOid,isCrossRepository,headRepositoryOwner",
1350+ ]);
1351+ expect(callAt(2).args).toEqual([
1352+ "run",
1353+ "list",
1354+ "--repo",
1355+ "github.com/acme/web",
1356+ "--commit",
1357+ "abc123",
1358+ "--branch",
1359+ "feat/page",
1360+ "--event",
1361+ "pull_request",
1362+ "--status",
1363+ "action_required",
1364+ "--limit",
1365+ "1001",
1366+ "--json",
1367+ "databaseId,workflowName,url",
1368+ ]);
1369+ expect([callAt(6).args, callAt(10).args]).toEqual([
1370+ [
1371+ "api",
1372+ "--method",
1373+ "POST",
1374+ "--hostname",
1375+ "github.com",
1376+ "repos/acme/web/actions/runs/10/approve",
1377+ "--silent",
1378+ ],
1379+ [
1380+ "api",
1381+ "--method",
1382+ "POST",
1383+ "--hostname",
1384+ "github.com",
1385+ "repos/acme/web/actions/runs/11/approve",
1386+ "--silent",
1387+ ],
1388+ ]);
1389+ expect(mockedExecute).toHaveBeenCalledTimes(11);
1390+ }),
1391+ );
1392+
1393+ it.effect("refuses a stale workflow approval after the pull request head changes", () =>
1394+ Effect.gen(function* () {
1395+ const detail = {
1396+ number: 7,
1397+ title: "Pull request 7",
1398+ url: "https://github.com/acme/web/pull/7",
1399+ headRefName: "feat/page",
1400+ headRefOid: "abc123",
1401+ isCrossRepository: true,
1402+ headRepositoryOwner: { login: "octocat" },
1403+ baseRefName: "main",
1404+ createdAt: "2026-07-01T00:00:00Z",
1405+ updatedAt: "2026-07-02T00:00:00Z",
1406+ };
1407+ for (const value of [
1408+ detail,
1409+ [
1410+ {
1411+ number: 7,
1412+ headRefOid: "abc123",
1413+ isCrossRepository: true,
1414+ headRepositoryOwner: { login: "octocat" },
1415+ },
1416+ ],
1417+ [{ databaseId: 10, workflowName: "build", url: "https://example.com/10" }],
1418+ { ...detail, headRefOid: "def456" },
1419+ ]) {
1420+ mockedExecute.mockReturnValueOnce(
1421+ Effect.succeed(
1422+ output(
1423+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1424+ JSON.stringify(value),
1425+ ),
1426+ ),
1427+ );
1428+ }
1429+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1430+
1431+ const error = yield* Effect.flip(
1432+ cli.runPullRequestAction({
1433+ cwd: "/w",
1434+ repository: "acme/web",
1435+ host: "github.com",
1436+ number: 7,
1437+ action: "approve-workflows",
1438+ }),
1439+ );
1440+
1441+ expect(error).toMatchObject({
1442+ _tag: "GitHubWorkflowApprovalHeadChangedError",
1443+ number: 7,
1444+ });
1445+ expect(mockedExecute).toHaveBeenCalledTimes(4);
1446+ }),
1447+ );
1448+
1449+ it.effect("refuses workflow approval when one head belongs to several pull requests", () =>
1450+ Effect.gen(function* () {
1451+ mockedExecute.mockReturnValueOnce(
1452+ Effect.succeed(
1453+ output(
1454+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1455+ JSON.stringify(
1456+ [7, 8].map((number) => ({
1457+ number,
1458+ headRefOid: "abc123",
1459+ isCrossRepository: true,
1460+ headRepositoryOwner: { login: "octocat" },
1461+ })),
1462+ ),
1463+ ),
1464+ ),
1465+ );
1466+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1467+
1468+ const error = yield* Effect.flip(
1469+ cli.listWorkflowRunsRequiringApproval({
1470+ cwd: "/w",
1471+ repository: "acme/web",
1472+ host: "github.com",
1473+ number: 7,
1474+ headSha: "abc123",
1475+ headBranch: "feat/page",
1476+ headRepositoryOwner: "octocat",
1477+ isCrossRepository: true,
1478+ }),
1479+ );
1480+
1481+ expect(error).toMatchObject({
1482+ _tag: "GitHubWorkflowApprovalRefusedError",
1483+ reason: "head-not-unique",
1484+ number: 7,
1485+ observedCount: 2,
1486+ limit: 1_000,
1487+ });
1488+ expect(error.detail).toContain("instead of uniquely matching #7");
1489+ expect(mockedExecute).toHaveBeenCalledTimes(1);
1490+ }),
1491+ );
1492+
1493+ it.effect("refuses workflow approval when GitHub omits the head repository", () =>
1494+ Effect.gen(function* () {
1495+ mockedExecute.mockReturnValueOnce(
1496+ Effect.succeed(
1497+ output(
1498+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1499+ JSON.stringify({
1500+ number: 7,
1501+ title: "Pull request 7",
1502+ url: "https://github.com/acme/web/pull/7",
1503+ headRefName: "feat/page",
1504+ headRefOid: "abc123",
1505+ isCrossRepository: true,
1506+ headRepositoryOwner: null,
1507+ baseRefName: "main",
1508+ createdAt: "2026-07-01T00:00:00Z",
1509+ updatedAt: "2026-07-02T00:00:00Z",
1510+ }),
1511+ ),
1512+ ),
1513+ );
1514+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1515+
1516+ const error = yield* Effect.flip(
1517+ cli.runPullRequestAction({
1518+ cwd: "/w",
1519+ repository: "acme/web",
1520+ host: "github.com",
1521+ number: 7,
1522+ action: "approve-workflows",
1523+ }),
1524+ );
1525+
1526+ expect(error).toMatchObject({
1527+ _tag: "GitHubWorkflowApprovalHeadUnavailableError",
1528+ number: 7,
1529+ });
1530+ expect(mockedExecute).toHaveBeenCalledTimes(1);
1531+ }),
1532+ );
1533+
1534+ it.effect("surfaces a workflow run list beyond the safe approval bound", () =>
1535+ Effect.gen(function* () {
1536+ mockedExecute.mockReturnValueOnce(
1537+ Effect.succeed(
1538+ output(
1539+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1540+ JSON.stringify([
1541+ {
1542+ number: 7,
1543+ headRefOid: "abc123",
1544+ isCrossRepository: true,
1545+ headRepositoryOwner: { login: "octocat" },
1546+ },
1547+ ]),
1548+ ),
1549+ ),
1550+ );
1551+ mockedExecute.mockReturnValueOnce(
1552+ Effect.succeed(
1553+ output(
1554+ // @effect-diagnostics-next-line preferSchemaOverJson:off - canned gh response.
1555+ JSON.stringify(Array.from({ length: 1_001 }, (_, id) => ({ databaseId: id + 1 }))),
1556+ ),
1557+ ),
1558+ );
1559+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
1560+
1561+ const error = yield* Effect.flip(
1562+ cli.listWorkflowRunsRequiringApproval({
1563+ cwd: "/w",
1564+ repository: "acme/web",
1565+ host: "github.com",
1566+ number: 7,
1567+ headSha: "abc123",
1568+ headBranch: "feat/page",
1569+ headRepositoryOwner: "octocat",
1570+ isCrossRepository: true,
1571+ }),
1572+ );
1573+
1574+ expect(error).toMatchObject({
1575+ _tag: "GitHubWorkflowApprovalRefusedError",
1576+ reason: "run-list-truncated",
1577+ number: 7,
1578+ observedCount: 1_001,
1579+ limit: 1_000,
1580+ });
1581+ expect(error.detail).toContain("more than 1000 workflow runs");
1582+ expect(mockedExecute).toHaveBeenCalledTimes(2);
1583+ }),
1584+ );
1585+
12031586 it.effect("returns a pull request to draft by undoing ready", () =>
12041587 Effect.gen(function* () {
12051588 mockedExecute.mockReturnValue(Effect.succeed(output("")));
@@ -2136,7 +2519,7 @@ layer("GitHubPullRequestCli.layer", (it) => { · 930 unmodified lines
21362519 expect(detail.body).toBe("Core body");
21372520 expect(activity.author?.login).toBe("octocat");
21382521 expect(callAt(0).args.at(-1)).toBe(
2139− "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest",
2522+ "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest",
21402523 );
21412524 expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits");
21422525 }),
apps/server/src/pullRequest/GitHubPullRequestCli.ts+342−28
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
3434 decodePullRequestActivityJson,
3535 decodePullRequestDetailJson,
3636 decodePullRequestFilesJson,
37+ decodePullRequestHeadsJson,
3738 decodePullRequestListJson,
3839 decodePullRequestNodeIdJson,
3940 decodePullRequestSearchJson,
@@ -56,6 +57,7 @@ import { · 16 unmodified lines
5657 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
5758 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
5859 REMOVE_REACTION_GRAPHQL_MUTATION,
60+ REVERT_PULL_REQUEST_GRAPHQL_MUTATION,
5961 gitHubReactionContent,
6062 REPOSITORY_ACCESS_JSON_FIELDS,
6163 RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
@@ -71,13 +73,16 @@ import { · 9 unmodified lines
7173 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
7274 VIEWER_PERMISSIONS_GRAPHQL_QUERY,
7375 decodeViewerPermissionsJson,
76+ decodeWorkflowRunApprovalsJson,
7477 type GitHubBaseComparison,
7578 type GitHubPullRequestDetail,
7679 type GitHubPullRequestActivity,
80+ type GitHubPullRequestHead,
7781 type GitHubPullRequestListItem,
7882 type GitHubPullRequestSearchItem,
7983 type GitHubReviewThreadComments,
8084 type GitHubRepositoryAccess,
85+ type GitHubWorkflowRunApproval,
8186 type GitHubReviewThreadEntry,
8287 type GitHubReviewThreadPage,
8388 type GitHubViewerAccess,
@@ -259,6 +264,69 @@ export class GitHubSubjectScopeError extends Schema.TaggedErrorClass<GitHubSubje · 175 unmodified lines
259264 }
260265 }
261266
267+/** GitHub answered successfully, but approving every returned workflow would be unsafe. */
268+export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedErrorClass<GitHubWorkflowApprovalRefusedError>()(
269+ "GitHubWorkflowApprovalRefusedError",
270+ {
271+ command: Schema.Literal("gh"),
272+ cwd: Schema.String,
273+ number: Schema.Int,
274+ reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]),
275+ observedCount: Schema.Int,
276+ limit: Schema.Int,
277+ },
278+) {
279+ get detail(): string {
280+ if (this.reason === "head-list-truncated") {
281+ return `GitHub returned more than ${this.limit} pull requests for this head branch.`;
282+ }
283+ if (this.reason === "head-not-unique") {
284+ return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`;
285+ }
286+ return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`;
287+ }
288+
289+ override get message(): string {
290+ return `GitHub CLI refused listWorkflowRunsRequiringApproval: ${this.detail}`;
291+ }
292+}
293+
294+/** GitHub omitted the immutable head identity needed to scope an approval safely. */
295+export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErrorClass<GitHubWorkflowApprovalHeadUnavailableError>()(
296+ "GitHubWorkflowApprovalHeadUnavailableError",
297+ {
298+ command: Schema.Literal("gh"),
299+ cwd: Schema.String,
300+ number: Schema.Int,
301+ },
302+) {
303+ get detail(): string {
304+ return `GitHub did not report a complete head revision for #${this.number}.`;
305+ }
306+
307+ override get message(): string {
308+ return `GitHub CLI refused approve-workflows: ${this.detail}`;
309+ }
310+}
311+
312+/** The pull request moved after its approval candidates were read. */
313+export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedErrorClass<GitHubWorkflowApprovalHeadChangedError>()(
314+ "GitHubWorkflowApprovalHeadChangedError",
315+ {
316+ command: Schema.Literal("gh"),
317+ cwd: Schema.String,
318+ number: Schema.Int,
319+ },
320+) {
321+ get detail(): string {
322+ return `The head revision of #${this.number} changed before its workflows could be approved.`;
323+ }
324+
325+ override get message(): string {
326+ return `GitHub CLI refused approve-workflows: ${this.detail}`;
327+ }
328+}
329+
262330 export type GitHubPullRequestCliError =
263331 | GitHubCli.GitHubCliError
264332 | GitHubPullRequestReadError
@@ -268,6 +336,9 @@ export type GitHubPullRequestCliError = · 3 unmodified lines
268336 | GitHubDiffFileContentsUnavailableError
269337 | GitHubRepositorySelectorError
270338 | GitHubSubjectScopeError
339+ | GitHubWorkflowApprovalRefusedError
340+ | GitHubWorkflowApprovalHeadUnavailableError
341+ | GitHubWorkflowApprovalHeadChangedError
271342 | SourceControlRateLimit.SourceControlRateLimitPausedError
272343 | GitHubViewerLoginUnavailableError
273344 | GitHubPullRequestUpdatedAtUnavailableError;
@@ -405,6 +476,17 @@ export class GitHubPullRequestCli extends Context.Service< · 131 unmodified lines
405476 readonly number: number;
406477 }) => Effect.Effect<GitHubPullRequestDetail, GitHubPullRequestCliError>;
407478
479+ readonly listWorkflowRunsRequiringApproval: (input: {
480+ readonly cwd: string;
481+ readonly repository: string;
482+ readonly host: string;
483+ readonly number: number;
484+ readonly headSha: string;
485+ readonly headBranch: string;
486+ readonly headRepositoryOwner: string;
487+ readonly isCrossRepository: true;
488+ }) => Effect.Effect<ReadonlyArray<GitHubWorkflowRunApproval>, GitHubPullRequestCliError>;
489+
408490 /**
409491 * How far the branch trails its base, and whether this viewer may update it. Its own read
410492 * because the comparison needs the head ref the detail answers with — a fork's branch is not
@@ -864,6 +946,12 @@ function actionArgs( · 453 unmodified lines
864946 return ["close"];
865947 case "reopen":
866948 return ["reopen"];
949+ case "revert":
950+ throw new Error("Revert requires a GraphQL mutation");
951+ // Handled separately because it may approve several workflow runs rather than mutate the
952+ // pull request itself.
953+ case "approve-workflows":
954+ throw new Error("Workflow approval requires run discovery");
867955 }
868956 }
869957
@@ -1194,6 +1282,158 @@ export const make = Effect.gen(function* () { · 324 unmodified lines
11941282 return { oldContents, newContents };
11951283 });
11961284
1285+ const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) =>
1286+ github
1287+ .execute({
1288+ cwd: input.cwd,
1289+ args: [
1290+ "pr",
1291+ "view",
1292+ String(input.number),
1293+ ...repositoryArgs(input),
1294+ "--json",
1295+ PULL_REQUEST_DETAIL_JSON_FIELDS,
1296+ ],
1297+ })
1298+ .pipe(
1299+ Effect.flatMap((result) => {
1300+ const decoded = decodePullRequestDetailJson(result.stdout.trim());
1301+ return Result.isSuccess(decoded)
1302+ ? Effect.succeed(decoded.success)
1303+ : Effect.fail(
1304+ new GitHubPullRequestReadError({
1305+ command: "gh",
1306+ cwd: input.cwd,
1307+ operation: "getPullRequestDetail",
1308+ cause: decoded.failure,
1309+ }),
1310+ );
1311+ }),
1312+ );
1313+
1314+ const workflowApprovalLimit = 1_000;
1315+ const workflowApprovalProbeLimit = String(workflowApprovalLimit + 1);
1316+ const workflowApprovalReadError = (cwd: string, cause: unknown) =>
1317+ new GitHubPullRequestReadError({
1318+ command: "gh",
1319+ cwd,
1320+ operation: "listWorkflowRunsRequiringApproval",
1321+ cause,
1322+ });
1323+ const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] =
1324+ (input) =>
1325+ github
1326+ .execute({
1327+ cwd: input.cwd,
1328+ args: [
1329+ "pr",
1330+ "list",
1331+ ...repositoryArgs(input),
1332+ "--state",
1333+ "open",
1334+ "--head",
1335+ input.headBranch,
1336+ "--limit",
1337+ workflowApprovalProbeLimit,
1338+ "--json",
1339+ "number,headRefOid,isCrossRepository,headRepositoryOwner",
1340+ ],
1341+ })
1342+ .pipe(
1343+ Effect.flatMap(
1344+ (
1345+ result,
1346+ ): Effect.Effect<
1347+ GitHubPullRequestHead,
1348+ GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1349+ > => {
1350+ const decoded = decodePullRequestHeadsJson(result.stdout.trim());
1351+ if (!Result.isSuccess(decoded)) {
1352+ return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1353+ }
1354+ const exactHeads = decoded.success.filter(
1355+ (pullRequest) =>
1356+ pullRequest.headSha === input.headSha &&
1357+ pullRequest.isCrossRepository === true &&
1358+ pullRequest.headRepositoryOwner?.toLowerCase() ===
1359+ input.headRepositoryOwner.toLowerCase(),
1360+ );
1361+ if (decoded.success.length > workflowApprovalLimit) {
1362+ return Effect.fail(
1363+ new GitHubWorkflowApprovalRefusedError({
1364+ command: "gh",
1365+ cwd: input.cwd,
1366+ number: input.number,
1367+ reason: "head-list-truncated",
1368+ observedCount: decoded.success.length,
1369+ limit: workflowApprovalLimit,
1370+ }),
1371+ );
1372+ }
1373+ if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) {
1374+ return Effect.fail(
1375+ new GitHubWorkflowApprovalRefusedError({
1376+ command: "gh",
1377+ cwd: input.cwd,
1378+ number: input.number,
1379+ reason: "head-not-unique",
1380+ observedCount: exactHeads.length,
1381+ limit: workflowApprovalLimit,
1382+ }),
1383+ );
1384+ }
1385+ return Effect.succeed(exactHeads[0]);
1386+ },
1387+ ),
1388+ Effect.flatMap(() =>
1389+ github.execute({
1390+ cwd: input.cwd,
1391+ args: [
1392+ "run",
1393+ "list",
1394+ ...repositoryArgs(input),
1395+ "--commit",
1396+ input.headSha,
1397+ "--branch",
1398+ input.headBranch,
1399+ "--event",
1400+ "pull_request",
1401+ "--status",
1402+ "action_required",
1403+ "--limit",
1404+ workflowApprovalProbeLimit,
1405+ "--json",
1406+ "databaseId,workflowName,url",
1407+ ],
1408+ }),
1409+ ),
1410+ Effect.flatMap(
1411+ (
1412+ result,
1413+ ): Effect.Effect<
1414+ ReadonlyArray<GitHubWorkflowRunApproval>,
1415+ GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError
1416+ > => {
1417+ const decoded = decodeWorkflowRunApprovalsJson(result.stdout.trim());
1418+ if (!Result.isSuccess(decoded)) {
1419+ return Effect.fail(workflowApprovalReadError(input.cwd, decoded.failure));
1420+ }
1421+ return decoded.success.length > workflowApprovalLimit
1422+ ? Effect.fail(
1423+ new GitHubWorkflowApprovalRefusedError({
1424+ command: "gh",
1425+ cwd: input.cwd,
1426+ number: input.number,
1427+ reason: "run-list-truncated",
1428+ observedCount: decoded.success.length,
1429+ limit: workflowApprovalLimit,
1430+ }),
1431+ )
1432+ : Effect.succeed(decoded.success);
1433+ },
1434+ ),
1435+ );
1436+
11971437 return GitHubPullRequestCli.of({
11981438 getViewerLogin: (input) =>
11991439 github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe(
@@ -1393,34 +1633,8 @@ export const make = Effect.gen(function* () { · 193 unmodified lines
13931633 ),
13941634 ),
13951635
1396− getPullRequestDetail: (input) =>
1397− github
1398− .execute({
1399− cwd: input.cwd,
1400− args: [
1401− "pr",
1402− "view",
1403− String(input.number),
1404− ...repositoryArgs(input),
1405− "--json",
1406− PULL_REQUEST_DETAIL_JSON_FIELDS,
1407− ],
1408− })
1409− .pipe(
1410− Effect.flatMap((result) => {
1411− const decoded = decodePullRequestDetailJson(result.stdout.trim());
1412− return Result.isSuccess(decoded)
1413− ? Effect.succeed(decoded.success)
1414− : Effect.fail(
1415− new GitHubPullRequestReadError({
1416− command: "gh",
1417− cwd: input.cwd,
1418− operation: "getPullRequestDetail",
1419− cause: decoded.failure,
1420− }),
1421− );
1422− }),
1423− ),
1636+ getPullRequestDetail,
1637+ listWorkflowRunsRequiringApproval,
14241638
14251639 getPullRequestBaseComparison: (input) => {
14261640 const { owner, name } = parseRepositorySelector(input.repository);
@@ -1764,6 +1978,106 @@ export const make = Effect.gen(function* () { · 337 unmodified lines
17641978 },
17651979
17661980 runPullRequestAction: (input) => {
1981+ if (input.action === "revert") {
1982+ return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(
1983+ Effect.flatMap((pullRequestId) =>
1984+ graphql({
1985+ cwd: input.cwd,
1986+ host: input.host,
1987+ query: REVERT_PULL_REQUEST_GRAPHQL_MUTATION,
1988+ variables: { pullRequestId },
1989+ }),
1990+ ),
1991+ );
1992+ }
1993+ if (input.action === "approve-workflows") {
1994+ const { owner, name } = parseRepositorySelector(input.repository);
1995+ return getPullRequestDetail(input).pipe(
1996+ Effect.flatMap((detail) => {
1997+ if (detail.isCrossRepository !== true) return Effect.void;
1998+ if (detail.headSha == null || detail.headRepositoryOwner == null) {
1999+ return Effect.fail(
2000+ new GitHubWorkflowApprovalHeadUnavailableError({
2001+ command: "gh",
2002+ cwd: input.cwd,
2003+ number: input.number,
2004+ }),
2005+ );
2006+ }
2007+ const expectedHeadSha = detail.headSha;
2008+ const expectedHeadBranch = detail.headBranch;
2009+ const expectedHeadRepositoryOwner = detail.headRepositoryOwner;
2010+ return listWorkflowRunsRequiringApproval({
2011+ ...input,
2012+ headSha: expectedHeadSha,
2013+ headBranch: expectedHeadBranch,
2014+ headRepositoryOwner: expectedHeadRepositoryOwner,
2015+ isCrossRepository: true,
2016+ }).pipe(
2017+ Effect.flatMap((runs) =>
2018+ Effect.forEach(
2019+ runs,
2020+ (run) =>
2021+ getPullRequestDetail(input).pipe(
2022+ Effect.flatMap((current) => {
2023+ if (current.headSha == null || current.headRepositoryOwner == null) {
2024+ return Effect.fail(
2025+ new GitHubWorkflowApprovalHeadUnavailableError({
2026+ command: "gh",
2027+ cwd: input.cwd,
2028+ number: input.number,
2029+ }),
2030+ );
2031+ }
2032+ if (
2033+ current.isCrossRepository !== true ||
2034+ current.headSha !== expectedHeadSha ||
2035+ current.headBranch !== expectedHeadBranch ||
2036+ current.headRepositoryOwner.toLowerCase() !==
2037+ expectedHeadRepositoryOwner.toLowerCase()
2038+ ) {
2039+ return Effect.fail(
2040+ new GitHubWorkflowApprovalHeadChangedError({
2041+ command: "gh",
2042+ cwd: input.cwd,
2043+ number: input.number,
2044+ }),
2045+ );
2046+ }
2047+ return listWorkflowRunsRequiringApproval({
2048+ ...input,
2049+ headSha: current.headSha,
2050+ headBranch: current.headBranch,
2051+ headRepositoryOwner: current.headRepositoryOwner,
2052+ isCrossRepository: true,
2053+ });
2054+ }),
2055+ Effect.flatMap((currentRuns) =>
2056+ currentRuns.some((current) => current.id === run.id)
2057+ ? github
2058+ .execute({
2059+ cwd: input.cwd,
2060+ args: [
2061+ "api",
2062+ "--method",
2063+ "POST",
2064+ "--hostname",
2065+ input.host,
2066+ `repos/${owner}/${name}/actions/runs/${run.id}/approve`,
2067+ "--silent",
2068+ ],
2069+ })
2070+ .pipe(Effect.asVoid)
2071+ : Effect.void,
2072+ ),
2073+ ),
2074+ { concurrency: 1, discard: true },
2075+ ),
2076+ ),
2077+ );
2078+ }),
2079+ );
2080+ }
17672081 const [subcommand, ...flags] = actionArgs(
17682082 input.action,
17692083 input.mergeMethod,
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+256−30
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
33 import * as Layer from "effect/Layer";
44 import type { PullRequestReaction } from "@t3tools/contracts";
55
6+import * as GitHubCli from "../sourceControl/GitHubCli.ts";
67 import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
78 import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
89 import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
@@ -52,6 +53,8 @@ describe("gitHubViewerPermissions", () => { · 43 unmodified lines
5253 "merge",
5354 "enable-auto-merge",
5455 "disable-auto-merge",
56+ "revert",
57+ "approve-workflows",
5558 "ready",
5659 "draft",
5760 "close",
@@ -108,6 +111,13 @@ describe("gitHubViewerPermissions", () => { · 50 unmodified lines
108111 verdicts: ["comment", "approve", "request-changes"],
109112 requestReviewers: false,
110113 });
114+ expect(detail.workflowApprovalsRequired).toBeUndefined();
115+ expect(detail.checks).toContainEqual({
116+ name: "Workflow approval status",
117+ status: "action-required",
118+ description: "GitHub could not determine whether workflows are awaiting approval.",
119+ url: null,
120+ });
111121 }).pipe(
112122 Effect.provide(
113123 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
@@ -118,6 +128,7 @@ describe("gitHubViewerPermissions", () => { · 4 unmodified lines
118128 title: "Pull request 7",
119129 url: "https://github.com/acme/web/pull/7",
120130 author: null,
131+ isCrossRepository: true,
121132 headRepositoryOwner: null,
122133 headBranch: "feat/page",
123134 baseBranch: "main",
@@ -152,39 +163,254 @@ describe("gitHubViewerPermissions", () => { · 28 unmodified lines
152163 ),
153164 ),
154165 );
166+
167+ it.effect("keeps fork workflows awaiting approval out of the passing state", () =>
168+ Effect.gen(function* () {
169+ const provider = yield* make;
170+ const detail = yield* provider.getChangeRequest({
171+ cwd: "/w",
172+ repository: "acme/web",
173+ host: "github.com",
174+ number: 7,
175+ });
176+
177+ expect(detail.workflowApprovalsRequired).toBe(1);
178+ expect(detail.checks).toEqual([
179+ {
180+ name: "manual gate",
181+ status: "action-required",
182+ description: null,
183+ url: "https://example.com/manual-gate",
184+ },
185+ {
186+ name: "build",
187+ status: "success",
188+ description: null,
189+ url: null,
190+ },
191+ {
192+ name: "contributor tests",
193+ status: "action-required",
194+ description: "A maintainer must approve this workflow before it can run.",
195+ url: "https://github.com/acme/web/actions/runs/123",
196+ },
197+ ]);
198+ }).pipe(
199+ Effect.provide(
200+ Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
201+ getPullRequestDetail: () =>
202+ Effect.succeed({
203+ authorId: null,
204+ number: 7,
205+ title: "Pull request 7",
206+ url: "https://github.com/acme/web/pull/7",
207+ author: null,
208+ isCrossRepository: true,
209+ headRepositoryOwner: "octocat",
210+ headSha: "abc123",
211+ headBranch: "feat/page",
212+ baseBranch: "main",
213+ state: "open",
214+ isDraft: false,
215+ mergeability: "mergeable",
216+ reviewDecision: null,
217+ additions: 1,
218+ deletions: 1,
219+ createdAt: "2026-07-01T00:00:00Z",
220+ updatedAt: "2026-07-02T00:00:00Z",
221+ reviewRequestLogins: [],
222+ hasTeamReviewRequest: false,
223+ checksState: "passing",
224+ labels: [],
225+ body: "",
226+ changedFiles: 1,
227+ mergedAt: null,
228+ closedAt: null,
229+ checks: [
230+ {
231+ name: "manual gate",
232+ status: "action-required",
233+ description: null,
234+ url: "https://example.com/manual-gate",
235+ },
236+ { name: "build", status: "success", description: null, url: null },
237+ ],
238+ comments: [],
239+ commits: [],
240+ }),
241+ listWorkflowRunsRequiringApproval: () =>
242+ Effect.succeed([
243+ {
244+ id: 123,
245+ name: "contributor tests",
246+ url: "https://github.com/acme/web/actions/runs/123",
247+ },
248+ ]),
249+ getPullRequestBaseComparison: () =>
250+ Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
251+ getRepositoryAccess: () =>
252+ Effect.succeed({
253+ canWrite: true,
254+ mergeCapabilities: { merge: true, squash: true, rebase: true },
255+ }),
256+ getViewerAccess: () =>
257+ Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
258+ }),
259+ ),
260+ ),
261+ );
155262 });
156263
157−describe("getViewerPermissions", () => {
158− const openDetail = {
159− authorId: null,
160− number: 7,
161− title: "Pull request 7",
162− url: "https://github.com/acme/web/pull/7",
163− author: null,
164− headRepositoryOwner: "acme",
165− headBranch: "feat/page",
166− baseBranch: "main",
167− state: "open" as const,
168− isDraft: false,
169− mergeability: "mergeable" as const,
170− reviewDecision: null,
171− additions: 1,
172− deletions: 1,
173− createdAt: "2026-07-01T00:00:00Z",
174− updatedAt: "2026-07-02T00:00:00Z",
175− reviewRequestLogins: [],
176− hasTeamReviewRequest: false,
177− checksState: null,
178− labels: [],
179− body: "",
180− changedFiles: 1,
181− mergedAt: null,
182− closedAt: null,
183− checks: [],
184− comments: [],
185− commits: [],
186− };
264+const openDetail = {
265+ authorId: null,
266+ number: 7,
267+ title: "Pull request 7",
268+ url: "https://github.com/acme/web/pull/7",
269+ author: null,
270+ isCrossRepository: true,
271+ headRepositoryOwner: "acme",
272+ headSha: "abc123",
273+ headBranch: "feat/page",
274+ baseBranch: "main",
275+ state: "open" as const,
276+ isDraft: false,
277+ mergeability: "mergeable" as const,
278+ reviewDecision: null,
279+ additions: 1,
280+ deletions: 1,
281+ createdAt: "2026-07-01T00:00:00Z",
282+ updatedAt: "2026-07-02T00:00:00Z",
283+ reviewRequestLogins: [],
284+ hasTeamReviewRequest: false,
285+ checksState: null,
286+ labels: [],
287+ body: "",
288+ changedFiles: 1,
289+ mergedAt: null,
290+ closedAt: null,
291+ checks: [],
292+ comments: [],
293+ commits: [],
294+};
295+
296+it.effect("does not classify same-repository gates as fork workflow approvals", () =>
297+ Effect.gen(function* () {
298+ const provider = yield* make;
299+ const detail = yield* provider.getChangeRequest({
300+ cwd: "/w",
301+ repository: "acme/web",
302+ host: "github.com",
303+ number: 7,
304+ });
187305
306+ expect(detail.workflowApprovalsRequired).toBe(0);
307+ expect(detail.checks).toEqual([]);
308+ }).pipe(
309+ Effect.provide(
310+ Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
311+ getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }),
312+ getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
313+ listWorkflowRunsRequiringApproval: () =>
314+ Effect.die("same-repository pull requests must not probe fork workflow approvals"),
315+ getRepositoryAccess: () =>
316+ Effect.succeed({
317+ canWrite: true,
318+ mergeCapabilities: { merge: true, squash: true, rebase: true },
319+ }),
320+ getViewerAccess: () =>
321+ Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
322+ }),
323+ ),
324+ ),
325+);
326+
327+it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
328+ Effect.gen(function* () {
329+ const provider = yield* make;
330+ const detail = yield* provider.getChangeRequest({
331+ cwd: "/w",
332+ repository: "acme/web",
333+ host: "github.com",
334+ number: 7,
335+ });
336+
337+ expect(detail.workflowApprovalsRequired).toBeUndefined();
338+ expect(detail.checks).toEqual([
339+ {
340+ name: "Workflow approval status",
341+ status: "action-required",
342+ description: "GitHub could not determine whether workflows are awaiting approval.",
343+ url: null,
344+ },
345+ ]);
346+ }).pipe(
347+ Effect.provide(
348+ Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
349+ getPullRequestDetail: () => Effect.succeed(openDetail),
350+ getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
351+ listWorkflowRunsRequiringApproval: () =>
352+ Effect.fail(
353+ new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({
354+ command: "gh",
355+ cwd: "/w",
356+ number: 7,
357+ reason: "head-not-unique",
358+ observedCount: 2,
359+ limit: 1_000,
360+ }),
361+ ),
362+ getRepositoryAccess: () =>
363+ Effect.succeed({
364+ canWrite: true,
365+ mergeCapabilities: { merge: true, squash: true, rebase: true },
366+ }),
367+ getViewerAccess: () =>
368+ Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
369+ }),
370+ ),
371+ ),
372+);
373+
374+it.effect("propagates workflow discovery rate limits", () =>
375+ Effect.gen(function* () {
376+ const provider = yield* make;
377+ const error = yield* provider
378+ .getChangeRequest({
379+ cwd: "/w",
380+ repository: "acme/web",
381+ host: "github.com",
382+ number: 7,
383+ })
384+ .pipe(Effect.flip);
385+
386+ expect(error.operation).toBe("getChangeRequest");
387+ expect(error.reason).toBe("rate-limited");
388+ }).pipe(
389+ Effect.provide(
390+ Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
391+ getPullRequestDetail: () => Effect.succeed(openDetail),
392+ getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
393+ listWorkflowRunsRequiringApproval: () =>
394+ Effect.fail(
395+ new GitHubCli.GitHubCliRateLimitError({
396+ command: "gh",
397+ cwd: "/w",
398+ cause: new Error("rate limited"),
399+ }),
400+ ),
401+ getRepositoryAccess: () =>
402+ Effect.succeed({
403+ canWrite: true,
404+ mergeCapabilities: { merge: true, squash: true, rebase: true },
405+ }),
406+ getViewerAccess: () =>
407+ Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }),
408+ }),
409+ ),
410+ ),
411+);
412+
413+describe("getViewerPermissions", () => {
188414 const layerWithComparison = (
189415 comparison: Effect.Effect<{
190416 readonly behindBy: number | null;
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+106−16
@@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines
55 import type {
66 PullRequestActor,
77 PullRequestCapabilities,
8+ PullRequestCheck,
89 PullRequestReaction,
910 PullRequestViewerPermissions,
1011 } from "@t3tools/contracts";
@@ -17,7 +18,7 @@ import { · 6 unmodified lines
1718 type ProviderChangeRequestDetail,
1819 type PullRequestProviderApi,
1920 } from "./PullRequestProvider.ts";
20−import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts";
21+import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
2122
2223 const CAPABILITIES: PullRequestCapabilities = {
2324 diff: true,
@@ -31,6 +32,8 @@ const CAPABILITIES: PullRequestCapabilities = { · 7 unmodified lines
3132 "update-branch",
3233 "enable-auto-merge",
3334 "disable-auto-merge",
35+ "revert",
36+ "approve-workflows",
3437 ],
3538 mergeMethods: ["merge", "squash", "rebase"],
3639 updateMethods: ["merge", "rebase"],
@@ -68,7 +71,15 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest · 31 unmodified lines
6871 actions: [
6972 // Arming a merge and taking the arming back are the merge, deferred: whoever may not
7073 // merge here may not leave an instruction to merge later either.
71− ...(access.canWrite ? (["merge", "enable-auto-merge", "disable-auto-merge"] as const) : []),
74+ ...(access.canWrite
75+ ? ([
76+ "merge",
77+ "enable-auto-merge",
78+ "disable-auto-merge",
79+ "revert",
80+ "approve-workflows",
81+ ] as const)
82+ : []),
7283 ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []),
7384 // Whether this viewer may update the branch is GitHub's own answer, read with the
7485 // comparison; without it the action is offered to nobody rather than to everybody.
@@ -117,6 +128,43 @@ function withAvatar( · 42 unmodified lines
117128 return avatarUrl === null ? actor : { ...actor, avatarUrl };
118129 }
119130
131+function withWorkflowApprovals(
132+ checks: ReadonlyArray<PullRequestCheck>,
133+ runs: ReadonlyArray<GitHubWorkflowRunApproval>,
134+ unavailable: boolean,
135+): ReadonlyArray<PullRequestCheck> {
136+ const representedRunIds = new Set<number>();
137+ for (const check of checks) {
138+ if (check.status !== "action-required" || check.url === null) continue;
139+ const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1];
140+ if (id !== undefined) representedRunIds.add(Number(id));
141+ }
142+ const approvalChecks = runs
143+ .filter((run) => !representedRunIds.has(run.id))
144+ .map(
145+ (run): PullRequestCheck => ({
146+ name: run.name,
147+ status: "action-required",
148+ description: "A maintainer must approve this workflow before it can run.",
149+ url: run.url,
150+ }),
151+ );
152+ return [
153+ ...checks,
154+ ...approvalChecks,
155+ ...(unavailable
156+ ? [
157+ {
158+ name: "Workflow approval status",
159+ status: "action-required" as const,
160+ description: "GitHub could not determine whether workflows are awaiting approval.",
161+ url: null,
162+ },
163+ ]
164+ : []),
165+ ];
166+}
167+
120168 /**
121169 * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which
122170 * names no page, and a guessed URL that 404s is worse than the initials it would replace.
@@ -250,20 +298,54 @@ export const make = Effect.gen(function* () { · 127 unmodified lines
250298 [
251299 cli.getPullRequestDetail(input).pipe(
252300 Effect.flatMap((pullRequest) =>
253− // Only an open pull request can be behind anything worth saying so about, and only
254− // one whose head repository is known can be compared at all. A comparison that
255− // fails is left unknown: the banner is an offer, never a blocker.
256− pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
257− ? Effect.succeed({ pullRequest, comparison: null })
258− : cli
259− .getPullRequestBaseComparison({
260− ...input,
261− headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
262− })
263− .pipe(
264− Effect.map((comparison) => ({ pullRequest, comparison })),
265− Effect.orElseSucceed(() => ({ pullRequest, comparison: null })),
266− ),
301+ Effect.all({
302+ // Only an open pull request can be behind anything worth saying so about, and
303+ // only one whose head repository is known can be compared at all.
304+ comparison:
305+ pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
306+ ? Effect.succeed(null)
307+ : cli
308+ .getPullRequestBaseComparison({
309+ ...input,
310+ headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
311+ })
312+ .pipe(Effect.orElseSucceed(() => null)),
313+ // GitHub omits a fork workflow that has not been approved from the normal check
314+ // rollup. Read the action-required runs by head revision so "all passed" cannot
315+ // be shown while a whole workflow is still waiting to start.
316+ workflowApprovals:
317+ pullRequest.state !== "open" || pullRequest.isCrossRepository !== true
318+ ? Effect.succeed({
319+ runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
320+ unavailable: false,
321+ })
322+ : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null
323+ ? Effect.succeed({
324+ runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
325+ unavailable: true,
326+ })
327+ : cli
328+ .listWorkflowRunsRequiringApproval({
329+ ...input,
330+ headSha: pullRequest.headSha,
331+ headBranch: pullRequest.headBranch,
332+ headRepositoryOwner: pullRequest.headRepositoryOwner,
333+ isCrossRepository: true,
334+ })
335+ .pipe(
336+ Effect.matchEffect({
337+ onFailure: (error) =>
338+ error._tag === "GitHubCliRateLimitError" ||
339+ error._tag === "SourceControlRateLimitPausedError"
340+ ? Effect.fail(error)
341+ : Effect.succeed({
342+ runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
343+ unavailable: true,
344+ }),
345+ onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }),
346+ }),
347+ ),
348+ }).pipe(Effect.map((extra) => ({ pullRequest, ...extra }))),
267349 ),
268350 ),
269351 getRepositoryAccess({
@@ -281,6 +363,14 @@ export const make = Effect.gen(function* () { · 11 unmodified lines
281363 Effect.map(
282364 ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({
283365 ...detail.pullRequest,
366+ checks: withWorkflowApprovals(
367+ detail.pullRequest.checks,
368+ detail.workflowApprovals.runs,
369+ detail.workflowApprovals.unavailable,
370+ ),
371+ ...(detail.workflowApprovals.unavailable
372+ ? {}
373+ : { workflowApprovalsRequired: detail.workflowApprovals.runs.length }),
284374 reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({
285375 login,
286376 name: null,
apps/server/src/pullRequest/GitLabPullRequestCli.ts+4−0
@@ -504,6 +504,10 @@ function actionArgs( · 503 unmodified lines
504504 return ["rebase"];
505505 case "reopen":
506506 return ["reopen"];
507+ // Never reached: this host does not declare the action, so the service refuses it first.
508+ case "revert":
509+ case "approve-workflows":
510+ throw new Error(`GitLab merge request action ${action} is unsupported`);
507511 }
508512 }
509513
apps/server/src/pullRequest/PullRequestProvider.ts+4−0
@@ -177,6 +177,10 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { · 176 unmodified lines
177177 readonly behindBy?: number;
178178 /** Absent from a host that does not report whether it is armed to merge this on its own. */
179179 readonly autoMergeEnabled?: boolean;
180+ /** The strategy stored with an armed auto-merge, where the host reports it. */
181+ readonly autoMergeMethod?: PullRequestMergeMethod;
182+ /** Workflow runs on this head commit that still need a maintainer's approval. */
183+ readonly workflowApprovalsRequired?: number;
180184 }
181185
182186 /** The conversation-shaped half of a detail, loaded after the core can already render. */
apps/server/src/pullRequest/PullRequestService.ts+9−0
@@ -202,6 +202,9 @@ const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = { · 201 unmodified lines
202202 "You need write access on this repository to have it merged for you once it is ready.",
203203 "disable-auto-merge":
204204 "You need write access on this repository to stop it being merged for you once it is ready.",
205+ revert: "You need write access on this repository to open a revert pull request.",
206+ "approve-workflows":
207+ "You need write access on this repository to approve workflows from a fork pull request.",
205208 };
206209
207210 /**
@@ -1290,6 +1293,12 @@ export const make = Effect.gen(function* () { · 1082 unmodified lines
12901293 ...(changeRequest.autoMergeEnabled === undefined
12911294 ? {}
12921295 : { autoMergeEnabled: changeRequest.autoMergeEnabled }),
1296+ ...(changeRequest.autoMergeMethod === undefined
1297+ ? {}
1298+ : { autoMergeMethod: changeRequest.autoMergeMethod }),
1299+ ...(changeRequest.workflowApprovalsRequired === undefined
1300+ ? {}
1301+ : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }),
12931302 }),
12941303 ),
12951304 ),
apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts+28−0
@@ -159,6 +159,34 @@ describe("decodePullRequestJson", () => { · 158 unmodified lines
159159 );
160160 });
161161
162+ it("keeps the strategy stored with auto-complete", () => {
163+ const armed = expectSuccess(
164+ decodePullRequestJson(
165+ asJson(
166+ pullRequest({
167+ autoCompleteSetBy: { displayName: "Bilal Hassan" },
168+ completionOptions: { mergeStrategy: "squash" },
169+ }),
170+ ),
171+ ),
172+ );
173+
174+ expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
175+
176+ const unspecified = expectSuccess(
177+ decodePullRequestJson(
178+ asJson(
179+ pullRequest({
180+ autoCompleteSetBy: { displayName: "Bilal Hassan" },
181+ completionOptions: { squashMerge: false },
182+ }),
183+ ),
184+ ),
185+ );
186+ expect(unspecified?.autoMergeEnabled).toBe(true);
187+ expect(unspecified?.autoMergeMethod).toBeUndefined();
188+ });
189+
162190 it("works out where the conversation lives from what Azure returned", () => {
163191 const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));
164192
apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts+30−0
@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines
55 import type {
66 PullRequestActor,
77 PullRequestComment,
8+ PullRequestMergeMethod,
89 PullRequestMergeability,
910 PullRequestState,
1011 } from "@t3tools/contracts";
@@ -41,6 +42,14 @@ const RawPullRequestSchema = Schema.Struct({ · 30 unmodified lines
4142 * entirely once nobody has. So its presence is the answer, and there is no third state.
4243 */
4344 autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
45+ completionOptions: Schema.optional(
46+ Schema.NullOr(
47+ Schema.Struct({
48+ mergeStrategy: Schema.optional(Schema.NullOr(Schema.String)),
49+ squashMerge: Schema.optional(Schema.NullOr(Schema.Boolean)),
50+ }),
51+ ),
52+ ),
4453 mergeStatus: Schema.optional(Schema.NullOr(Schema.String)),
4554 createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)),
4655 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))),
@@ -132,6 +141,8 @@ export interface AzureDevOpsPullRequest { · 85 unmodified lines
132141 readonly threadsUrl: string | null;
133142 /** Whether Azure is set to complete this on its own once its policies pass. */
134143 readonly autoMergeEnabled: boolean;
144+ /** The completion strategy Azure stored with auto-complete, where it reported one. */
145+ readonly autoMergeMethod?: PullRequestMergeMethod;
135146 }
136147
137148 function trimmed(value: string | null | undefined): string | null {
@@ -188,6 +199,23 @@ function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): str · 50 unmodified lines
188199 return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`;
189200 }
190201
202+function toAutoMergeMethod(
203+ raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
204+): PullRequestMergeMethod | undefined {
205+ if (raw.autoCompleteSetBy == null) return undefined;
206+ switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) {
207+ case "squash":
208+ return "squash";
209+ case "rebase":
210+ case "rebasemerge":
211+ return "rebase";
212+ case "nofastforward":
213+ return "merge";
214+ default:
215+ return raw.completionOptions?.squashMerge === true ? "squash" : undefined;
216+ }
217+}
218+
191219 /**
192220 * Null when Azure said too little to place the pull request: a row with no browser url and no
193221 * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract
@@ -196,6 +224,7 @@ function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): str · 2 unmodified lines
196224 function toPullRequest(
197225 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
198226 ): AzureDevOpsPullRequest | null {
227+ const autoMergeMethod = toAutoMergeMethod(raw);
199228 const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
200229 const actor = toActor(reviewer);
201230 return actor === null ? [] : [actor];
@@ -232,6 +261,7 @@ function toPullRequest( · 30 unmodified lines
232261 reviewers,
233262 threadsUrl: toThreadsUrl(raw),
234263 autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null,
264+ ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }),
235265 };
236266 }
237267
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+75−6
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 decodeReviewThreadCommentsJson,
1717 decodeReviewThreadsJson,
1818 decodeViewerPermissionsJson,
19+ decodeWorkflowRunApprovalsJson,
1920 reviewThreadConversation,
2021 REVIEW_THREADS_GRAPHQL_QUERY,
2122 } from "./gitHubPullRequestJson.ts";
@@ -223,18 +224,56 @@ describe("pull request detail decoding", () => { · 201 unmodified lines
223224 ]);
224225 });
225226
226− it("reads an auto-merge request as armed, its null as off and its absence as neither", () => {
227+ it("keeps a workflow waiting for approval out of the passing state", () => {
228+ const raw = JSON.parse(detailJson) as Record<string, unknown>;
229+ const detail = expectSuccess(
230+ decodePullRequestDetailJson(
231+ JSON.stringify({
232+ ...raw,
233+ statusCheckRollup: [
234+ { __typename: "CheckRun", name: "build", status: "COMPLETED", conclusion: "SUCCESS" },
235+ {
236+ __typename: "CheckRun",
237+ name: "contributor tests",
238+ status: "COMPLETED",
239+ conclusion: "ACTION_REQUIRED",
240+ },
241+ ],
242+ }),
243+ ),
244+ );
245+
246+ expect(detail.checks.map((check) => check.status)).toEqual(["success", "action-required"]);
247+ expect(detail.checksState).toBe("pending");
248+ });
249+
250+ it("decodes workflow runs that can be approved", () => {
251+ expect(
252+ expectSuccess(
253+ decodeWorkflowRunApprovalsJson(
254+ JSON.stringify([
255+ { databaseId: 10, workflowName: "contributor tests", url: "https://example.com/10" },
256+ { databaseId: 11, workflowName: null, url: null },
257+ ]),
258+ ),
259+ ),
260+ ).toEqual([
261+ { id: 10, name: "contributor tests", url: "https://example.com/10" },
262+ { id: 11, name: "Workflow run 11", url: null },
263+ ]);
264+ });
265+
266+ it("reads an auto-merge request and strategy, its null as off and its absence as neither", () => {
227267 const raw = JSON.parse(detailJson) as Record<string, unknown>;
228268 const armed = (entry: Record<string, unknown>) =>
229− expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry })))
230− .autoMergeEnabled;
269+ expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry })));
231270
232271 expect(
233272 armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }),
234− ).toBe(true);
235− expect(armed({ autoMergeRequest: null })).toBe(false);
273+ ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
274+ expect(armed({ autoMergeRequest: null }).autoMergeEnabled).toBe(false);
236275 // `gh` not answering for the field at all is not GitHub saying the merge is unarmed.
237− expect(armed({})).toBeUndefined();
276+ expect(armed({}).autoMergeEnabled).toBeUndefined();
238277 });
239278
240279 it("shows a re-running check once, as the run that is happening now", () => {
@@ -419,6 +458,36 @@ describe("review thread decoding", () => { · 178 unmodified lines
419458 ]);
420459 });
421460
461+ it("omits misleading line counts from merge commits", () => {
462+ const result = expectSuccess(
463+ decodeReviewThreadsJson(
464+ JSON.stringify({
465+ data: {
466+ repository: {
467+ pullRequest: {
468+ reviewThreads: { totalCount: 0, nodes: [] },
469+ commits: {
470+ nodes: [
471+ {
472+ commit: {
473+ oid: "merge123",
474+ additions: 36_858,
475+ deletions: 12_928,
476+ parents: { totalCount: 2 },
477+ },
478+ },
479+ ],
480+ },
481+ },
482+ },
483+ },
484+ }),
485+ ),
486+ );
487+
488+ expect([...result.commitStats]).toEqual([]);
489+ });
490+
422491 it("decodes the newest commits off the same connection, oldest to newest", () => {
423492 const result = expectSuccess(
424493 decodeReviewThreadsJson(
apps/server/src/pullRequest/gitHubPullRequestJson.ts+115−11
@@ -11,6 +11,7 @@ import type { · 10 unmodified lines
1111 PullRequestCommit,
1212 PullRequestLabel,
1313 PullRequestMergeCapabilities,
14+ PullRequestMergeMethod,
1415 PullRequestOmittedFileStat,
1516 PullRequestMergeability,
1617 PullRequestReaction,
@@ -359,17 +360,32 @@ const RawCommitSchema = Schema.Struct({ · 342 unmodified lines
359360
360361 const RawDetailSchema = Schema.Struct({
361362 ...RawListItemSchema.fields,
363+ /** GitHub's explicit distinction between a fork head and a branch in the base repository. */
364+ isCrossRepository: Schema.optional(Schema.Boolean),
362365 /** Names the fork a pull request came from, which is what qualifies its head ref. */
363366 headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))),
367+ /** The exact head revision, used to find workflow runs that GitHub has not started yet. */
368+ headRefOid: Schema.optional(Schema.NullOr(Schema.String)),
364369 body: Schema.optional(Schema.String),
365370 changedFiles: Schema.optional(Schema.Int),
366371 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
367− /**
368− * The standing instruction to merge once GitHub's own requirements are met, which is an object
369− * describing who armed it and how, and a JSON null where nobody has. Nothing inside it is read:
370− * the question the page asks is whether one exists.
371− */
372− autoMergeRequest: Schema.optional(Schema.NullOr(Schema.Unknown)),
372+ /** The standing instruction and strategy GitHub will use once its requirements are met. */
373+ autoMergeRequest: Schema.optional(
374+ Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })),
375+ ),
376+});
377+
378+const RawWorkflowRunApprovalSchema = Schema.Struct({
379+ databaseId: Schema.Int,
380+ workflowName: Schema.optional(Schema.NullOr(Schema.String)),
381+ url: Schema.optional(Schema.NullOr(Schema.String)),
382+});
383+
384+const RawPullRequestHeadSchema = Schema.Struct({
385+ number: Schema.Int,
386+ headRefOid: Schema.String,
387+ isCrossRepository: Schema.optional(Schema.Boolean),
388+ headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))),
373389 });
374390
375391 const RawActivitySchema = Schema.Struct({
@@ -508,6 +524,9 @@ const RawReviewThreadsSchema = Schema.Struct({ · 132 unmodified lines
508524 committedDate: Schema.optional(Schema.NullOr(Schema.String)),
509525 additions: Schema.optional(Schema.Int),
510526 deletions: Schema.optional(Schema.Int),
527+ parents: Schema.optional(
528+ Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })),
529+ ),
511530 authors: Schema.optional(
512531 Schema.NullOr(
513532 Schema.Struct({
@@ -599,7 +618,7 @@ export function decodeActorAvatarsJson( · 85 unmodified lines
599618 export const PULL_REQUEST_LIST_JSON_FIELDS =
600619 "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup";
601620
602−export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest`;
621+export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`;
603622 export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits";
604623
605624 /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */
@@ -731,6 +750,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin · 125 unmodified lines
731750 committedDate
732751 additions
733752 deletions
753+ parents(first: 1) { totalCount }
734754 authors(first: 3) { nodes { name avatarUrl user { login } } }
735755 }
736756 }
@@ -879,6 +899,13 @@ export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID · 142 unmodified lines
879899 }
880900 }`;
881901
902+/** Creates a new pull request that reverses a merged pull request. */
903+export const REVERT_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) {
904+ revertPullRequest(input: { pullRequestId: $pullRequestId }) {
905+ revertPullRequest { id }
906+ }
907+}`;
908+
882909 /**
883910 * The two comment mutations name their comment differently. The variable is spelled the same in
884911 * both, so a rewrite sends one set of variables whichever kind of remark it is.
@@ -1016,8 +1043,11 @@ export interface GitHubPullRequestListItem { · 131 unmodified lines
10161043 }
10171044
10181045 export interface GitHubPullRequestDetail extends GitHubPullRequestListItem {
1046+ /** True only when GitHub says the head belongs to another repository. */
1047+ readonly isCrossRepository?: boolean;
10191048 /** The owner of the head branch's repository; null where `gh` did not say. */
10201049 readonly headRepositoryOwner: string | null;
1050+ readonly headSha?: string | null;
10211051 readonly body: string;
10221052 readonly changedFiles: number;
10231053 readonly mergedAt: string | null;
@@ -1025,6 +1055,21 @@ export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { · 1 unmodified line
10251055 readonly checks: ReadonlyArray<PullRequestCheck>;
10261056 /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */
10271057 readonly autoMergeEnabled?: boolean;
1058+ /** Absent where auto-merge is off or GitHub did not report the stored strategy. */
1059+ readonly autoMergeMethod?: PullRequestMergeMethod;
1060+}
1061+
1062+export interface GitHubWorkflowRunApproval {
1063+ readonly id: number;
1064+ readonly name: string;
1065+ readonly url: string | null;
1066+}
1067+
1068+export interface GitHubPullRequestHead {
1069+ readonly number: number;
1070+ readonly headSha: string;
1071+ readonly isCrossRepository?: boolean;
1072+ readonly headRepositoryOwner: string | null;
10281073 }
10291074
10301075 export interface GitHubPullRequestActivity {
@@ -1114,6 +1159,19 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili · 83 unmodified lines
11141159 }
11151160 }
11161161
1162+function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined {
1163+ switch (value?.trim().toUpperCase()) {
1164+ case "MERGE":
1165+ return "merge";
1166+ case "SQUASH":
1167+ return "squash";
1168+ case "REBASE":
1169+ return "rebase";
1170+ default:
1171+ return undefined;
1172+ }
1173+}
1174+
11171175 function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null {
11181176 switch (value?.trim().toUpperCase()) {
11191177 case "APPROVED":
@@ -1169,12 +1227,12 @@ function toCheckStatus(raw: Schema.Schema.Type<typeof RawCheckSchema>): PullRequ · 49 unmodified lines
11691227 switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) {
11701228 case "SUCCESS":
11711229 return "success";
1230+ case "ACTION_REQUIRED":
1231+ return "action-required";
11721232 case "FAILURE":
11731233 case "ERROR":
11741234 case "TIMED_OUT":
11751235 case "STARTUP_FAILURE":
1176− // A completed check asking for manual intervention is blocking, not neutral.
1177− case "ACTION_REQUIRED":
11781236 return "failure";
11791237 case "CANCELLED":
11801238 return "cancelled";
@@ -1253,7 +1311,7 @@ function rollupChecksState( · 72 unmodified lines
12531311 ];
12541312 if (statuses.length === 0) return null;
12551313 if (statuses.includes("failure")) return "failing";
1256− if (statuses.includes("pending")) return "pending";
1314+ if (statuses.includes("pending") || statuses.includes("action-required")) return "pending";
12571315 return statuses.includes("success") ? "passing" : null;
12581316 }
12591317
@@ -1361,9 +1419,14 @@ function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPu · 101 unmodified lines
13611419 }
13621420
13631421 function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRequestDetail {
1422+ const autoMergeMethod = toMergeMethod(raw.autoMergeRequest?.mergeMethod);
13641423 return {
13651424 ...toListItem(raw),
1425+ ...(typeof raw.isCrossRepository === "boolean"
1426+ ? { isCrossRepository: raw.isCrossRepository }
1427+ : {}),
13661428 headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login),
1429+ headSha: trimmed(raw.headRefOid),
13671430 body: raw.body ?? "",
13681431 changedFiles: raw.changedFiles ?? 0,
13691432 mergedAt: trimmed(raw.mergedAt),
@@ -1374,6 +1437,7 @@ function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRe · 4 unmodified lines
13741437 ...(raw.autoMergeRequest === undefined
13751438 ? {}
13761439 : { autoMergeEnabled: raw.autoMergeRequest !== null }),
1440+ ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }),
13771441 };
13781442 }
13791443
@@ -1391,6 +1455,8 @@ const decodeSearch = decodeJsonResult(RawSearchSchema); · 11 unmodified lines
13911455 const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema);
13921456 const decodeStats = decodeJsonResult(RawStatsSchema);
13931457 const decodeDetail = decodeJsonResult(RawDetailSchema);
1458+const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema));
1459+const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema));
13941460 const decodeActivity = decodeJsonResult(RawActivitySchema);
13951461 const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
13961462 const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema);
@@ -1551,6 +1617,37 @@ export function decodePullRequestDetailJson( · 154 unmodified lines
15511617 : Result.fail(decoded.failure);
15521618 }
15531619
1620+export function decodeWorkflowRunApprovalsJson(
1621+ raw: string,
1622+): Result.Result<ReadonlyArray<GitHubWorkflowRunApproval>, DecodeFailure> {
1623+ const decoded = decodeWorkflowRunApprovals(raw);
1624+ if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1625+ return Result.succeed(
1626+ decoded.success.map((run) => ({
1627+ id: run.databaseId,
1628+ name: trimmed(run.workflowName) ?? `Workflow run ${run.databaseId}`,
1629+ url: trimmed(run.url),
1630+ })),
1631+ );
1632+}
1633+
1634+export function decodePullRequestHeadsJson(
1635+ raw: string,
1636+): Result.Result<ReadonlyArray<GitHubPullRequestHead>, DecodeFailure> {
1637+ const decoded = decodePullRequestHeads(raw);
1638+ if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1639+ return Result.succeed(
1640+ decoded.success.map((pullRequest) => ({
1641+ number: pullRequest.number,
1642+ headSha: pullRequest.headRefOid,
1643+ ...(typeof pullRequest.isCrossRepository === "boolean"
1644+ ? { isCrossRepository: pullRequest.isCrossRepository }
1645+ : {}),
1646+ headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login),
1647+ })),
1648+ );
1649+}
1650+
15541651 export function decodePullRequestActivityJson(
15551652 raw: string,
15561653 ): Result.Result<GitHubPullRequestActivity, DecodeFailure> {
@@ -1788,7 +1885,14 @@ export function decodeReviewThreadsJson( · 231 unmodified lines
17881885 const commit = node.commit;
17891886 const oid = trimmed(commit.oid);
17901887 if (oid === null) continue;
1791− if (commit.additions !== undefined && commit.deletions !== undefined) {
1888+ // GitHub measures a merge commit against its first parent, so merging the base into the head
1889+ // reports every upstream change as if it belonged to the pull request. There is no useful
1890+ // per-commit stat to show for that integration commit without another comparison request.
1891+ if (
1892+ (commit.parents?.totalCount ?? 1) <= 1 &&
1893+ commit.additions !== undefined &&
1894+ commit.deletions !== undefined
1895+ ) {
17921896 commitStats.set(oid, {
17931897 additions: Math.max(0, commit.additions),
17941898 deletions: Math.max(0, commit.deletions),
apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts+22−0
@@ -192,6 +192,28 @@ describe("decodeMergeRequestDetailJson", () => { · 191 unmodified lines
192192 expect(armed({})).toBeUndefined();
193193 });
194194
195+ it("keeps the squash choice stored with auto-merge", () => {
196+ expect(
197+ expectSuccess(
198+ decodeMergeRequestDetailJson(
199+ detailJson({ auto_merge_enabled: true, squash_on_merge: true }),
200+ ),
201+ ),
202+ ).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
203+
204+ expect(
205+ expectSuccess(
206+ decodeMergeRequestDetailJson(
207+ detailJson({
208+ auto_merge_enabled: true,
209+ squash: true,
210+ squash_on_merge: false,
211+ }),
212+ ),
213+ ).autoMergeMethod,
214+ ).toBeUndefined();
215+ });
216+
195217 it("keeps a divergence GitLab did not count apart from a divergence of none", () => {
196218 const behind = (entry: Record<string, unknown>) =>
197219 expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits;
apps/server/src/pullRequest/gitLabMergeRequestJson.ts+9−0
@@ -11,6 +11,7 @@ import type { · 10 unmodified lines
1111 PullRequestLabel,
1212 PullRequestMergeability,
1313 PullRequestMergeCapabilities,
14+ PullRequestMergeMethod,
1415 PullRequestReaction,
1516 PullRequestReactionContent,
1617 PullRequestReviewThread,
@@ -80,6 +81,9 @@ const RawMergeRequestSchema = Schema.Struct({ · 63 unmodified lines
8081 */
8182 merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)),
8283 auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)),
84+ /** The merge request's stored squash choice, including project-policy overrides. */
85+ squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)),
86+ squash: Schema.optional(Schema.NullOr(Schema.Boolean)),
8387 /**
8488 * How far the target branch has moved on since this one left it, which is the same number
8589 * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab
@@ -228,6 +232,8 @@ export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { · 142 unmodified lines
228232 readonly reviewerIds: ReadonlyArray<number>;
229233 /** Absent where GitLab named neither auto-merge field, which is not the same as off. */
230234 readonly autoMergeEnabled?: boolean;
235+ /** GitLab only exposes the stored strategy separately when that strategy is squash. */
236+ readonly autoMergeMethod?: PullRequestMergeMethod;
231237 /**
232238 * Absent where GitLab did not count, which is not the same as a branch that has nothing behind
233239 * it: an install too old to answer must not be read as saying the branch is current.
@@ -379,6 +385,9 @@ function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLab · 145 unmodified lines
379385 reviewer.id === undefined ? [] : [reviewer.id],
380386 ),
381387 ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }),
388+ ...(autoMerge === true && raw.squash_on_merge === true
389+ ? { autoMergeMethod: "squash" as const }
390+ : {}),
382391 ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }),
383392 };
384393 }
apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx+2−1
@@ -65,7 +65,7 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) { · 64 unmodified lines
6565 <TooltipPopup side="top">{check.description ?? check.name}</TooltipPopup>
6666 </Tooltip>
6767 <span className="shrink-0 text-muted-foreground">
68− {pullRequestCheckStatusLabel(check.status)}
68+ {pullRequestCheckStatusLabel(check)}
6969 </span>
7070 {check.url === null ? null : (
7171 <button
@@ -112,6 +112,7 @@ export function PullRequestChecksPopover({ · 40 unmodified lines
112112 not valid inside one. The click is stopped here so opening the checks does not also
113113 select the row it sits on. */}
114114 <PopoverTrigger
115+ nativeButton={false}
115116 render={
116117 <span
117118 role="button"
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+181−43
@@ -33,7 +33,9 @@ import { · 32 unmodified lines
3333 MoreHorizontalIcon,
3434 PanelRightIcon,
3535 PencilIcon,
36+ PlayIcon,
3637 RefreshCwIcon,
38+ RotateCcwIcon,
3739 ServerIcon,
3840 TriangleAlertIcon,
3941 } from "lucide-react";
@@ -118,6 +120,7 @@ import { · 78 unmodified lines
118120 pullRequestFindingKey,
119121 pullRequestHandoffLabels,
120122 readableFailure,
123+ resolvePullRequestPrimaryControl,
121124 resolveBaseFreshness,
122125 type PullRequestFinding,
123126 shouldRefreshPullRequestActivity,
@@ -154,6 +157,8 @@ const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = { · 30 unmodified lines
154157 "enable-auto-merge":
155158 "Auto-merge turned on — merges as soon as this is ready, sooner if it already is",
156159 "disable-auto-merge": "Auto-merge turned off",
160+ revert: "Revert pull request opened",
161+ "approve-workflows": "Workflows approved",
157162 };
158163
159164 const MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = {
@@ -172,6 +177,8 @@ const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = { · 12 unmodified lines
172177 "update-branch": "Could not update this branch",
173178 "enable-auto-merge": "Could not turn on auto-merge",
174179 "disable-auto-merge": "Could not turn off auto-merge",
180+ revert: "Could not open a revert pull request",
181+ "approve-workflows": "Could not approve workflows",
175182 };
176183
177184 /** What to try, for the times the host says only that it refused. */
@@ -193,6 +200,10 @@ const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = { · 15 unmodified lines
193200 "The host refused it. Check that this repository allows auto-merge, that you have write access, and that there is something left for it to wait on.",
194201 "disable-auto-merge":
195202 "The host refused it. Check that you have write access, and that the merge has not already happened.",
203+ revert:
204+ "The host refused it. Check that you have write access and that this pull request was merged on the host.",
205+ "approve-workflows":
206+ "The host refused it. Check that you have Actions write access and that these workflow runs are still awaiting approval.",
196207 };
197208
198209 /**
@@ -518,10 +529,18 @@ export function PullRequestDetailPanel({ · 319 unmodified lines
518529 compensationRef.current = null;
519530 if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);
520531 }, [condensed]);
521− const [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>("merge");
532+ const [mergeMethodSelection, setMergeMethodSelection] = useState<{
533+ readonly pullRequestKey: string;
534+ readonly method: PullRequestMergeMethod;
535+ }>(() => ({ pullRequestKey, method: "merge" }));
536+ const mergeMethod =
537+ mergeMethodSelection.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : "merge";
538+ const setMergeMethod = (method: PullRequestMergeMethod) => {
539+ setMergeMethodSelection({ pullRequestKey, method });
540+ };
522541 const [confirmation, setConfirmation] = useState<{
523542 readonly open: boolean;
524− readonly action: "merge" | "close" | "enable-auto-merge";
543+ readonly action: "merge" | "close" | "enable-auto-merge" | "revert" | "approve-workflows";
525544 }>({ open: false, action: "merge" });
526545 const confirmAction = confirmation.action;
527546 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself
@@ -558,6 +577,9 @@ export function PullRequestDetailPanel({ · 30 unmodified lines
558577 },
559578 [activity, coreDetail],
560579 );
580+ useEffect(() => {
581+ if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod);
582+ }, [detail?.autoMergeMethod, pullRequestKey]);
561583 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);
562584 const checkoutCommand = detail
563585 ? pullRequestCheckoutCommand(
@@ -636,6 +658,7 @@ export function PullRequestDetailPanel({ · 72 unmodified lines
636658 void refreshFromHost();
637659 }, [forcedRefreshToken, refreshFromHost]);
638660 const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
661+ const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });
639662 // Which action is in flight, not merely that one is: every control here is disabled while any
640663 // of them runs, but only the button that was pressed may say what it is doing.
641664 const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null);
@@ -689,13 +712,11 @@ export function PullRequestDetailPanel({ · 47 unmodified lines
689712 cwd: acting?.workspaceRoot ?? detail?.workspaceRoot ?? null,
690713 });
691714
692− const perform = async (
715+ const finishAction = async (
693716 action: PullRequestAction,
694717 method?: PullRequestMergeMethod,
695718 updateMethod?: PullRequestUpdateMethod,
696719 ) => {
697− if (pendingAction !== null) return;
698− setPendingAction(action);
699720 const result = await runAction({
700721 environmentId,
701722 input: {
@@ -723,7 +744,7 @@ export function PullRequestDetailPanel({ · 21 unmodified lines
723744 title: ACTION_FAILURE_LABELS[action],
724745 description: readableFailure(failure, hint),
725746 });
726− return;
747+ return false;
727748 }
728749 toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] });
729750 // A branch update moves the head commit, which leaves the diff atom pointed at a comparison
@@ -737,6 +758,36 @@ export function PullRequestDetailPanel({ · 7 unmodified lines
737758 refreshDetail();
738759 }
739760 onActed?.();
761+ return true;
762+ };
763+
764+ const perform = async (
765+ action: PullRequestAction,
766+ method?: PullRequestMergeMethod,
767+ updateMethod?: PullRequestUpdateMethod,
768+ ) => {
769+ if (pendingAction !== null) return false;
770+ setPendingAction(action);
771+ return finishAction(action, method, updateMethod);
772+ };
773+
774+ const performCommentAction = async (body: string, action: "close" | "reopen") => {
775+ if (pendingAction !== null) return { commentPosted: false };
776+ setPendingAction(action);
777+ const commentResult = await postComment({
778+ environmentId,
779+ input: { ...reference, body },
780+ });
781+ if (commentResult._tag === "Failure") {
782+ setPendingAction(null);
783+ toastManager.add({ type: "error", title: "Could not post the comment" });
784+ return { commentPosted: false };
785+ }
786+ const actionSucceeded = await finishAction(action);
787+ // The comment is durable even if the state change was refused, so make it visible while the
788+ // shared action failure explains why the pull request stayed where it was.
789+ if (!actionSucceeded) refreshDetail();
790+ return { commentPosted: true };
740791 };
741792
742793 const saveTitle = async (next: string) => {
@@ -1094,10 +1145,17 @@ export function PullRequestDetailPanel({ · 351 unmodified lines
10941145 ? mergeMethod
10951146 : (allowedMergeMethods[0] ?? "merge");
10961147 const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod];
1148+ const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`;
10971149 const conflicting = detail?.state === "open" && detail.mergeability === "conflicting";
10981150 // Only an outright yes arms it. A host that reports nothing has not said the merge is already
10991151 // spoken for, and an off switch for something that may not be on says the wrong thing twice.
11001152 const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true;
1153+ const armedMergeMethod = detail?.autoMergeMethod;
1154+ const armedAutoMergeLabel = armedMergeMethod
1155+ ? `Auto-merge (${MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})`
1156+ : "Auto-merge";
1157+ const workflowApprovalsRequired =
1158+ detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0;
11011159 // Out of date with the base, and still cleanly mergeable — the one pairing an update button
11021160 // exists for. Null everywhere else, including hosts that cannot compare at all.
11031161 const freshness = detail === null ? null : resolveBaseFreshness(detail);
@@ -1118,20 +1176,22 @@ export function PullRequestDetailPanel({ · 14 unmodified lines
11181176 const can = (action: PullRequestAction) =>
11191177 detail?.capabilities.actions.includes(action) === true &&
11201178 detail.viewerPermissions.actions.includes(action);
1121− // One live action holds the slot. Conflicts take priority because every other completion action
1122− // depends on resolving them first, even for a reader who cannot merge on the host themselves.
1123− const primaryAction =
1124− detail === null || detail.state !== "open"
1125− ? null
1126− : conflicting
1127− ? "resolve"
1128− : detail.isDraft && can("ready")
1129− ? "ready"
1130− : !can("merge")
1131− ? null
1132− : allowedMergeMethods.length > 0
1133− ? "merge"
1134− : null;
1179+ const checksState = detail ? pullRequestChecksState(detail.checks) : null;
1180+ // The merge state remains in one stable slot from waiting through completion. Conflicts take
1181+ // the slot while they need a person; the armed badge remains beside them so that state is not lost.
1182+ const primaryAction = detail
1183+ ? resolvePullRequestPrimaryControl({
1184+ state: detail.state,
1185+ isDraft: detail.isDraft,
1186+ mergeability: detail.mergeability,
1187+ checksState,
1188+ autoMergeEnabled: detail.autoMergeEnabled,
1189+ hasMergeMethod: allowedMergeMethods.length > 0,
1190+ canMerge: can("merge"),
1191+ canMarkReady: can("ready"),
1192+ canEnableAutoMerge: can("enable-auto-merge"),
1193+ })
1194+ : null;
11351195 // What the menu's action group holds. Named once so the separators around it are drawn from
11361196 // the same answer as its contents, rather than on the assumption that it has any.
11371197 const showsDraftToggle =
@@ -1142,10 +1202,18 @@ export function PullRequestDetailPanel({ · 4 unmodified lines
11421202 detail?.state === "open" &&
11431203 ((autoMergeArmed && can("disable-auto-merge")) ||
11441204 (!autoMergeArmed &&
1205+ primaryAction !== "enable-auto-merge" &&
11451206 !detail.isDraft &&
11461207 !conflicting &&
11471208 can("enable-auto-merge") &&
11481209 allowedMergeMethods.length > 0));
1210+ const showsMergeNow =
1211+ detail?.state === "open" &&
1212+ (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") &&
1213+ can("merge") &&
1214+ !detail.isDraft &&
1215+ !conflicting &&
1216+ allowedMergeMethods.length > 0;
11491217 const showsMergeMethods =
11501218 detail?.state === "open" &&
11511219 can("merge") &&
@@ -1158,7 +1226,6 @@ export function PullRequestDetailPanel({ · 6 unmodified lines
11581226 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
11591227 : null;
11601228 const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null;
1161− const checksState = detail ? pullRequestChecksState(detail.checks) : null;
11621229 // Approvals that still stand, and only those. A superseded one is dimmed beside the reviewer
11631230 // who gave it, so counting it here would have the header assert in a number what the row next
11641231 // to it has just qualified.
@@ -1335,18 +1402,28 @@ export function PullRequestDetailPanel({ · 170 unmodified lines
13351402 </MenuPopup>
13361403 </Menu>
13371404 ) : null}
1405+ {workflowApprovalsRequired > 0 && can("approve-workflows") ? (
1406+ <Button
1407+ size="xs"
1408+ variant="warning-outline"
1409+ disabled={actionPending}
1410+ onClick={() => setConfirmation({ open: true, action: "approve-workflows" })}
1411+ >
1412+ <PlayIcon className="size-3.5" />
1413+ {pendingAction === "approve-workflows"
1414+ ? "Approving..."
1415+ : "Approve workflows to run"}
1416+ </Button>
1417+ ) : null}
13381418 {/* Said where the Merge button is, because it is the answer to why nobody has
13391419 pressed it: the merge is already asked for, and the host is holding it. */}
1340− {autoMergeArmed ? (
1420+ {autoMergeArmed && primaryAction !== "auto-merge-armed" ? (
13411421 <Tooltip>
13421422 <TooltipTrigger
13431423 render={
1344− <Badge
1345− variant="info"
1346− className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
1347− >
1348− <GitMergeIcon aria-hidden className="size-3" />
1349− Auto-merge
1424+ <Badge size="control" variant="info">
1425+ <GitMergeIcon aria-hidden className="size-3.5" />
1426+ {armedAutoMergeLabel}
13501427 </Badge>
13511428 }
13521429 />
@@ -1369,6 +1446,29 @@ export function PullRequestDetailPanel({ · 16 unmodified lines
13691446 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
13701447 Ready for review
13711448 </Button>
1449+ ) : primaryAction === "enable-auto-merge" ? (
1450+ <Button
1451+ size="xs"
1452+ disabled={actionPending}
1453+ onClick={() => setConfirmation({ open: true, action: "enable-auto-merge" })}
1454+ >
1455+ <GitMergeIcon className="size-3.5" />
1456+ {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel}
1457+ </Button>
1458+ ) : primaryAction === "auto-merge-armed" ? (
1459+ <Tooltip>
1460+ <TooltipTrigger
1461+ render={
1462+ <Badge size="control" variant="info">
1463+ <GitMergeIcon className="size-3.5" />
1464+ {armedAutoMergeLabel}
1465+ </Badge>
1466+ }
1467+ />
1468+ <TooltipPopup side="top">
1469+ The host will merge this on its own once its requirements are met
1470+ </TooltipPopup>
1471+ </Tooltip>
13721472 ) : primaryAction === "merge" ? (
13731473 <Button
13741474 size="xs"
@@ -1377,6 +1477,12 @@ export function PullRequestDetailPanel({ · 2 unmodified lines
13771477 >
13781478 {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}
13791479 </Button>
1480+ ) : (primaryAction === "merged" || primaryAction === "closed") &&
1481+ statePresentation !== null ? (
1482+ <Badge size="control" variant="outline" className={statePresentation.toneClassName}>
1483+ <statePresentation.Icon className="size-3.5" />
1484+ {statePresentation.label}
1485+ </Badge>
13801486 ) : null}
13811487 <Menu>
13821488 <MenuTrigger
@@ -1447,11 +1553,18 @@ export function PullRequestDetailPanel({ · 64 unmodified lines
14471553 {detail.isDraft ? "Ready for review" : "Convert to draft"}
14481554 </MenuItem>
14491555 ) : null}
1450− {/* The same merge, left with the host to carry out once the things it
1451− waits on are done. It is offered beside the merge rather than instead
1452− of it, because the reader who can wait and the reader who cannot are
1453− the same person on different days — and a conflicting branch is neither,
1454− since nothing the host waits for will clear it. */}
1556+ {showsMergeNow ? (
1557+ <MenuItem
1558+ disabled={actionPending}
1559+ onClick={() => setConfirmation({ open: true, action: "merge" })}
1560+ >
1561+ <GitMergeIcon className="size-3.5" />
1562+ Merge now
1563+ </MenuItem>
1564+ ) : null}
1565+ {/* The same merge, left with the host to carry out once its requirements
1566+ pass. A conflicting branch cannot be armed because nothing the host
1567+ waits for will clear the conflict. */}
14551568 {autoMergeArmed && can("disable-auto-merge") ? (
14561569 <MenuItem
14571570 disabled={actionPending}
@@ -1460,11 +1573,7 @@ export function PullRequestDetailPanel({ · 2 unmodified lines
14601573 <GitMergeIcon className="size-3.5" />
14611574 Disable auto-merge
14621575 </MenuItem>
1463− ) : !autoMergeArmed &&
1464− !detail.isDraft &&
1465− !conflicting &&
1466− can("enable-auto-merge") &&
1467− allowedMergeMethods.length > 0 ? (
1576+ ) : showsAutoMerge ? (
14681577 <MenuItem
14691578 disabled={actionPending}
14701579 onClick={() =>
@@ -1485,7 +1594,9 @@ export function PullRequestDetailPanel({ · 14 unmodified lines
14851594 {/* Only below the draft control. A host with no draft of its own, or
14861595 a draft whose control is already the header button, would leave
14871596 this against the separator that opened the group. */}
1488− {showsDraftToggle ? <MenuSeparator /> : null}
1597+ {showsDraftToggle || showsMergeNow || showsAutoMerge ? (
1598+ <MenuSeparator />
1599+ ) : null}
14891600 <MenuRadioGroup
14901601 value={selectedMergeMethod}
14911602 onValueChange={(method) =>
@@ -1507,7 +1618,7 @@ export function PullRequestDetailPanel({ · 15 unmodified lines
15071618 ) : null}
15081619 {pullRequestActionMenuHasGroup(
15091620 showsDraftToggle,
1510− showsAutoMerge,
1621+ showsAutoMerge || showsMergeNow,
15111622 showsMergeMethods,
15121623 ) ? (
15131624 <MenuSeparator />
@@ -1542,6 +1653,17 @@ export function PullRequestDetailPanel({ · 28 unmodified lines
15421653 Reopen pull request
15431654 </MenuItem>
15441655 </>
1656+ ) : detail.state === "merged" && can("revert") ? (
1657+ <>
1658+ <MenuSeparator />
1659+ <MenuItem
1660+ disabled={actionPending}
1661+ onClick={() => setConfirmation({ open: true, action: "revert" })}
1662+ >
1663+ <RotateCcwIcon className="size-3.5" />
1664+ Revert changes
1665+ </MenuItem>
1666+ </>
15451667 ) : null}
15461668 </MenuPopup>
15471669 </Menu>
@@ -1987,6 +2109,8 @@ export function PullRequestDetailPanel({ · 439 unmodified lines
19872109 fixFindingLabel={handoffLabels.fixFinding}
19882110 fixCheckLabel={handoffLabels.fixCheck}
19892111 onFixFinding={startFixFinding}
2112+ actionPending={actionPending}
2113+ onCommentAction={performCommentAction}
19902114 onRefresh={refreshDetail}
19912115 />
19922116 </div>
@@ -2049,7 +2173,11 @@ export function PullRequestDetailPanel({ · 56 unmodified lines
20492173 ? "Merge pull request?"
20502174 : confirmAction === "enable-auto-merge"
20512175 ? "Enable auto-merge?"
2052− : "Close pull request?"}
2176+ : confirmAction === "revert"
2177+ ? "Revert these changes?"
2178+ : confirmAction === "approve-workflows"
2179+ ? "Approve workflows to run?"
2180+ : "Close pull request?"}
20532181 </AlertDialogTitle>
20542182 <AlertDialogDescription>
20552183 {confirmAction === "merge"
@@ -2059,7 +2187,11 @@ export function PullRequestDetailPanel({ · 3 unmodified lines
20592187 // may be immediately — there is no telling from here whether anything is
20602188 // still outstanding.
20612189 `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.`
2062− : `This closes #${reference.number} without merging it.`}
2190+ : confirmAction === "revert"
2191+ ? `This opens a new pull request that reverses the changes merged by #${reference.number}.`
2192+ : confirmAction === "approve-workflows"
2193+ ? `This allows ${workflowApprovalsRequired} ${workflowApprovalsRequired === 1 ? "workflow" : "workflows"} from #${reference.number} to run. Review the code and workflow changes first.`
2194+ : `This closes #${reference.number} without merging it.`}
20632195 </AlertDialogDescription>
20642196 </AlertDialogHeader>
20652197 <AlertDialogFooter>
@@ -2076,6 +2208,8 @@ export function PullRequestDetailPanel({ · 10 unmodified lines
20762208 if (action === "merge") void perform("merge", selectedMergeMethod);
20772209 if (action === "enable-auto-merge")
20782210 void perform("enable-auto-merge", selectedMergeMethod);
2211+ if (action === "revert") void perform("revert");
2212+ if (action === "approve-workflows") void perform("approve-workflows");
20792213 if (action === "close") void perform("close");
20802214 }}
20812215 >
@@ -2083,7 +2217,11 @@ export function PullRequestDetailPanel({ · 1 unmodified line
20832217 ? selectedMergeMethodLabel
20842218 : confirmAction === "enable-auto-merge"
20852219 ? "Enable auto-merge"
2086− : "Close"}
2220+ : confirmAction === "revert"
2221+ ? "Create revert PR"
2222+ : confirmAction === "approve-workflows"
2223+ ? "Approve and run"
2224+ : "Close"}
20872225 </Button>
20882226 </AlertDialogFooter>
20892227 </AlertDialogPopup>
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+1−1
@@ -524,7 +524,7 @@ export function PullRequestFiltersMenu({ · 523 unmodified lines
524524 <ListFilterIcon className="size-4" />
525525 <span>Filters</span>
526526 {filterCount > 0 ? (
527− <span className="rounded-full bg-primary/10 px-1.5 text-xs text-primary tabular-nums">
527+ <span className="rounded-full bg-muted px-1.5 text-xs text-muted-foreground tabular-nums">
528528 {filterCount}
529529 </span>
530530 ) : null}
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+67−11
@@ -9,9 +9,11 @@ import { · 8 unmodified lines
99 ArrowDownUpIcon,
1010 ChevronDownIcon,
1111 ChevronRightIcon,
12+ GitPullRequestClosedIcon,
1213 HammerIcon,
1314 MessageSquareIcon,
1415 PencilIcon,
16+ RotateCcwIcon,
1517 SendIcon,
1618 TagIcon,
1719 UsersIcon,
@@ -308,20 +310,43 @@ function Section({ · 290 unmodified lines
308310 function CommentComposer({
309311 environmentId,
310312 detail,
313+ actionPending,
314+ onCommentAction,
311315 onCommented,
312316 }: {
313317 environmentId: EnvironmentId;
314318 detail: PullRequestDetailView;
319+ actionPending: boolean;
320+ onCommentAction: (
321+ body: string,
322+ action: "close" | "reopen",
323+ ) => Promise<{ readonly commentPosted: boolean }>;
315324 onCommented: () => void;
316325 }) {
317326 const [body, setBody] = useState("");
318− const [posting, setPosting] = useState(false);
327+ const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null);
319328 const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false });
329+ const followUpAction =
330+ detail.state === "open" &&
331+ detail.capabilities.actions.includes("close") &&
332+ detail.viewerPermissions.actions.includes("close")
333+ ? ("close" as const)
334+ : detail.state === "closed" &&
335+ detail.capabilities.actions.includes("reopen") &&
336+ detail.viewerPermissions.actions.includes("reopen")
337+ ? ("reopen" as const)
338+ : null;
320339
321− const submit = async () => {
340+ const submit = async (action: "comment" | "close" | "reopen") => {
322341 const trimmed = body.trim();
323− if (trimmed.length === 0 || posting) return;
324− setPosting(true);
342+ if (trimmed.length === 0 || submitting !== null || actionPending) return;
343+ setSubmitting(action);
344+ if (action !== "comment") {
345+ const result = await onCommentAction(trimmed, action);
346+ if (result.commentPosted) setBody("");
347+ setSubmitting(null);
348+ return;
349+ }
325350 const result = await postComment({
326351 environmentId,
327352 input: {
@@ -331,12 +356,13 @@ function CommentComposer({ · 3 unmodified lines
331356 body: trimmed,
332357 },
333358 });
334− setPosting(false);
335359 if (result._tag === "Failure") {
360+ setSubmitting(null);
336361 toastManager.add({ type: "error", title: "Could not post the comment" });
337362 return;
338363 }
339364 setBody("");
365+ setSubmitting(null);
340366 onCommented();
341367 };
342368
@@ -345,22 +371,43 @@ function CommentComposer({ · 2 unmodified lines
345371 <Textarea
346372 // Locked while posting: the body is cleared on success, which would otherwise throw
347373 // away a new draft typed while the request was still in flight.
348− disabled={posting}
374+ disabled={submitting !== null || actionPending}
349375 value={body}
350376 rows={3}
351377 placeholder="Leave a comment"
352378 aria-label="Comment on this pull request"
353379 onChange={(event) => setBody(event.target.value)}
354380 />
355− <div className="flex justify-end">
381+ <div className="flex justify-end gap-2">
382+ {followUpAction === null ? null : (
383+ <Button
384+ size="xs"
385+ variant={followUpAction === "close" ? "destructive-outline" : "outline"}
386+ disabled={body.trim().length === 0 || submitting !== null || actionPending}
387+ onClick={() => void submit(followUpAction)}
388+ >
389+ {followUpAction === "close" ? (
390+ <GitPullRequestClosedIcon className="size-3.5" />
391+ ) : (
392+ <RotateCcwIcon className="size-3.5" />
393+ )}
394+ {submitting === followUpAction
395+ ? followUpAction === "close"
396+ ? "Closing..."
397+ : "Reopening..."
398+ : followUpAction === "close"
399+ ? "Close with comment"
400+ : "Reopen with comment"}
401+ </Button>
402+ )}
356403 <Button
357404 size="xs"
358405 variant="outline"
359− disabled={body.trim().length === 0 || posting}
360− onClick={() => void submit()}
406+ disabled={body.trim().length === 0 || submitting !== null || actionPending}
407+ onClick={() => void submit("comment")}
361408 >
362409 <SendIcon className="size-3.5" />
363− {posting ? "Posting..." : "Comment"}
410+ {submitting === "comment" ? "Posting..." : "Comment"}
364411 </Button>
365412 </div>
366413 </div>
@@ -383,6 +430,8 @@ export function PullRequestSummaryTab({ · 16 unmodified lines
383430 fixFindingLabel = "Fix in a thread",
384431 fixCheckLabel = "Fix",
385432 onFixFinding,
433+ actionPending,
434+ onCommentAction,
386435 onRefresh,
387436 }: {
388437 environmentId: EnvironmentId;
@@ -395,6 +444,11 @@ export function PullRequestSummaryTab({ · 6 unmodified lines
395444 fixFindingLabel?: string;
396445 fixCheckLabel?: string;
397446 onFixFinding?: (finding: PullRequestFinding) => void;
447+ actionPending: boolean;
448+ onCommentAction: (
449+ body: string,
450+ action: "close" | "reopen",
451+ ) => Promise<{ readonly commentPosted: boolean }>;
398452 onRefresh: () => void;
399453 }) {
400454 // Keyed by the pull request, so opening another one starts at the end of its conversation
@@ -718,7 +772,7 @@ export function PullRequestSummaryTab({ · 317 unmodified lines
718772 <PullRequestCheckStatusIcon status={check.status} />
719773 <span className="min-w-0 flex-1 truncate">{check.name}</span>
720774 <span className="shrink-0 text-muted-foreground">
721− {pullRequestCheckStatusLabel(check.status)}
775+ {pullRequestCheckStatusLabel(check)}
722776 </span>
723777 </button>
724778 {/* Only where there is something to fix. A passing check has no failure to
@@ -919,6 +973,8 @@ export function PullRequestSummaryTab({ · 194 unmodified lines
919973 key={`${environmentId}:${detail.projectId}/${detail.repository}#${detail.number}`}
920974 environmentId={environmentId}
921975 detail={detail}
976+ actionPending={actionPending}
977+ onCommentAction={onCommentAction}
922978 onCommented={onRefresh}
923979 />
924980 ) : null}
apps/web/src/components/pullRequest/pullRequestChecks.test.tsx+30−3
@@ -5,10 +5,17 @@ import { describe, expect, it } from "vite-plus/test"; · 4 unmodified lines
55 import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
66 import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
77 import { PullRequestRow } from "./PullRequestRow";
8−import { pullRequestChecksState } from "./pullRequestPresentation";
8+import {
9+ pullRequestChecksState,
10+ pullRequestCheckStatusLabel,
11+ summarizePullRequestChecks,
12+} from "./pullRequestPresentation";
913
10−function check(status: PullRequestCheck["status"]): PullRequestCheck {
11− return { name: `check-${status}`, status, description: null, url: null };
14+function check(
15+ status: PullRequestCheck["status"],
16+ overrides: Partial<PullRequestCheck> = {},
17+): PullRequestCheck {
18+ return { name: `check-${status}`, status, description: null, url: null, ...overrides };
1219 }
1320
1421 describe("pullRequestChecksState", () => {
@@ -18,11 +25,31 @@ describe("pullRequestChecksState", () => { · 3 unmodified lines
1825 );
1926 expect(pullRequestChecksState([check("success"), check("cancelled")])).toBe("failing");
2027 expect(pullRequestChecksState([check("success"), check("pending")])).toBe("pending");
28+ expect(pullRequestChecksState([check("success"), check("action-required")])).toBe("pending");
2129 expect(pullRequestChecksState([check("success")])).toBe("passing");
2230 // Skipped and neutral are neither a pass nor a failure, so they are no verdict at all.
2331 expect(pullRequestChecksState([check("skipped"), check("neutral")])).toBe(null);
2432 expect(pullRequestChecksState([])).toBe(null);
2533 });
34+
35+ it("names workflow approval instead of claiming every check passed", () => {
36+ const workflow = check("action-required", {
37+ url: "https://github.com/acme/web/actions/runs/42/job/7",
38+ });
39+ const manualGate = check("action-required", { url: "https://example.com/manual-gate" });
40+ expect(pullRequestCheckStatusLabel(workflow)).toBe("Awaiting approval");
41+ expect(pullRequestCheckStatusLabel(manualGate)).toBe("Awaiting action");
42+ expect(summarizePullRequestChecks([check("success"), workflow])).toBe(
43+ "1 workflow awaiting approval",
44+ );
45+ expect(summarizePullRequestChecks([check("failure"), workflow])).toBe("1 of 2 failing");
46+ expect(summarizePullRequestChecks([check("success"), manualGate])).toBe(
47+ "1 check awaiting action",
48+ );
49+ expect(summarizePullRequestChecks([workflow, manualGate])).toBe(
50+ "1 workflow and 1 check awaiting action",
51+ );
52+ });
2653 });
2754
2855 /** Every element of the tree the row returned, so a nested indicator can be looked for. */
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+53−1
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
3131 pullRequestHandoffLabels,
3232 pullRequestReviewOutcome,
3333 readableFailure,
34+ resolvePullRequestPrimaryControl,
3435 shouldRefreshPullRequestActivity,
3536 resolveBaseFreshness,
3637 buildPullRequestTimeline,
@@ -146,6 +147,54 @@ describe("pull request action menu", () => { · 109 unmodified lines
146147 });
147148 });
148149
150+describe("pull request primary control", () => {
151+ const open = {
152+ state: "open" as const,
153+ isDraft: false,
154+ mergeability: "mergeable" as const,
155+ checksState: "passing" as const,
156+ autoMergeEnabled: false,
157+ hasMergeMethod: true,
158+ canMerge: true,
159+ canMarkReady: true,
160+ canEnableAutoMerge: true,
161+ };
162+
163+ it("moves pending and failing checks to auto-merge", () => {
164+ expect(resolvePullRequestPrimaryControl({ ...open, checksState: "pending" })).toBe(
165+ "enable-auto-merge",
166+ );
167+ expect(resolvePullRequestPrimaryControl({ ...open, checksState: "failing" })).toBe(
168+ "enable-auto-merge",
169+ );
170+ });
171+
172+ it("does not offer auto-merge while the host state is unknown", () => {
173+ expect(
174+ resolvePullRequestPrimaryControl({
175+ ...open,
176+ checksState: "pending",
177+ autoMergeEnabled: undefined,
178+ }),
179+ ).toBe("merge");
180+ });
181+
182+ it("keeps armed and terminal states in the merge button slot", () => {
183+ expect(resolvePullRequestPrimaryControl({ ...open, autoMergeEnabled: true })).toBe(
184+ "auto-merge-armed",
185+ );
186+ expect(resolvePullRequestPrimaryControl({ ...open, state: "merged" })).toBe("merged");
187+ expect(resolvePullRequestPrimaryControl({ ...open, state: "closed" })).toBe("closed");
188+ });
189+
190+ it("keeps conflicts and drafts actionable before merge", () => {
191+ expect(resolvePullRequestPrimaryControl({ ...open, mergeability: "conflicting" })).toBe(
192+ "resolve",
193+ );
194+ expect(resolvePullRequestPrimaryControl({ ...open, isDraft: true })).toBe("ready");
195+ });
196+});
197+
149198 describe("pull request state description", () => {
150199 it("keeps draft and conflicts orthogonal to the terminal states", () => {
151200 expect(describePullRequestState("open", true)).toBe("Draft");
@@ -1243,7 +1292,9 @@ describe("which actions need the host read again after they run", () => { · 1091 unmodified lines
12431292 // Imported from the contract rather than hand-listed, so a new PullRequestAction fails this
12441293 // test until somebody decides which side of the diff it belongs on.
12451294 expect(PullRequestAction.literals.map(pullRequestActionNeedsHostRefresh)).toEqual(
1246− PullRequestAction.literals.map((action) => action === "update-branch"),
1295+ PullRequestAction.literals.map(
1296+ (action) => action === "update-branch" || action === "approve-workflows",
1297+ ),
12471298 );
12481299 });
12491300
@@ -1260,6 +1311,7 @@ describe("which actions need the host read again after they run", () => { · 10 unmodified lines
12601311 "enable-auto-merge",
12611312 "disable-auto-merge",
12621313 "merge",
1314+ "revert",
12631315 ] as const) {
12641316 expect(pullRequestActionNeedsHostRefresh(action)).toBe(false);
12651317 }
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+45−5
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
33 PullRequestActor,
44 PullRequestBaseComparison,
55 PullRequestCheck,
6+ PullRequestChecksState,
67 PullRequestComment,
78 PullRequestCommit,
89 PullRequestDetailView,
@@ -20,6 +21,45 @@ import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/re · 11 unmodified lines
2021 const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
2122 const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
2223
24+export type PullRequestPrimaryControl =
25+ | "resolve"
26+ | "ready"
27+ | "merge"
28+ | "enable-auto-merge"
29+ | "auto-merge-armed"
30+ | "merged"
31+ | "closed"
32+ | null;
33+
34+/** The one merge-area state shown in the header, including terminal and deferred states. */
35+export function resolvePullRequestPrimaryControl(input: {
36+ readonly state: PullRequestState;
37+ readonly isDraft: boolean;
38+ readonly mergeability: PullRequestMergeability;
39+ readonly checksState: PullRequestChecksState | null;
40+ readonly autoMergeEnabled: boolean | undefined;
41+ readonly hasMergeMethod: boolean;
42+ readonly canMerge: boolean;
43+ readonly canMarkReady: boolean;
44+ readonly canEnableAutoMerge: boolean;
45+}): PullRequestPrimaryControl {
46+ if (input.state === "merged") return "merged";
47+ if (input.state === "closed") return "closed";
48+ if (input.mergeability === "conflicting") return "resolve";
49+ if (input.isDraft) return input.canMarkReady ? "ready" : null;
50+ if (input.autoMergeEnabled) return "auto-merge-armed";
51+ if (!input.hasMergeMethod) return null;
52+ if (
53+ input.autoMergeEnabled === false &&
54+ input.checksState !== null &&
55+ input.checksState !== "passing" &&
56+ input.canEnableAutoMerge
57+ ) {
58+ return "enable-auto-merge";
59+ }
60+ return input.canMerge ? "merge" : null;
61+}
62+
2363 export function pullRequestCheckoutCommand(
2464 provider: SourceControlProviderKind,
2565 number: number,
@@ -949,11 +989,9 @@ export function resolveBaseFreshness(detail: { · 923 unmodified lines
949989 }
950990
951991 /**
952− * Whether a completed action leaves the diff atom pointed at a comparison that no longer exists,
953− * the same staleness the manual refresh button fixes. Only `update-branch` moves the head commit;
954− * a merge moves the branch too, but it also closes the pull request, where the diff is no longer
955− * what anyone is looking at. Written as a `Record` so a new `PullRequestAction` fails to compile
956− * here until somebody decides which side of the diff it belongs on.
992+ * Whether a completed action needs the uncached host read rather than the cheaper detail refresh.
993+ * Updating a branch moves the diff's head. Approving workflows changes data GitHub omits from the
994+ * normal pull-request detail. Written as a `Record` so every new action makes that choice here.
957995 */
958996 const ACTION_NEEDS_HOST_REFRESH: Record<PullRequestAction, boolean> = {
959997 "update-branch": true,
@@ -964,6 +1002,8 @@ const ACTION_NEEDS_HOST_REFRESH: Record<PullRequestAction, boolean> = { · 4 unmodified lines
9641002 reopen: false,
9651003 "enable-auto-merge": false,
9661004 "disable-auto-merge": false,
1005+ revert: false,
1006+ "approve-workflows": true,
9671007 };
9681008
9691009 export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): boolean {
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+167−0
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
2121 readPullRequestListSnapshot,
2222 writePullRequestListSnapshot,
2323 rankPullRequestMatches,
24+ rankPullRequestsByMergeReadiness,
2425 scorePullRequestMatch,
2526 retainVisiblePullRequestStatsBatches,
2627 withDiffStat,
@@ -29,6 +30,11 @@ import { · 2 unmodified lines
2930 resolveSelectedEnvironmentId,
3031 type EnvironmentPullRequestEntry,
3132 } from "./pullRequestList.logic";
33+import {
34+ pullRequestListPreferences,
35+ readPullRequestListPreferences,
36+ writePullRequestListPreferences,
37+} from "./pullRequestListPreferences";
3238
3339 const VIEWERS = { "github.com": "Bilal" } as const;
3440 const NO_VIEWERS = {} as const;
@@ -656,6 +662,99 @@ describe("ranking what a search found", () => { · 621 unmodified lines
656662 });
657663 });
658664
665+describe("default merge-readiness ranking", () => {
666+ it("puts ready work first, finished work after open work, and every conflict last", () => {
667+ const conflict = entry({
668+ number: 1,
669+ mergeability: "conflicting",
670+ checksState: "passing",
671+ reviewDecision: "approved",
672+ updatedAt: "2026-09-01T00:00:00Z",
673+ });
674+ const other = entry({
675+ number: 2,
676+ checksState: "pending",
677+ updatedAt: "2026-08-01T00:00:00Z",
678+ });
679+ const green = entry({
680+ number: 3,
681+ checksState: "passing",
682+ reviewDecision: "review-required",
683+ updatedAt: "2026-07-01T00:00:00Z",
684+ });
685+ const approved = entry({
686+ number: 4,
687+ checksState: "passing",
688+ reviewDecision: "approved",
689+ updatedAt: "2026-06-01T00:00:00Z",
690+ });
691+ const draft = entry({
692+ number: 5,
693+ isDraft: true,
694+ checksState: "passing",
695+ reviewDecision: "approved",
696+ updatedAt: "2026-09-02T00:00:00Z",
697+ });
698+ const finished = entry({
699+ number: 6,
700+ state: "merged",
701+ checksState: "passing",
702+ reviewDecision: "approved",
703+ updatedAt: "2026-09-03T00:00:00Z",
704+ });
705+
706+ expect(
707+ rankPullRequestsByMergeReadiness([conflict, other, green, approved, draft, finished]).map(
708+ (row) => row.number,
709+ ),
710+ ).toEqual([4, 3, 5, 2, 6, 1]);
711+ });
712+
713+ it("uses recency inside one readiness tier", () => {
714+ const older = entry({ number: 1, checksState: "passing" });
715+ const newer = entry({
716+ number: 2,
717+ checksState: "passing",
718+ updatedAt: "2026-08-01T00:00:00Z",
719+ });
720+
721+ expect(rankPullRequestsByMergeReadiness([older, newer]).map((row) => row.number)).toEqual([
722+ 2, 1,
723+ ]);
724+ });
725+
726+ it("puts the smallest measured change first inside a readiness tier", () => {
727+ const larger = entry({
728+ number: 1,
729+ checksState: "passing",
730+ reviewDecision: "approved",
731+ additions: 40,
732+ deletions: 10,
733+ updatedAt: "2026-09-01T00:00:00Z",
734+ });
735+ const smaller = entry({
736+ number: 2,
737+ checksState: "passing",
738+ reviewDecision: "approved",
739+ additions: 3,
740+ deletions: 2,
741+ updatedAt: "2026-08-01T00:00:00Z",
742+ });
743+ const unknown = entry({
744+ number: 3,
745+ checksState: "passing",
746+ reviewDecision: "approved",
747+ additions: 0,
748+ deletions: 0,
749+ updatedAt: "2026-09-02T00:00:00Z",
750+ });
751+
752+ expect(
753+ rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number),
754+ ).toEqual([2, 1, 3]);
755+ });
756+});
757+
659758 describe("line counts that arrive after the rows", () => {
660759 const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]);
661760
@@ -858,6 +957,74 @@ describe("the list snapshot across a reload", () => { · 196 unmodified lines
858957 });
859958 });
860959
960+describe("remembered pull request list controls", () => {
961+ const makeStorage = () => {
962+ const held = new Map<string, string>();
963+ return {
964+ getItem: (key: string) => held.get(key) ?? null,
965+ setItem: (key: string, value: string) => void held.set(key, value),
966+ };
967+ };
968+
969+ it("restores every filter and the selected sort", () => {
970+ const storage = makeStorage();
971+ const preferences = {
972+ involvement: "reviewing",
973+ state: "merged",
974+ environmentId: "env-1" as EnvironmentId,
975+ projectId: "project-1" as ProjectId,
976+ host: "github.com",
977+ q: "workflow",
978+ draft: "hide",
979+ review: "approved",
980+ checks: "passing",
981+ author: "octocat",
982+ labels: ["bug", "priority"],
983+ sort: "largest",
984+ } as const;
985+
986+ writePullRequestListPreferences(preferences, storage);
987+ expect(readPullRequestListPreferences(storage)).toEqual(preferences);
988+ });
989+
990+ it("omits the default sort from storage", () => {
991+ expect(
992+ pullRequestListPreferences({
993+ involvement: "all",
994+ state: "open",
995+ sort: "ready",
996+ }),
997+ ).toEqual({ involvement: "all", state: "open" });
998+ });
999+
1000+ it("keeps an explicit recency sort", () => {
1001+ expect(
1002+ pullRequestListPreferences({ involvement: "all", state: "open", sort: "updated" }),
1003+ ).toEqual({ involvement: "all", state: "open", sort: "updated" });
1004+ });
1005+
1006+ it("falls back to the default controls when storage is corrupt", () => {
1007+ const storage = makeStorage();
1008+ storage.setItem("t3.pullRequests.preferences", "{not json");
1009+ expect(readPullRequestListPreferences(storage)).toEqual({ involvement: "all", state: "open" });
1010+ });
1011+
1012+ it("falls back when browser policy denies storage", () => {
1013+ const denied = {
1014+ getItem: () => {
1015+ throw new Error("storage denied");
1016+ },
1017+ setItem: () => {
1018+ throw new Error("storage denied");
1019+ },
1020+ };
1021+ expect(readPullRequestListPreferences(denied)).toEqual({ involvement: "all", state: "open" });
1022+ expect(() =>
1023+ writePullRequestListPreferences({ involvement: "all", state: "open" }, denied),
1024+ ).not.toThrow();
1025+ });
1026+});
1027+
8611028 const ENV_1 = "env-1" as EnvironmentId;
8621029 const ENV_2 = "env-2" as EnvironmentId;
8631030
apps/web/src/components/pullRequest/pullRequestList.logic.ts+30−1
@@ -985,7 +985,7 @@ export function scorePullRequestMatch(entry: PullRequestListEntry, query: string · 984 unmodified lines
985985
986986 /**
987987 * Search results in the order they answer the question, most convincing first, and by recency
988− * among equals. Only for a search: without one, a listing is a timeline and recency is the order.
988+ * among equals. Without a search, preserve the host order for the caller's browse-time ranking.
989989 */
990990 export function rankPullRequestMatches<Entry extends PullRequestListEntry>(
991991 entries: ReadonlyArray<Entry>,
@@ -998,6 +998,35 @@ export function rankPullRequestMatches<Entry extends PullRequestListEntry>( · 6 unmodified lines
998998 });
999999 }
10001000
1001+/**
1002+ * The default review queue: work that is green and approved, then green work still waiting on a
1003+ * verdict, then everything else still open. Drafts stay in that third tier because their author
1004+ * has not made them mergeable yet. Finished work follows open work when all states are visible. A
1005+ * known conflict is never ready, whatever its checks, review or state say, so it stays at the
1006+ * bottom. Smaller measured changes come first within a tier; recency only breaks a remaining tie.
1007+ */
1008+export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEntry>(
1009+ entries: ReadonlyArray<Entry>,
1010+ hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0,
1011+): ReadonlyArray<Entry> {
1012+ const tier = (entry: Entry) => {
1013+ if (entry.mergeability === "conflicting") return 4;
1014+ if (entry.state !== "open") return 3;
1015+ if (entry.isDraft) return 2;
1016+ if (entry.checksState === "passing" && entry.reviewDecision === "approved") return 0;
1017+ if (entry.checksState === "passing") return 1;
1018+ return 2;
1019+ };
1020+ return entries.toSorted((left, right) => {
1021+ const byTier = tier(left) - tier(right);
1022+ if (byTier !== 0) return byTier;
1023+ const byMeasurement = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left));
1024+ if (byMeasurement !== 0) return byMeasurement;
1025+ const bySize = left.additions + left.deletions - (right.additions + right.deletions);
1026+ return bySize !== 0 ? bySize : right.updatedAt.localeCompare(left.updatedAt);
1027+ });
1028+}
1029+
10011030 /**
10021031 * A row with the line counts that arrived after it did. Only where the host left them out — a
10031032 * listing that carried them is not second-guessed — and only where they have arrived, since a row
apps/web/src/components/pullRequest/pullRequestListPreferences.ts+122−0
@@ -0,0 +1,122 @@
1+import * as Schema from "effect/Schema";
2+
3+import {
4+ EnvironmentId,
5+ ProjectId,
6+ PullRequestInvolvement,
7+ PullRequestListFilters,
8+ PullRequestListState,
9+} from "@t3tools/contracts";
10+
11+export const PullRequestListSort = Schema.Literals([
12+ "ready",
13+ "updated",
14+ "newest",
15+ "oldest",
16+ "largest",
17+ "smallest",
18+]);
19+export type PullRequestListSort = typeof PullRequestListSort.Type;
20+
21+export interface PullRequestListPreferences {
22+ readonly involvement: PullRequestInvolvement;
23+ readonly state: PullRequestListState;
24+ readonly environmentId?: EnvironmentId;
25+ readonly projectId?: ProjectId;
26+ readonly host?: string;
27+ readonly q?: string;
28+ readonly draft?: "only" | "hide";
29+ readonly review?: NonNullable<PullRequestListFilters["review"]>;
30+ readonly checks?: NonNullable<PullRequestListFilters["checks"]>;
31+ readonly author?: string;
32+ readonly labels?: ReadonlyArray<string>;
33+ readonly sort?: PullRequestListSort;
34+}
35+
36+export type PullRequestListPreferencePatch = {
37+ [Key in keyof PullRequestListPreferences]?: PullRequestListPreferences[Key] | undefined;
38+};
39+
40+export const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = {
41+ involvement: "all",
42+ state: "open",
43+} as const satisfies PullRequestListPreferences;
44+
45+const BoundedPreference = Schema.String.check(Schema.isMaxLength(200));
46+const PullRequestListPreferencesSchema = Schema.Struct({
47+ involvement: PullRequestInvolvement,
48+ state: PullRequestListState,
49+ environmentId: Schema.optional(EnvironmentId),
50+ projectId: Schema.optional(ProjectId),
51+ host: Schema.optional(BoundedPreference),
52+ q: Schema.optional(BoundedPreference),
53+ draft: PullRequestListFilters.fields.draft,
54+ review: PullRequestListFilters.fields.review,
55+ checks: PullRequestListFilters.fields.checks,
56+ author: Schema.optional(BoundedPreference),
57+ labels: Schema.optional(Schema.Array(BoundedPreference).check(Schema.isMaxLength(10))),
58+ sort: Schema.optional(PullRequestListSort),
59+});
60+
61+const decodePullRequestListPreferences = Schema.decodeUnknownOption(
62+ PullRequestListPreferencesSchema,
63+);
64+const PULL_REQUEST_LIST_PREFERENCES_STORAGE_KEY = "t3.pullRequests.preferences";
65+type PreferenceStorage = Pick<Storage, "getItem" | "setItem">;
66+
67+function resolvePreferenceStorage(
68+ storage: PreferenceStorage | undefined,
69+): PreferenceStorage | undefined {
70+ return storage ?? (typeof window === "undefined" ? undefined : window.localStorage);
71+}
72+
73+/** Only list controls are remembered. The selected row remains a URL and right-panel concern. */
74+export function pullRequestListPreferences(
75+ search: PullRequestListPreferences | Schema.Schema.Type<typeof PullRequestListPreferencesSchema>,
76+): PullRequestListPreferences {
77+ return {
78+ involvement: search.involvement,
79+ state: search.state,
80+ ...(search.environmentId ? { environmentId: search.environmentId } : {}),
81+ ...(search.projectId ? { projectId: search.projectId } : {}),
82+ ...(search.host ? { host: search.host } : {}),
83+ ...(search.q ? { q: search.q } : {}),
84+ ...(search.draft ? { draft: search.draft } : {}),
85+ ...(search.review ? { review: search.review } : {}),
86+ ...(search.checks ? { checks: search.checks } : {}),
87+ ...(search.author ? { author: search.author } : {}),
88+ ...(search.labels && search.labels.length > 0 ? { labels: search.labels } : {}),
89+ ...(search.sort && search.sort !== "ready" ? { sort: search.sort } : {}),
90+ };
91+}
92+
93+export function readPullRequestListPreferences(
94+ storage?: PreferenceStorage,
95+): PullRequestListPreferences {
96+ try {
97+ const raw = resolvePreferenceStorage(storage)?.getItem(
98+ PULL_REQUEST_LIST_PREFERENCES_STORAGE_KEY,
99+ );
100+ if (!raw) return DEFAULT_PULL_REQUEST_LIST_PREFERENCES;
101+ const decoded = decodePullRequestListPreferences(JSON.parse(raw));
102+ return decoded._tag === "Some"
103+ ? pullRequestListPreferences(decoded.value)
104+ : DEFAULT_PULL_REQUEST_LIST_PREFERENCES;
105+ } catch {
106+ return DEFAULT_PULL_REQUEST_LIST_PREFERENCES;
107+ }
108+}
109+
110+export function writePullRequestListPreferences(
111+ preferences: PullRequestListPreferences,
112+ storage?: PreferenceStorage,
113+): void {
114+ try {
115+ resolvePreferenceStorage(storage)?.setItem(
116+ PULL_REQUEST_LIST_PREFERENCES_STORAGE_KEY,
117+ JSON.stringify(preferences),
118+ );
119+ } catch {
120+ // Storage can be full or denied; the URL remains the source of truth for this visit.
121+ }
122+}
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+35−6
@@ -120,6 +120,11 @@ export function PullRequestStateGlyph({ · 119 unmodified lines
120120
121121 const CHECK_STATUS_PRESENTATION = {
122122 pending: { label: "Running", Icon: LoaderIcon, toneClassName: "animate-spin text-amber-500" },
123+ "action-required": {
124+ label: "Awaiting action",
125+ Icon: CircleDotIcon,
126+ toneClassName: "text-amber-600 dark:text-amber-400/90",
127+ },
123128 success: {
124129 label: "Passed",
125130 Icon: CircleCheckIcon,
@@ -134,8 +139,20 @@ const CHECK_STATUS_PRESENTATION = { · 8 unmodified lines
134139 { label: string; Icon: typeof CircleCheckIcon; toneClassName: string }
135140 >;
136141
137−export function pullRequestCheckStatusLabel(status: PullRequestCheckStatus): string {
138− return CHECK_STATUS_PRESENTATION[status].label;
142+function isWorkflowApprovalCheck(check: Pick<PullRequestCheck, "status" | "url">): boolean {
143+ return (
144+ check.status === "action-required" &&
145+ check.url !== null &&
146+ /\/actions\/runs\/\d+(?:\/|$)/u.test(check.url)
147+ );
148+}
149+
150+export function pullRequestCheckStatusLabel(
151+ check: Pick<PullRequestCheck, "status" | "url">,
152+): string {
153+ return isWorkflowApprovalCheck(check)
154+ ? "Awaiting approval"
155+ : CHECK_STATUS_PRESENTATION[check.status].label;
139156 }
140157
141158 export function PullRequestCheckStatusIcon({ status }: { status: PullRequestCheckStatus }) {
@@ -187,10 +204,10 @@ export function pullRequestChecksState( · 45 unmodified lines
187204 checks: ReadonlyArray<PullRequestCheck>,
188205 ): PullRequestChecksState | null {
189206 if (checks.length === 0) return null;
190− const statuses = checks.map((check) => check.status);
191− if (statuses.includes("failure") || statuses.includes("cancelled")) return "failing";
192− if (statuses.includes("pending")) return "pending";
193− return statuses.includes("success") ? "passing" : null;
207+ const statuses = new Set(checks.map((check) => check.status));
208+ if (statuses.has("failure") || statuses.has("cancelled")) return "failing";
209+ if (statuses.has("pending") || statuses.has("action-required")) return "pending";
210+ return statuses.has("success") ? "passing" : null;
194211 }
195212
196213 /**
@@ -435,12 +452,24 @@ export function PullRequestMetaLine({ · 238 unmodified lines
435452
436453 export function summarizePullRequestChecks(checks: ReadonlyArray<PullRequestCheck>): string {
437454 if (checks.length === 0) return "No checks reported";
455+ const actionRequired = checks.filter((check) => check.status === "action-required");
456+ const workflowApprovalRequired = actionRequired.filter(isWorkflowApprovalCheck).length;
457+ const otherActionRequired = actionRequired.length - workflowApprovalRequired;
438458 const failed = checks.filter(
439459 (check) => check.status === "failure" || check.status === "cancelled",
440460 ).length;
441461 const pending = checks.filter((check) => check.status === "pending").length;
442462 const passed = checks.filter((check) => check.status === "success").length;
443463 if (failed > 0) return `${failed} of ${checks.length} failing`;
464+ if (workflowApprovalRequired > 0 && otherActionRequired > 0) {
465+ return `${workflowApprovalRequired} ${workflowApprovalRequired === 1 ? "workflow" : "workflows"} and ${otherActionRequired} ${otherActionRequired === 1 ? "check" : "checks"} awaiting action`;
466+ }
467+ if (workflowApprovalRequired > 0) {
468+ return `${workflowApprovalRequired} ${workflowApprovalRequired === 1 ? "workflow" : "workflows"} awaiting approval`;
469+ }
470+ if (otherActionRequired > 0) {
471+ return `${otherActionRequired} ${otherActionRequired === 1 ? "check" : "checks"} awaiting action`;
472+ }
444473 if (pending > 0) return `${pending} of ${checks.length} running`;
445474 return passed === checks.length ? "All checks passed" : `${passed} of ${checks.length} passing`;
446475 }
apps/web/src/components/sidebar/SidebarChrome.tsx+5−1
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
3030 useSidebar,
3131 } from "../ui/sidebar";
3232 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
33+import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
3334 import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
3435 import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
3536
@@ -157,7 +158,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { · 121 unmodified lines
157158 }, [isMobile, setOpenMobile]);
158159 const handlePullRequestsClick = useCallback(() => {
159160 closeMobileSidebar();
160− void navigate({ to: "/pull-requests", search: { involvement: "all", state: "open" } });
161+ void navigate({
162+ to: "/pull-requests",
163+ search: readPullRequestListPreferences(),
164+ });
161165 }, [closeMobileSidebar, navigate]);
162166 const handleSettingsClick = useCallback(() => {
163167 closeMobileSidebar();
apps/web/src/components/ui/badge.tsx+2−0
@@ -15,6 +15,8 @@ const badgeVariants = cva( · 14 unmodified lines
1515 },
1616 variants: {
1717 size: {
18+ control:
19+ "h-7 min-w-7 rounded-[var(--control-radius)] px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:min-w-6 sm:text-xs",
1820 default:
1921 "h-5.5 min-w-5.5 px-[calc(--spacing(1)-1px)] text-sm sm:h-4.5 sm:min-w-4.5 sm:text-xs",
2022 lg: "h-6.5 min-w-6.5 px-[calc(--spacing(1.5)-1px)] text-base sm:h-5.5 sm:min-w-5.5 sm:text-sm",
apps/web/src/components/ui/button.tsx+2−0
@@ -53,6 +53,8 @@ const buttonVariants = cva( · 52 unmodified lines
5353 "[--control-icon-color:var(--contrast-muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64",
5454 secondary:
5555 "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",
56+ "warning-outline":
57+ "border-warning/32 bg-warning-surface text-warning-foreground shadow-xs/5 [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-warning/40 [:hover,[data-pressed]]:bg-warning/16 dark:[:hover,[data-pressed]]:bg-warning/24",
5658 },
5759 },
5860 },
apps/web/src/routes/_chat.pull-requests.tsx+49−34
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
2525 GitPullRequestClosedIcon,
2626 GitPullRequestIcon,
2727 LayersIcon,
28+ ListChecksIcon,
2829 PenLineIcon,
2930 LoaderIcon,
3031 Maximize2Icon,
@@ -57,6 +58,7 @@ import { · 26 unmodified lines
5758 pullRequestEntryKey,
5859 pullRequestEntryViewer,
5960 rankPullRequestMatches,
61+ rankPullRequestsByMergeReadiness,
6062 pullRequestEnvironmentSetKey,
6163 readPullRequestListSnapshot,
6264 resolveProjectScope,
@@ -76,6 +78,13 @@ import { · 13 unmodified lines
7678 type PullRequestStatsScope,
7779 type PullRequestPartitionsSnapshot,
7880 } from "../components/pullRequest/pullRequestList.logic";
81+import {
82+ pullRequestListPreferences,
83+ type PullRequestListPreferencePatch,
84+ type PullRequestListPreferences,
85+ type PullRequestListSort,
86+ writePullRequestListPreferences,
87+} from "../components/pullRequest/pullRequestListPreferences";
7988 import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
8089 import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
8190 import {
@@ -129,9 +138,7 @@ import { useAtomCommand } from "../state/use-atom-command"; · 47 unmodified lines
129138 import { cn } from "~/lib/utils";
130139 import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
131140
132−export interface PullRequestsSearch {
133− readonly involvement: PullRequestInvolvement;
134− readonly state: PullRequestListState;
141+export interface PullRequestsSearch extends PullRequestListPreferences {
135142 /**
136143 * Narrows the list to one server. Absent means every connected one, which is the default the
137144 * page has now — so a link written before servers could be chosen still opens the whole list.
@@ -153,21 +160,8 @@ export interface PullRequestsSearch { · 15 unmodified lines
153160 * link without it still opens, resolved by project id alone where that is unambiguous.
154161 */
155162 readonly selectedEnvironmentId?: EnvironmentId;
156− readonly q?: string;
157− /**
158− * The narrowings beyond state and involvement, each absent when that group is unfiltered. Flat
159− * in the URL because a link is read and edited by hand; folded into one record for the listing.
160− */
161− readonly draft?: "only" | "hide";
162− readonly review?: NonNullable<PullRequestListFilters["review"]>;
163− readonly checks?: NonNullable<PullRequestListFilters["checks"]>;
164− readonly author?: string;
165− readonly labels?: ReadonlyArray<string>;
166− readonly sort?: PullRequestListSort;
167163 }
168164
169−type PullRequestListSort = "updated" | "newest" | "oldest" | "largest" | "smallest";
170−
171165 // The state filters wear the same glyphs the rows do, so the two read as one vocabulary.
172166 const INVOLVEMENT_TABS = [
173167 { value: "all", label: "All", Icon: LayersIcon },
@@ -183,6 +177,7 @@ const STATE_TABS = [ · 9 unmodified lines
183177 ] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
184178
185179 const SORT_OPTIONS = [
180+ { value: "ready", label: "Merge readiness", Icon: ListChecksIcon },
186181 { value: "updated", label: "Recently updated", Icon: ClockIcon },
187182 { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon },
188183 { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon },
@@ -287,9 +282,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ · 98 unmodified lines
287282
288283 function PullRequestsRouteView() {
289284 const search = Route.useSearch();
290− const sort = search.sort ?? "updated";
285+ const sort = search.sort ?? "ready";
291286 const statsPolicy: PullRequestStatsPolicy =
292− sort === "largest" || sort === "smallest" ? "eager" : "visible";
287+ sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible";
293288 const navigate = useNavigate({ from: Route.fullPath });
294289 const { environments } = useEnvironments();
295290 // Every connected environment that has said it can list pull requests. Sorted, so the query
@@ -487,7 +482,7 @@ function PullRequestsRouteView() { · 191 unmodified lines
487482 return {
488483 involvement: next.involvement ?? previous.involvement,
489484 state: next.state ?? previous.state,
490− ...(next.sort && next.sort !== "updated" ? { sort: next.sort } : {}),
485+ ...(next.sort && next.sort !== "ready" ? { sort: next.sort } : {}),
491486 ...(next.repository ? { repository: next.repository } : {}),
492487 ...(next.number ? { number: next.number } : {}),
493488 ...(next.projectId ? { projectId: next.projectId } : {}),
@@ -510,22 +505,25 @@ function PullRequestsRouteView() { · 16 unmodified lines
510505 [navigate],
511506 );
512507
513− // Changing what the list contains must not leave a selection from the previous view open.
514− // The project filter is untouched: it is the user's scope, not part of the selection.
515508 const clearedSelection = {
516509 repository: undefined,
517510 number: undefined,
518511 selectedProjectId: undefined,
519512 selectedEnvironmentId: undefined,
520513 };
521− const updateListScope = (patch: {
522− [Key in keyof PullRequestsSearch]?: PullRequestsSearch[Key] | undefined;
523− }) => {
524− if (rightPanelRef !== null) {
525− // Hide the old selection while retaining peer PR tabs for parallel reviews.
526− useRightPanelStore.getState().close(rightPanelRef);
527− }
528− updateSearch({ ...patch, ...clearedSelection });
514+ // List controls change the rows behind the detail, not the independent selected surface. The
515+ // reader can keep working in that panel while narrowing, sorting, or switching projects.
516+ const updateListScope = (patch: PullRequestListPreferencePatch) => {
517+ const currentPreferences = pullRequestListPreferences({
518+ ...search,
519+ ...patch,
520+ involvement: patch.involvement ?? search.involvement,
521+ state: patch.state ?? search.state,
522+ });
523+ // Opening a selected-only link does not save its default all/open list. Once a list control
524+ // changes, the entire visible scope is intentional and becomes the next sidebar destination.
525+ writePullRequestListPreferences(currentPreferences);
526+ updateSearch(patch);
529527 };
530528
531529 // Searching asks the hosts, which takes a round trip, so the text is held for a moment before
@@ -1420,7 +1418,24 @@ function PullRequestsRouteView() { · 888 unmodified lines
14201418 ...group,
14211419 entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)),
14221420 }));
1423− if (sort === "updated") return enriched;
1421+ if (sort === "ready" && typedParsed.text.length === 0) {
1422+ return [
1423+ {
1424+ key: "others" as const,
1425+ label: "",
1426+ entries: rankPullRequestsByMergeReadiness(
1427+ enriched.flatMap((group) => group.entries),
1428+ (entry) =>
1429+ entry.additions + entry.deletions > 0 ||
1430+ statsByRow.has(pullRequestDiffStatKey(entry)),
1431+ ),
1432+ },
1433+ ];
1434+ }
1435+ // Searching keeps its relevance order and priority groups unless the reader explicitly asks
1436+ // for another sort. The readiness queue is the default browse order, not a way to bury a
1437+ // closer text match.
1438+ if (sort === "ready" || sort === "updated") return enriched;
14241439 const entries = enriched.flatMap((group) => group.entries);
14251440 const hasSize = (entry: (typeof entries)[number]) =>
14261441 entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry));
@@ -1448,7 +1463,7 @@ function PullRequestsRouteView() { · 21 unmodified lines
14481463 }),
14491464 },
14501465 ];
1451− }, [groups, sort, statsByRow]);
1466+ }, [groups, sort, statsByRow, typedParsed.text]);
14521467
14531468 const linkedSelection = useMemo(
14541469 () =>
@@ -1567,7 +1582,7 @@ function PullRequestsRouteView() { · 112 unmodified lines
15671582 <PullRequestSearchInput
15681583 value={search.q ?? ""}
15691584 busy={typedQuery.length > 0 && (!querySettled || showingCarried)}
1570− onChange={(query) => updateSearch({ q: query || undefined })}
1585+ onChange={(query) => updateListScope({ q: query || undefined })}
15711586 />
15721587 );
15731588 const panelToggleControls = (
@@ -1635,7 +1650,7 @@ function PullRequestsRouteView() { · 61 unmodified lines
16351650 searching={typedQuery.length > 0 && (!querySettled || showingCarried)}
16361651 canLoadMore={listData?.truncated === true && (canContinue || pageSize < MAX_PAGE_SIZE)}
16371652 loadingMore={loadingMore}
1638− onClearQuery={() => updateSearch({ q: undefined })}
1653+ onClearQuery={() => updateListScope({ q: undefined })}
16391654 onLoadMore={loadMore}
16401655 />
16411656 ) : (
@@ -1739,7 +1754,7 @@ function PullRequestsRouteView() { · 97 unmodified lines
17391754 outlined
17401755 value={sort}
17411756 options={SORT_OPTIONS}
1742− onChange={(next) => updateSearch({ sort: next })}
1757+ onChange={(next) => updateListScope({ sort: next })}
17431758 />
17441759 );
17451760 const filtersMenu = (
docs/user/source-control.md+14−2
@@ -41,9 +41,19 @@ T3 Code works with the platforms your team already uses: · 40 unmodified lines
4141
4242 - See if your current branch already has an open PR/MR
4343 - Open several reviews from the **Pull requests** page as tabs in the right panel
44+- By default, see passing and approved reviews first, passing reviews awaiting approval next, and
45+ conflicting reviews last. Smaller changes come first within each readiness group, and finished
46+ reviews follow open work when all states are visible.
4447 - Filter the list by author or labels, rank authors by merges in the loaded results, see label and
45− change-size context on each row, and sort the results currently shown by update time, creation
46− time, or change size
48+ change-size context on each row, and sort the results currently shown by readiness, update time,
49+ creation time, or change size. Your filters, search, scope, and sort are restored when you return.
50+- Merge now, or on GitHub, GitLab, and Azure DevOps, leave an auto-merge instruction with a chosen
51+ strategy while checks are outstanding; see the completed state in the same control after the
52+ pull request merges
53+- On GitHub, approve fork workflows that are waiting to run and open a revert pull request for a
54+ merged change
55+- Timeline line counts stay hidden on merge commits, where GitHub's totals include upstream changes
56+ brought in from the base branch
4757 - While working in a thread, open linked reviews in the same compact right-panel tabs without
4858 leaving the conversation
4959 - Enable **Settings → General → Proactive panels** to open a newly linked review automatically and
@@ -56,6 +66,8 @@ T3 Code works with the platforms your team already uses: · 6 unmodified lines
5666
5767 **Fix what you wrote, in place**
5868
69+- Comment while closing an open pull request or reopening a closed one when the host offers that
70+ action
5971 - Rewrite a pull request's title and description from the review itself, in Markdown, with a
6072 preview before you save
6173 - Rewrite your own comments the same way, wherever they are shown
packages/contracts/src/pullRequest.test.ts+28−2
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);
1414 const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput);
1515 const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput);
16+const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
1617
1718 const LIST_RESULT: PullRequestListResult = {
1819 viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" },
@@ -158,7 +159,6 @@ describe("PullRequestReviewerRequestInput", () => { · 139 unmodified lines
158159 });
159160
160161 describe("updating a branch that has fallen behind its base", () => {
161− const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
162162 const ref = { projectId: "project-1", repository: "acme/web", number: 7 };
163163
164164 it("carries the way the branch should be brought up to date", () => {
@@ -182,7 +182,6 @@ describe("updating a branch that has fallen behind its base", () => { · 17 unmodified lines
182182 });
183183
184184 describe("leaving a merge for the host to make once it is ready", () => {
185− const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
186185 const ref = { projectId: "project-1", repository: "acme/web", number: 7 };
187186
188187 it("carries the strategy the deferred merge should use, as merging now does", () => {
@@ -196,6 +195,33 @@ describe("leaving a merge for the host to make once it is ready", () => { · 7 unmodified lines
196195 });
197196 });
198197
198+describe("reverting a merged pull request", () => {
199+ it("carries the revert action without merge options", () => {
200+ const action = decodeAction({
201+ projectId: "project-1",
202+ repository: "acme/web",
203+ number: 7,
204+ action: "revert",
205+ });
206+
207+ expect(action.action).toBe("revert");
208+ expect(action.mergeMethod).toBeUndefined();
209+ });
210+});
211+
212+describe("approving fork workflows", () => {
213+ it("carries workflow approval as its own action", () => {
214+ const action = decodeAction({
215+ projectId: "project-1",
216+ repository: "acme/web",
217+ number: 7,
218+ action: "approve-workflows",
219+ });
220+
221+ expect(action.action).toBe("approve-workflows");
222+ });
223+});
224+
199225 describe("PullRequestCapabilities", () => {
200226 const decodeCapabilities = Schema.decodeUnknownSync(PullRequestCapabilities);
201227 const base = {
packages/contracts/src/pullRequest.ts+9−0
@@ -93,6 +93,10 @@ export const PullRequestAction = Schema.Literals([ · 92 unmodified lines
9393 "enable-auto-merge",
9494 /** Take the standing instruction back, which leaves the change request where it was. */
9595 "disable-auto-merge",
96+ /** Open a new change request that reverses a merged one. */
97+ "revert",
98+ /** Allow Actions workflows from a fork pull request to begin running. */
99+ "approve-workflows",
96100 ]);
97101 export type PullRequestAction = typeof PullRequestAction.Type;
98102
@@ -131,6 +135,7 @@ export type PullRequestLabel = typeof PullRequestLabel.Type; · 32 unmodified lines
131135
132136 export const PullRequestCheckStatus = Schema.Literals([
133137 "pending",
138+ "action-required",
134139 "success",
135140 "failure",
136141 "skipped",
@@ -709,6 +714,10 @@ export const PullRequestDetail = Schema.Struct({ · 572 unmodified lines
709714 * arm something that is already armed, and a second arming is a write nobody asked for.
710715 */
711716 autoMergeEnabled: Schema.optional(Schema.Boolean),
717+ /** The strategy the host will use for an armed auto-merge, where it reports one. */
718+ autoMergeMethod: Schema.optional(PullRequestMergeMethod),
719+ /** GitHub Actions runs on this head commit that are waiting for a maintainer's approval. */
720+ workflowApprovalsRequired: Schema.optional(NonNegativeInt),
712721 });
713722 export type PullRequestDetail = typeof PullRequestDetail.Type;
714723