authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 20:00:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 20:00:27-04:00
log443b4ebfe83fcfe64c34b09ecb5a5fffdebb85c7
treede97bf11921339582f599d9f1eee2f074e3214f9
parent4ba39a6f408bdee468df2e3ffdf7d5dc08e7b59d
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

34 files changed, 2241 insertions(+), 205 deletions(-)

apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts+1-1
......@@ -414,7 +414,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
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(
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(
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
......@@ -169,6 +169,9 @@ export const make = Effect.gen(function* () {
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) => {
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) => {
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 {
3434 decodePullRequestActivityJson,
3535 decodePullRequestDetailJson,
3636 decodePullRequestFilesJson,
37 decodePullRequestHeadsJson,
3738 decodePullRequestListJson,
3839 decodePullRequestNodeIdJson,
3940 decodePullRequestSearchJson,
......@@ -56,6 +57,7 @@ import {
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 {
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
259264 }
260265}
261266
267/** GitHub answered successfully, but approving every returned workflow would be unsafe. */
268export 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. */
295export 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. */
313export 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
262330export type GitHubPullRequestCliError =
263331 | GitHubCli.GitHubCliError
264332 | GitHubPullRequestReadError
......@@ -268,6 +336,9 @@ export type GitHubPullRequestCliError =
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<
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(
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* () {
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* () {
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* () {
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";
33import * as Layer from "effect/Layer";
44import type { PullRequestReaction } from "@t3tools/contracts";
55
6import * as GitHubCli from "../sourceControl/GitHubCli.ts";
67import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
78import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
89import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
......@@ -52,6 +53,8 @@ describe("gitHubViewerPermissions", () => {
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", () => {
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", () => {
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", () => {
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
157describe("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 };
264const 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
296it.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
327it.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
374it.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
413describe("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";
55import type {
66 PullRequestActor,
77 PullRequestCapabilities,
8 PullRequestCheck,
89 PullRequestReaction,
910 PullRequestViewerPermissions,
1011} from "@t3tools/contracts";
......@@ -17,7 +18,7 @@ import {
1718 type ProviderChangeRequestDetail,
1819 type PullRequestProviderApi,
1920} from "./PullRequestProvider.ts";
20import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts";
21import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
2122
2223const CAPABILITIES: PullRequestCapabilities = {
2324 diff: true,
......@@ -31,6 +32,8 @@ const CAPABILITIES: PullRequestCapabilities = {
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
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(
117128 return avatarUrl === null ? actor : { ...actor, avatarUrl };
118129}
119130
131function 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* () {
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* () {
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
......@@ -504,6 +504,10 @@ function actionArgs(
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
......@@ -177,6 +177,10 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest {
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
......@@ -202,6 +202,9 @@ const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = {
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* () {
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
......@@ -159,6 +159,34 @@ describe("decodePullRequestJson", () => {
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
......@@ -5,6 +5,7 @@ import * as Schema from "effect/Schema";
55import type {
66 PullRequestActor,
77 PullRequestComment,
8 PullRequestMergeMethod,
89 PullRequestMergeability,
910 PullRequestState,
1011} from "@t3tools/contracts";
......@@ -41,6 +42,14 @@ const RawPullRequestSchema = Schema.Struct({
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 {
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
137148function trimmed(value: string | null | undefined): string | null {
......@@ -188,6 +199,23 @@ function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): str
188199 return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`;
189200}
190201
202function 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
196224function 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(
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 {
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", () => {
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", () => {
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 {
1111 PullRequestCommit,
1212 PullRequestLabel,
1313 PullRequestMergeCapabilities,
14 PullRequestMergeMethod,
1415 PullRequestOmittedFileStat,
1516 PullRequestMergeability,
1617 PullRequestReaction,
......@@ -359,17 +360,32 @@ const RawCommitSchema = Schema.Struct({
359360
360361const 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
378const 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
384const 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
375391const RawActivitySchema = Schema.Struct({
......@@ -508,6 +524,9 @@ const RawReviewThreadsSchema = Schema.Struct({
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(
599618export 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
602export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest`;
621export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`;
603622export 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
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
879899 }
880900}`;
881901
902/** Creates a new pull request that reverses a merged pull request. */
903export 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 {
10161043}
10171044
10181045export 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 {
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
1062export interface GitHubWorkflowRunApproval {
1063 readonly id: number;
1064 readonly name: string;
1065 readonly url: string | null;
1066}
1067
1068export interface GitHubPullRequestHead {
1069 readonly number: number;
1070 readonly headSha: string;
1071 readonly isCrossRepository?: boolean;
1072 readonly headRepositoryOwner: string | null;
10281073}
10291074
10301075export interface GitHubPullRequestActivity {
......@@ -1114,6 +1159,19 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili
11141159 }
11151160}
11161161
1162function 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
11171175function 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
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(
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
13611419}
13621420
13631421function 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
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);
13911455const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema);
13921456const decodeStats = decodeJsonResult(RawStatsSchema);
13931457const decodeDetail = decodeJsonResult(RawDetailSchema);
1458const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema));
1459const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema));
13941460const decodeActivity = decodeJsonResult(RawActivitySchema);
13951461const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema);
13961462const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema);
......@@ -1551,6 +1617,37 @@ export function decodePullRequestDetailJson(
15511617 : Result.fail(decoded.failure);
15521618}
15531619
1620export 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
1634export 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
15541651export function decodePullRequestActivityJson(
15551652 raw: string,
15561653): Result.Result<GitHubPullRequestActivity, DecodeFailure> {
......@@ -1788,7 +1885,14 @@ export function decodeReviewThreadsJson(
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
......@@ -192,6 +192,28 @@ describe("decodeMergeRequestDetailJson", () => {
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
......@@ -11,6 +11,7 @@ import type {
1111 PullRequestLabel,
1212 PullRequestMergeability,
1313 PullRequestMergeCapabilities,
14 PullRequestMergeMethod,
1415 PullRequestReaction,
1516 PullRequestReactionContent,
1617 PullRequestReviewThread,
......@@ -80,6 +81,9 @@ const RawMergeRequestSchema = Schema.Struct({
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 {
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
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> }) {
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({
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 {
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 {
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> = {
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
159164const MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = {
......@@ -172,6 +177,8 @@ const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {
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> = {
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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
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({
15071618 ) : null}
15081619 {pullRequestActionMenuHasGroup(
15091620 showsDraftToggle,
1510 showsAutoMerge,
1621 showsAutoMerge || showsMergeNow,
15111622 showsMergeMethods,
15121623 ) ? (
15131624 <MenuSeparator />
......@@ -1542,6 +1653,17 @@ export function PullRequestDetailPanel({
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({
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({
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({
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({
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({
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({
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 {
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({
308310function 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({
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({
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({
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({
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({
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({
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";
55import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
66import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
77import { PullRequestRow } from "./PullRequestRow";
8import { pullRequestChecksState } from "./pullRequestPresentation";
8import {
9 pullRequestChecksState,
10 pullRequestCheckStatusLabel,
11 summarizePullRequestChecks,
12} from "./pullRequestPresentation";
913
10function check(status: PullRequestCheck["status"]): PullRequestCheck {
11 return { name: `check-${status}`, status, description: null, url: null };
14function check(
15 status: PullRequestCheck["status"],
16 overrides: Partial<PullRequestCheck> = {},
17): PullRequestCheck {
18 return { name: `check-${status}`, status, description: null, url: null, ...overrides };
1219}
1320
1421describe("pullRequestChecksState", () => {
......@@ -18,11 +25,31 @@ describe("pullRequestChecksState", () => {
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 {
3131 pullRequestHandoffLabels,
3232 pullRequestReviewOutcome,
3333 readableFailure,
34 resolvePullRequestPrimaryControl,
3435 shouldRefreshPullRequestActivity,
3536 resolveBaseFreshness,
3637 buildPullRequestTimeline,
......@@ -146,6 +147,54 @@ describe("pull request action menu", () => {
146147 });
147148});
148149
150describe("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
149198describe("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", () => {
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", () => {
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 {
33 PullRequestActor,
44 PullRequestBaseComparison,
55 PullRequestCheck,
6 PullRequestChecksState,
67 PullRequestComment,
78 PullRequestCommit,
89 PullRequestDetailView,
......@@ -20,6 +21,45 @@ import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/re
2021const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/;
2122const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
2223
24export 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. */
35export 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
2363export function pullRequestCheckoutCommand(
2464 provider: SourceControlProviderKind,
2565 number: number,
......@@ -949,11 +989,9 @@ export function resolveBaseFreshness(detail: {
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 */
958996const ACTION_NEEDS_HOST_REFRESH: Record<PullRequestAction, boolean> = {
959997 "update-branch": true,
......@@ -964,6 +1002,8 @@ const ACTION_NEEDS_HOST_REFRESH: Record<PullRequestAction, boolean> = {
9641002 reopen: false,
9651003 "enable-auto-merge": false,
9661004 "disable-auto-merge": false,
1005 revert: false,
1006 "approve-workflows": true,
9671007};
9681008
9691009export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): boolean {
apps/web/src/components/pullRequest/pullRequestList.logic.test.ts+167
......@@ -21,6 +21,7 @@ import {
2121 readPullRequestListSnapshot,
2222 writePullRequestListSnapshot,
2323 rankPullRequestMatches,
24 rankPullRequestsByMergeReadiness,
2425 scorePullRequestMatch,
2526 retainVisiblePullRequestStatsBatches,
2627 withDiffStat,
......@@ -29,6 +30,11 @@ import {
2930 resolveSelectedEnvironmentId,
3031 type EnvironmentPullRequestEntry,
3132} from "./pullRequestList.logic";
33import {
34 pullRequestListPreferences,
35 readPullRequestListPreferences,
36 writePullRequestListPreferences,
37} from "./pullRequestListPreferences";
3238
3339const VIEWERS = { "github.com": "Bilal" } as const;
3440const NO_VIEWERS = {} as const;
......@@ -656,6 +662,99 @@ describe("ranking what a search found", () => {
656662 });
657663});
658664
665describe("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
659758describe("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", () => {
858957 });
859958});
860959
960describe("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
8611028const ENV_1 = "env-1" as EnvironmentId;
8621029const 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
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 */
990990export function rankPullRequestMatches<Entry extends PullRequestListEntry>(
991991 entries: ReadonlyArray<Entry>,
......@@ -998,6 +998,35 @@ export function rankPullRequestMatches<Entry extends PullRequestListEntry>(
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 */
1008export 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 created+122
......@@ -0,0 +1,122 @@
1import * as Schema from "effect/Schema";
2
3import {
4 EnvironmentId,
5 ProjectId,
6 PullRequestInvolvement,
7 PullRequestListFilters,
8 PullRequestListState,
9} from "@t3tools/contracts";
10
11export const PullRequestListSort = Schema.Literals([
12 "ready",
13 "updated",
14 "newest",
15 "oldest",
16 "largest",
17 "smallest",
18]);
19export type PullRequestListSort = typeof PullRequestListSort.Type;
20
21export 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
36export type PullRequestListPreferencePatch = {
37 [Key in keyof PullRequestListPreferences]?: PullRequestListPreferences[Key] | undefined;
38};
39
40export const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = {
41 involvement: "all",
42 state: "open",
43} as const satisfies PullRequestListPreferences;
44
45const BoundedPreference = Schema.String.check(Schema.isMaxLength(200));
46const 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
61const decodePullRequestListPreferences = Schema.decodeUnknownOption(
62 PullRequestListPreferencesSchema,
63);
64const PULL_REQUEST_LIST_PREFERENCES_STORAGE_KEY = "t3.pullRequests.preferences";
65type PreferenceStorage = Pick<Storage, "getItem" | "setItem">;
66
67function 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. */
74export 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
93export 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
110export 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({
120120
121121const 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 = {
134139 { label: string; Icon: typeof CircleCheckIcon; toneClassName: string }
135140>;
136141
137export function pullRequestCheckStatusLabel(status: PullRequestCheckStatus): string {
138 return CHECK_STATUS_PRESENTATION[status].label;
142function 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
150export 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
141158export function PullRequestCheckStatusIcon({ status }: { status: PullRequestCheckStatus }) {
......@@ -187,10 +204,10 @@ export function pullRequestChecksState(
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({
435452
436453export 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 {
3030 useSidebar,
3131} from "../ui/sidebar";
3232import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
33import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
3334import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
3435import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
3536
......@@ -157,7 +158,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
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
......@@ -15,6 +15,8 @@ const badgeVariants = cva(
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
......@@ -53,6 +53,8 @@ const buttonVariants = cva(
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 {
2525 GitPullRequestClosedIcon,
2626 GitPullRequestIcon,
2727 LayersIcon,
28 ListChecksIcon,
2829 PenLineIcon,
2930 LoaderIcon,
3031 Maximize2Icon,
......@@ -57,6 +58,7 @@ import {
5758 pullRequestEntryKey,
5859 pullRequestEntryViewer,
5960 rankPullRequestMatches,
61 rankPullRequestsByMergeReadiness,
6062 pullRequestEnvironmentSetKey,
6163 readPullRequestListSnapshot,
6264 resolveProjectScope,
......@@ -76,6 +78,13 @@ import {
7678 type PullRequestStatsScope,
7779 type PullRequestPartitionsSnapshot,
7880} from "../components/pullRequest/pullRequestList.logic";
81import {
82 pullRequestListPreferences,
83 type PullRequestListPreferencePatch,
84 type PullRequestListPreferences,
85 type PullRequestListSort,
86 writePullRequestListPreferences,
87} from "../components/pullRequest/pullRequestListPreferences";
7988import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic";
8089import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel";
8190import {
......@@ -129,9 +138,7 @@ import { useAtomCommand } from "../state/use-atom-command";
129138import { cn } from "~/lib/utils";
130139import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
131140
132export interface PullRequestsSearch {
133 readonly involvement: PullRequestInvolvement;
134 readonly state: PullRequestListState;
141export 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 {
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
169type 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.
172166const INVOLVEMENT_TABS = [
173167 { value: "all", label: "All", Icon: LayersIcon },
......@@ -183,6 +177,7 @@ const STATE_TABS = [
183177] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>;
184178
185179const 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")({
287282
288283function 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() {
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() {
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() {
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() {
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() {
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() {
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() {
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:
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:
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 {
1313const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);
1414const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput);
1515const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput);
16const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
1617
1718const LIST_RESULT: PullRequestListResult = {
1819 viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" },
......@@ -158,7 +159,6 @@ describe("PullRequestReviewerRequestInput", () => {
158159});
159160
160161describe("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", () => {
182182});
183183
184184describe("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", () => {
196195 });
197196});
198197
198describe("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
212describe("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
199225describe("PullRequestCapabilities", () => {
200226 const decodeCapabilities = Schema.decodeUnknownSync(PullRequestCapabilities);
201227 const base = {
packages/contracts/src/pullRequest.ts+9
......@@ -93,6 +93,10 @@ export const PullRequestAction = Schema.Literals([
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]);
97101export type PullRequestAction = typeof PullRequestAction.Type;
98102
......@@ -131,6 +135,7 @@ export type PullRequestLabel = typeof PullRequestLabel.Type;
131135
132136export const PullRequestCheckStatus = Schema.Literals([
133137 "pending",
138 "action-required",
134139 "success",
135140 "failure",
136141 "skipped",
......@@ -709,6 +714,10 @@ export const PullRequestDetail = Schema.Struct({
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});
713722export type PullRequestDetail = typeof PullRequestDetail.Type;
714723