Unified · Split
| @@ -414,7 +414,7 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { · 413 unmodified lines | ||
| 414 | 414 | ); |
| 415 | 415 | |
| 416 | 416 | it.effect.each([ |
| 417 | − { action: "enable-auto-merge", expected: ["--auto-complete", "true", "--squash", "false"] }, | |
| 417 | + { action: "enable-auto-merge", expected: ["--auto-complete", "true"] }, | |
| 418 | 418 | { action: "disable-auto-merge", expected: ["--auto-complete", "false"] }, |
| 419 | 419 | { action: "draft", expected: ["--draft", "true"] }, |
| 420 | 420 | { action: "ready", expected: ["--draft", "false"] }, |
| @@ -224,7 +224,13 @@ function actionArgs( · 223 unmodified lines | ||
| 224 | 224 | // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes |
| 225 | 225 | // it once its policies pass. The squash choice is stored with it, as it is for a merge now. |
| 226 | 226 | 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 | + ]; | |
| 228 | 234 | case "disable-auto-merge": |
| 229 | 235 | return ["--auto-complete", "false"]; |
| 230 | 236 | case "ready": |
| @@ -238,6 +244,10 @@ function actionArgs( · 7 unmodified lines | ||
| 238 | 244 | return []; |
| 239 | 245 | case "reopen": |
| 240 | 246 | 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`); | |
| 241 | 251 | } |
| 242 | 252 | } |
| 243 | 253 | |
| @@ -169,6 +169,9 @@ export const make = Effect.gen(function* () { · 168 unmodified lines | ||
| 169 | 169 | mergeCapabilities: { merge: true, squash: true, rebase: false }, |
| 170 | 170 | viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, |
| 171 | 171 | autoMergeEnabled: pullRequest.autoMergeEnabled, |
| 172 | + ...(pullRequest.autoMergeMethod === undefined | |
| 173 | + ? {} | |
| 174 | + : { autoMergeMethod: pullRequest.autoMergeMethod }), | |
| 172 | 175 | }), |
| 173 | 176 | ), |
| 174 | 177 | ), |
| @@ -1200,6 +1200,389 @@ layer("GitHubPullRequestCli.layer", (it) => { · 1199 unmodified lines | ||
| 1200 | 1200 | }), |
| 1201 | 1201 | ); |
| 1202 | 1202 | |
| 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 | + | |
| 1203 | 1586 | it.effect("returns a pull request to draft by undoing ready", () => |
| 1204 | 1587 | Effect.gen(function* () { |
| 1205 | 1588 | mockedExecute.mockReturnValue(Effect.succeed(output(""))); |
| @@ -2136,7 +2519,7 @@ layer("GitHubPullRequestCli.layer", (it) => { · 930 unmodified lines | ||
| 2136 | 2519 | expect(detail.body).toBe("Core body"); |
| 2137 | 2520 | expect(activity.author?.login).toBe("octocat"); |
| 2138 | 2521 | 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", | |
| 2140 | 2523 | ); |
| 2141 | 2524 | expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); |
| 2142 | 2525 | }), |
| @@ -34,6 +34,7 @@ import { · 33 unmodified lines | ||
| 34 | 34 | decodePullRequestActivityJson, |
| 35 | 35 | decodePullRequestDetailJson, |
| 36 | 36 | decodePullRequestFilesJson, |
| 37 | + decodePullRequestHeadsJson, | |
| 37 | 38 | decodePullRequestListJson, |
| 38 | 39 | decodePullRequestNodeIdJson, |
| 39 | 40 | decodePullRequestSearchJson, |
| @@ -56,6 +57,7 @@ import { · 16 unmodified lines | ||
| 56 | 57 | PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, |
| 57 | 58 | REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, |
| 58 | 59 | REMOVE_REACTION_GRAPHQL_MUTATION, |
| 60 | + REVERT_PULL_REQUEST_GRAPHQL_MUTATION, | |
| 59 | 61 | gitHubReactionContent, |
| 60 | 62 | REPOSITORY_ACCESS_JSON_FIELDS, |
| 61 | 63 | RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, |
| @@ -71,13 +73,16 @@ import { · 9 unmodified lines | ||
| 71 | 73 | UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, |
| 72 | 74 | VIEWER_PERMISSIONS_GRAPHQL_QUERY, |
| 73 | 75 | decodeViewerPermissionsJson, |
| 76 | + decodeWorkflowRunApprovalsJson, | |
| 74 | 77 | type GitHubBaseComparison, |
| 75 | 78 | type GitHubPullRequestDetail, |
| 76 | 79 | type GitHubPullRequestActivity, |
| 80 | + type GitHubPullRequestHead, | |
| 77 | 81 | type GitHubPullRequestListItem, |
| 78 | 82 | type GitHubPullRequestSearchItem, |
| 79 | 83 | type GitHubReviewThreadComments, |
| 80 | 84 | type GitHubRepositoryAccess, |
| 85 | + type GitHubWorkflowRunApproval, | |
| 81 | 86 | type GitHubReviewThreadEntry, |
| 82 | 87 | type GitHubReviewThreadPage, |
| 83 | 88 | type GitHubViewerAccess, |
| @@ -259,6 +264,69 @@ export class GitHubSubjectScopeError extends Schema.TaggedErrorClass<GitHubSubje · 175 unmodified lines | ||
| 259 | 264 | } |
| 260 | 265 | } |
| 261 | 266 | |
| 267 | +/** GitHub answered successfully, but approving every returned workflow would be unsafe. */ | |
| 268 | +export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedErrorClass<GitHubWorkflowApprovalRefusedError>()( | |
| 269 | + "GitHubWorkflowApprovalRefusedError", | |
| 270 | + { | |
| 271 | + command: Schema.Literal("gh"), | |
| 272 | + cwd: Schema.String, | |
| 273 | + number: Schema.Int, | |
| 274 | + reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), | |
| 275 | + observedCount: Schema.Int, | |
| 276 | + limit: Schema.Int, | |
| 277 | + }, | |
| 278 | +) { | |
| 279 | + get detail(): string { | |
| 280 | + if (this.reason === "head-list-truncated") { | |
| 281 | + return `GitHub returned more than ${this.limit} pull requests for this head branch.`; | |
| 282 | + } | |
| 283 | + if (this.reason === "head-not-unique") { | |
| 284 | + return `The head revision matched ${this.observedCount} pull requests instead of uniquely matching #${this.number}.`; | |
| 285 | + } | |
| 286 | + return `GitHub returned more than ${this.limit} workflow runs awaiting approval.`; | |
| 287 | + } | |
| 288 | + | |
| 289 | + override get message(): string { | |
| 290 | + return `GitHub CLI refused listWorkflowRunsRequiringApproval: ${this.detail}`; | |
| 291 | + } | |
| 292 | +} | |
| 293 | + | |
| 294 | +/** GitHub omitted the immutable head identity needed to scope an approval safely. */ | |
| 295 | +export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErrorClass<GitHubWorkflowApprovalHeadUnavailableError>()( | |
| 296 | + "GitHubWorkflowApprovalHeadUnavailableError", | |
| 297 | + { | |
| 298 | + command: Schema.Literal("gh"), | |
| 299 | + cwd: Schema.String, | |
| 300 | + number: Schema.Int, | |
| 301 | + }, | |
| 302 | +) { | |
| 303 | + get detail(): string { | |
| 304 | + return `GitHub did not report a complete head revision for #${this.number}.`; | |
| 305 | + } | |
| 306 | + | |
| 307 | + override get message(): string { | |
| 308 | + return `GitHub CLI refused approve-workflows: ${this.detail}`; | |
| 309 | + } | |
| 310 | +} | |
| 311 | + | |
| 312 | +/** The pull request moved after its approval candidates were read. */ | |
| 313 | +export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedErrorClass<GitHubWorkflowApprovalHeadChangedError>()( | |
| 314 | + "GitHubWorkflowApprovalHeadChangedError", | |
| 315 | + { | |
| 316 | + command: Schema.Literal("gh"), | |
| 317 | + cwd: Schema.String, | |
| 318 | + number: Schema.Int, | |
| 319 | + }, | |
| 320 | +) { | |
| 321 | + get detail(): string { | |
| 322 | + return `The head revision of #${this.number} changed before its workflows could be approved.`; | |
| 323 | + } | |
| 324 | + | |
| 325 | + override get message(): string { | |
| 326 | + return `GitHub CLI refused approve-workflows: ${this.detail}`; | |
| 327 | + } | |
| 328 | +} | |
| 329 | + | |
| 262 | 330 | export type GitHubPullRequestCliError = |
| 263 | 331 | | GitHubCli.GitHubCliError |
| 264 | 332 | | GitHubPullRequestReadError |
| @@ -268,6 +336,9 @@ export type GitHubPullRequestCliError = · 3 unmodified lines | ||
| 268 | 336 | | GitHubDiffFileContentsUnavailableError |
| 269 | 337 | | GitHubRepositorySelectorError |
| 270 | 338 | | GitHubSubjectScopeError |
| 339 | + | GitHubWorkflowApprovalRefusedError | |
| 340 | + | GitHubWorkflowApprovalHeadUnavailableError | |
| 341 | + | GitHubWorkflowApprovalHeadChangedError | |
| 271 | 342 | | SourceControlRateLimit.SourceControlRateLimitPausedError |
| 272 | 343 | | GitHubViewerLoginUnavailableError |
| 273 | 344 | | GitHubPullRequestUpdatedAtUnavailableError; |
| @@ -405,6 +476,17 @@ export class GitHubPullRequestCli extends Context.Service< · 131 unmodified lines | ||
| 405 | 476 | readonly number: number; |
| 406 | 477 | }) => Effect.Effect<GitHubPullRequestDetail, GitHubPullRequestCliError>; |
| 407 | 478 | |
| 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 | + | |
| 408 | 490 | /** |
| 409 | 491 | * How far the branch trails its base, and whether this viewer may update it. Its own read |
| 410 | 492 | * because the comparison needs the head ref the detail answers with — a fork's branch is not |
| @@ -864,6 +946,12 @@ function actionArgs( · 453 unmodified lines | ||
| 864 | 946 | return ["close"]; |
| 865 | 947 | case "reopen": |
| 866 | 948 | 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"); | |
| 867 | 955 | } |
| 868 | 956 | } |
| 869 | 957 | |
| @@ -1194,6 +1282,158 @@ export const make = Effect.gen(function* () { · 324 unmodified lines | ||
| 1194 | 1282 | return { oldContents, newContents }; |
| 1195 | 1283 | }); |
| 1196 | 1284 | |
| 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 | + | |
| 1197 | 1437 | return GitHubPullRequestCli.of({ |
| 1198 | 1438 | getViewerLogin: (input) => |
| 1199 | 1439 | github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( |
| @@ -1393,34 +1633,8 @@ export const make = Effect.gen(function* () { · 193 unmodified lines | ||
| 1393 | 1633 | ), |
| 1394 | 1634 | ), |
| 1395 | 1635 | |
| 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, | |
| 1424 | 1638 | |
| 1425 | 1639 | getPullRequestBaseComparison: (input) => { |
| 1426 | 1640 | const { owner, name } = parseRepositorySelector(input.repository); |
| @@ -1764,6 +1978,106 @@ export const make = Effect.gen(function* () { · 337 unmodified lines | ||
| 1764 | 1978 | }, |
| 1765 | 1979 | |
| 1766 | 1980 | 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 | + } | |
| 1767 | 2081 | const [subcommand, ...flags] = actionArgs( |
| 1768 | 2082 | input.action, |
| 1769 | 2083 | input.mergeMethod, |
| @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | ||
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import type { PullRequestReaction } from "@t3tools/contracts"; |
| 5 | 5 | |
| 6 | +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | |
| 6 | 7 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 7 | 8 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| 8 | 9 | import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; |
| @@ -52,6 +53,8 @@ describe("gitHubViewerPermissions", () => { · 43 unmodified lines | ||
| 52 | 53 | "merge", |
| 53 | 54 | "enable-auto-merge", |
| 54 | 55 | "disable-auto-merge", |
| 56 | + "revert", | |
| 57 | + "approve-workflows", | |
| 55 | 58 | "ready", |
| 56 | 59 | "draft", |
| 57 | 60 | "close", |
| @@ -108,6 +111,13 @@ describe("gitHubViewerPermissions", () => { · 50 unmodified lines | ||
| 108 | 111 | verdicts: ["comment", "approve", "request-changes"], |
| 109 | 112 | requestReviewers: false, |
| 110 | 113 | }); |
| 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 | + }); | |
| 111 | 121 | }).pipe( |
| 112 | 122 | Effect.provide( |
| 113 | 123 | Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ |
| @@ -118,6 +128,7 @@ describe("gitHubViewerPermissions", () => { · 4 unmodified lines | ||
| 118 | 128 | title: "Pull request 7", |
| 119 | 129 | url: "https://github.com/acme/web/pull/7", |
| 120 | 130 | author: null, |
| 131 | + isCrossRepository: true, | |
| 121 | 132 | headRepositoryOwner: null, |
| 122 | 133 | headBranch: "feat/page", |
| 123 | 134 | baseBranch: "main", |
| @@ -152,39 +163,254 @@ describe("gitHubViewerPermissions", () => { · 28 unmodified lines | ||
| 152 | 163 | ), |
| 153 | 164 | ), |
| 154 | 165 | ); |
| 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 | + ); | |
| 155 | 262 | }); |
| 156 | 263 | |
| 157 | −describe("getViewerPermissions", () => { | |
| 158 | − const openDetail = { | |
| 159 | − authorId: null, | |
| 160 | − number: 7, | |
| 161 | − title: "Pull request 7", | |
| 162 | − url: "https://github.com/acme/web/pull/7", | |
| 163 | − author: null, | |
| 164 | − headRepositoryOwner: "acme", | |
| 165 | − headBranch: "feat/page", | |
| 166 | − baseBranch: "main", | |
| 167 | − state: "open" as const, | |
| 168 | − isDraft: false, | |
| 169 | − mergeability: "mergeable" as const, | |
| 170 | − reviewDecision: null, | |
| 171 | − additions: 1, | |
| 172 | − deletions: 1, | |
| 173 | − createdAt: "2026-07-01T00:00:00Z", | |
| 174 | − updatedAt: "2026-07-02T00:00:00Z", | |
| 175 | − reviewRequestLogins: [], | |
| 176 | − hasTeamReviewRequest: false, | |
| 177 | − checksState: null, | |
| 178 | − labels: [], | |
| 179 | − body: "", | |
| 180 | − changedFiles: 1, | |
| 181 | − mergedAt: null, | |
| 182 | − closedAt: null, | |
| 183 | − checks: [], | |
| 184 | − comments: [], | |
| 185 | − commits: [], | |
| 186 | − }; | |
| 264 | +const openDetail = { | |
| 265 | + authorId: null, | |
| 266 | + number: 7, | |
| 267 | + title: "Pull request 7", | |
| 268 | + url: "https://github.com/acme/web/pull/7", | |
| 269 | + author: null, | |
| 270 | + isCrossRepository: true, | |
| 271 | + headRepositoryOwner: "acme", | |
| 272 | + headSha: "abc123", | |
| 273 | + headBranch: "feat/page", | |
| 274 | + baseBranch: "main", | |
| 275 | + state: "open" as const, | |
| 276 | + isDraft: false, | |
| 277 | + mergeability: "mergeable" as const, | |
| 278 | + reviewDecision: null, | |
| 279 | + additions: 1, | |
| 280 | + deletions: 1, | |
| 281 | + createdAt: "2026-07-01T00:00:00Z", | |
| 282 | + updatedAt: "2026-07-02T00:00:00Z", | |
| 283 | + reviewRequestLogins: [], | |
| 284 | + hasTeamReviewRequest: false, | |
| 285 | + checksState: null, | |
| 286 | + labels: [], | |
| 287 | + body: "", | |
| 288 | + changedFiles: 1, | |
| 289 | + mergedAt: null, | |
| 290 | + closedAt: null, | |
| 291 | + checks: [], | |
| 292 | + comments: [], | |
| 293 | + commits: [], | |
| 294 | +}; | |
| 295 | + | |
| 296 | +it.effect("does not classify same-repository gates as fork workflow approvals", () => | |
| 297 | + Effect.gen(function* () { | |
| 298 | + const provider = yield* make; | |
| 299 | + const detail = yield* provider.getChangeRequest({ | |
| 300 | + cwd: "/w", | |
| 301 | + repository: "acme/web", | |
| 302 | + host: "github.com", | |
| 303 | + number: 7, | |
| 304 | + }); | |
| 187 | 305 | |
| 306 | + expect(detail.workflowApprovalsRequired).toBe(0); | |
| 307 | + expect(detail.checks).toEqual([]); | |
| 308 | + }).pipe( | |
| 309 | + Effect.provide( | |
| 310 | + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 311 | + getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), | |
| 312 | + getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), | |
| 313 | + listWorkflowRunsRequiringApproval: () => | |
| 314 | + Effect.die("same-repository pull requests must not probe fork workflow approvals"), | |
| 315 | + getRepositoryAccess: () => | |
| 316 | + Effect.succeed({ | |
| 317 | + canWrite: true, | |
| 318 | + mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 319 | + }), | |
| 320 | + getViewerAccess: () => | |
| 321 | + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), | |
| 322 | + }), | |
| 323 | + ), | |
| 324 | + ), | |
| 325 | +); | |
| 326 | + | |
| 327 | +it.effect("keeps an unsafe workflow approval scope visible as unknown", () => | |
| 328 | + Effect.gen(function* () { | |
| 329 | + const provider = yield* make; | |
| 330 | + const detail = yield* provider.getChangeRequest({ | |
| 331 | + cwd: "/w", | |
| 332 | + repository: "acme/web", | |
| 333 | + host: "github.com", | |
| 334 | + number: 7, | |
| 335 | + }); | |
| 336 | + | |
| 337 | + expect(detail.workflowApprovalsRequired).toBeUndefined(); | |
| 338 | + expect(detail.checks).toEqual([ | |
| 339 | + { | |
| 340 | + name: "Workflow approval status", | |
| 341 | + status: "action-required", | |
| 342 | + description: "GitHub could not determine whether workflows are awaiting approval.", | |
| 343 | + url: null, | |
| 344 | + }, | |
| 345 | + ]); | |
| 346 | + }).pipe( | |
| 347 | + Effect.provide( | |
| 348 | + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 349 | + getPullRequestDetail: () => Effect.succeed(openDetail), | |
| 350 | + getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), | |
| 351 | + listWorkflowRunsRequiringApproval: () => | |
| 352 | + Effect.fail( | |
| 353 | + new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({ | |
| 354 | + command: "gh", | |
| 355 | + cwd: "/w", | |
| 356 | + number: 7, | |
| 357 | + reason: "head-not-unique", | |
| 358 | + observedCount: 2, | |
| 359 | + limit: 1_000, | |
| 360 | + }), | |
| 361 | + ), | |
| 362 | + getRepositoryAccess: () => | |
| 363 | + Effect.succeed({ | |
| 364 | + canWrite: true, | |
| 365 | + mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 366 | + }), | |
| 367 | + getViewerAccess: () => | |
| 368 | + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), | |
| 369 | + }), | |
| 370 | + ), | |
| 371 | + ), | |
| 372 | +); | |
| 373 | + | |
| 374 | +it.effect("propagates workflow discovery rate limits", () => | |
| 375 | + Effect.gen(function* () { | |
| 376 | + const provider = yield* make; | |
| 377 | + const error = yield* provider | |
| 378 | + .getChangeRequest({ | |
| 379 | + cwd: "/w", | |
| 380 | + repository: "acme/web", | |
| 381 | + host: "github.com", | |
| 382 | + number: 7, | |
| 383 | + }) | |
| 384 | + .pipe(Effect.flip); | |
| 385 | + | |
| 386 | + expect(error.operation).toBe("getChangeRequest"); | |
| 387 | + expect(error.reason).toBe("rate-limited"); | |
| 388 | + }).pipe( | |
| 389 | + Effect.provide( | |
| 390 | + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ | |
| 391 | + getPullRequestDetail: () => Effect.succeed(openDetail), | |
| 392 | + getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }), | |
| 393 | + listWorkflowRunsRequiringApproval: () => | |
| 394 | + Effect.fail( | |
| 395 | + new GitHubCli.GitHubCliRateLimitError({ | |
| 396 | + command: "gh", | |
| 397 | + cwd: "/w", | |
| 398 | + cause: new Error("rate limited"), | |
| 399 | + }), | |
| 400 | + ), | |
| 401 | + getRepositoryAccess: () => | |
| 402 | + Effect.succeed({ | |
| 403 | + canWrite: true, | |
| 404 | + mergeCapabilities: { merge: true, squash: true, rebase: true }, | |
| 405 | + }), | |
| 406 | + getViewerAccess: () => | |
| 407 | + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), | |
| 408 | + }), | |
| 409 | + ), | |
| 410 | + ), | |
| 411 | +); | |
| 412 | + | |
| 413 | +describe("getViewerPermissions", () => { | |
| 188 | 414 | const layerWithComparison = ( |
| 189 | 415 | comparison: Effect.Effect<{ |
| 190 | 416 | readonly behindBy: number | null; |
| @@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines | ||
| 5 | 5 | import type { |
| 6 | 6 | PullRequestActor, |
| 7 | 7 | PullRequestCapabilities, |
| 8 | + PullRequestCheck, | |
| 8 | 9 | PullRequestReaction, |
| 9 | 10 | PullRequestViewerPermissions, |
| 10 | 11 | } from "@t3tools/contracts"; |
| @@ -17,7 +18,7 @@ import { · 6 unmodified lines | ||
| 17 | 18 | type ProviderChangeRequestDetail, |
| 18 | 19 | type PullRequestProviderApi, |
| 19 | 20 | } from "./PullRequestProvider.ts"; |
| 20 | −import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts"; | |
| 21 | +import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts"; | |
| 21 | 22 | |
| 22 | 23 | const CAPABILITIES: PullRequestCapabilities = { |
| 23 | 24 | diff: true, |
| @@ -31,6 +32,8 @@ const CAPABILITIES: PullRequestCapabilities = { · 7 unmodified lines | ||
| 31 | 32 | "update-branch", |
| 32 | 33 | "enable-auto-merge", |
| 33 | 34 | "disable-auto-merge", |
| 35 | + "revert", | |
| 36 | + "approve-workflows", | |
| 34 | 37 | ], |
| 35 | 38 | mergeMethods: ["merge", "squash", "rebase"], |
| 36 | 39 | updateMethods: ["merge", "rebase"], |
| @@ -68,7 +71,15 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest · 31 unmodified lines | ||
| 68 | 71 | actions: [ |
| 69 | 72 | // Arming a merge and taking the arming back are the merge, deferred: whoever may not |
| 70 | 73 | // 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 | + : []), | |
| 72 | 83 | ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), |
| 73 | 84 | // Whether this viewer may update the branch is GitHub's own answer, read with the |
| 74 | 85 | // comparison; without it the action is offered to nobody rather than to everybody. |
| @@ -117,6 +128,43 @@ function withAvatar( · 42 unmodified lines | ||
| 117 | 128 | return avatarUrl === null ? actor : { ...actor, avatarUrl }; |
| 118 | 129 | } |
| 119 | 130 | |
| 131 | +function withWorkflowApprovals( | |
| 132 | + checks: ReadonlyArray<PullRequestCheck>, | |
| 133 | + runs: ReadonlyArray<GitHubWorkflowRunApproval>, | |
| 134 | + unavailable: boolean, | |
| 135 | +): ReadonlyArray<PullRequestCheck> { | |
| 136 | + const representedRunIds = new Set<number>(); | |
| 137 | + for (const check of checks) { | |
| 138 | + if (check.status !== "action-required" || check.url === null) continue; | |
| 139 | + const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1]; | |
| 140 | + if (id !== undefined) representedRunIds.add(Number(id)); | |
| 141 | + } | |
| 142 | + const approvalChecks = runs | |
| 143 | + .filter((run) => !representedRunIds.has(run.id)) | |
| 144 | + .map( | |
| 145 | + (run): PullRequestCheck => ({ | |
| 146 | + name: run.name, | |
| 147 | + status: "action-required", | |
| 148 | + description: "A maintainer must approve this workflow before it can run.", | |
| 149 | + url: run.url, | |
| 150 | + }), | |
| 151 | + ); | |
| 152 | + return [ | |
| 153 | + ...checks, | |
| 154 | + ...approvalChecks, | |
| 155 | + ...(unavailable | |
| 156 | + ? [ | |
| 157 | + { | |
| 158 | + name: "Workflow approval status", | |
| 159 | + status: "action-required" as const, | |
| 160 | + description: "GitHub could not determine whether workflows are awaiting approval.", | |
| 161 | + url: null, | |
| 162 | + }, | |
| 163 | + ] | |
| 164 | + : []), | |
| 165 | + ]; | |
| 166 | +} | |
| 167 | + | |
| 120 | 168 | /** |
| 121 | 169 | * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which |
| 122 | 170 | * names no page, and a guessed URL that 404s is worse than the initials it would replace. |
| @@ -250,20 +298,54 @@ export const make = Effect.gen(function* () { · 127 unmodified lines | ||
| 250 | 298 | [ |
| 251 | 299 | cli.getPullRequestDetail(input).pipe( |
| 252 | 300 | 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 }))), | |
| 267 | 349 | ), |
| 268 | 350 | ), |
| 269 | 351 | getRepositoryAccess({ |
| @@ -281,6 +363,14 @@ export const make = Effect.gen(function* () { · 11 unmodified lines | ||
| 281 | 363 | Effect.map( |
| 282 | 364 | ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ |
| 283 | 365 | ...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 }), | |
| 284 | 374 | reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ |
| 285 | 375 | login, |
| 286 | 376 | name: null, |
| @@ -504,6 +504,10 @@ function actionArgs( · 503 unmodified lines | ||
| 504 | 504 | return ["rebase"]; |
| 505 | 505 | case "reopen": |
| 506 | 506 | 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`); | |
| 507 | 511 | } |
| 508 | 512 | } |
| 509 | 513 | |
| @@ -177,6 +177,10 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { · 176 unmodified lines | ||
| 177 | 177 | readonly behindBy?: number; |
| 178 | 178 | /** Absent from a host that does not report whether it is armed to merge this on its own. */ |
| 179 | 179 | 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; | |
| 180 | 184 | } |
| 181 | 185 | |
| 182 | 186 | /** The conversation-shaped half of a detail, loaded after the core can already render. */ |
| @@ -202,6 +202,9 @@ const ACTION_ACCESS_REFUSALS: Record<PullRequestAction, string> = { · 201 unmodified lines | ||
| 202 | 202 | "You need write access on this repository to have it merged for you once it is ready.", |
| 203 | 203 | "disable-auto-merge": |
| 204 | 204 | "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.", | |
| 205 | 208 | }; |
| 206 | 209 | |
| 207 | 210 | /** |
| @@ -1290,6 +1293,12 @@ export const make = Effect.gen(function* () { · 1082 unmodified lines | ||
| 1290 | 1293 | ...(changeRequest.autoMergeEnabled === undefined |
| 1291 | 1294 | ? {} |
| 1292 | 1295 | : { autoMergeEnabled: changeRequest.autoMergeEnabled }), |
| 1296 | + ...(changeRequest.autoMergeMethod === undefined | |
| 1297 | + ? {} | |
| 1298 | + : { autoMergeMethod: changeRequest.autoMergeMethod }), | |
| 1299 | + ...(changeRequest.workflowApprovalsRequired === undefined | |
| 1300 | + ? {} | |
| 1301 | + : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), | |
| 1293 | 1302 | }), |
| 1294 | 1303 | ), |
| 1295 | 1304 | ), |
| @@ -159,6 +159,34 @@ describe("decodePullRequestJson", () => { · 158 unmodified lines | ||
| 159 | 159 | ); |
| 160 | 160 | }); |
| 161 | 161 | |
| 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 | + | |
| 162 | 190 | it("works out where the conversation lives from what Azure returned", () => { |
| 163 | 191 | const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); |
| 164 | 192 | |
| @@ -5,6 +5,7 @@ import * as Schema from "effect/Schema"; · 4 unmodified lines | ||
| 5 | 5 | import type { |
| 6 | 6 | PullRequestActor, |
| 7 | 7 | PullRequestComment, |
| 8 | + PullRequestMergeMethod, | |
| 8 | 9 | PullRequestMergeability, |
| 9 | 10 | PullRequestState, |
| 10 | 11 | } from "@t3tools/contracts"; |
| @@ -41,6 +42,14 @@ const RawPullRequestSchema = Schema.Struct({ · 30 unmodified lines | ||
| 41 | 42 | * entirely once nobody has. So its presence is the answer, and there is no third state. |
| 42 | 43 | */ |
| 43 | 44 | 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 | + ), | |
| 44 | 53 | mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), |
| 45 | 54 | createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), |
| 46 | 55 | reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), |
| @@ -132,6 +141,8 @@ export interface AzureDevOpsPullRequest { · 85 unmodified lines | ||
| 132 | 141 | readonly threadsUrl: string | null; |
| 133 | 142 | /** Whether Azure is set to complete this on its own once its policies pass. */ |
| 134 | 143 | readonly autoMergeEnabled: boolean; |
| 144 | + /** The completion strategy Azure stored with auto-complete, where it reported one. */ | |
| 145 | + readonly autoMergeMethod?: PullRequestMergeMethod; | |
| 135 | 146 | } |
| 136 | 147 | |
| 137 | 148 | function trimmed(value: string | null | undefined): string | null { |
| @@ -188,6 +199,23 @@ function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): str · 50 unmodified lines | ||
| 188 | 199 | return `${base}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repository)}/pullRequests/${raw.pullRequestId}/threads`; |
| 189 | 200 | } |
| 190 | 201 | |
| 202 | +function toAutoMergeMethod( | |
| 203 | + raw: Schema.Schema.Type<typeof RawPullRequestSchema>, | |
| 204 | +): PullRequestMergeMethod | undefined { | |
| 205 | + if (raw.autoCompleteSetBy == null) return undefined; | |
| 206 | + switch (raw.completionOptions?.mergeStrategy?.trim().toLowerCase()) { | |
| 207 | + case "squash": | |
| 208 | + return "squash"; | |
| 209 | + case "rebase": | |
| 210 | + case "rebasemerge": | |
| 211 | + return "rebase"; | |
| 212 | + case "nofastforward": | |
| 213 | + return "merge"; | |
| 214 | + default: | |
| 215 | + return raw.completionOptions?.squashMerge === true ? "squash" : undefined; | |
| 216 | + } | |
| 217 | +} | |
| 218 | + | |
| 191 | 219 | /** |
| 192 | 220 | * Null when Azure said too little to place the pull request: a row with no browser url and no |
| 193 | 221 | * branch left after its prefix is dropped cannot be rendered or opened, and the wire contract |
| @@ -196,6 +224,7 @@ function toThreadsUrl(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): str · 2 unmodified lines | ||
| 196 | 224 | function toPullRequest( |
| 197 | 225 | raw: Schema.Schema.Type<typeof RawPullRequestSchema>, |
| 198 | 226 | ): AzureDevOpsPullRequest | null { |
| 227 | + const autoMergeMethod = toAutoMergeMethod(raw); | |
| 199 | 228 | const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => { |
| 200 | 229 | const actor = toActor(reviewer); |
| 201 | 230 | return actor === null ? [] : [actor]; |
| @@ -232,6 +261,7 @@ function toPullRequest( · 30 unmodified lines | ||
| 232 | 261 | reviewers, |
| 233 | 262 | threadsUrl: toThreadsUrl(raw), |
| 234 | 263 | autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, |
| 264 | + ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), | |
| 235 | 265 | }; |
| 236 | 266 | } |
| 237 | 267 | |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | decodeReviewThreadCommentsJson, |
| 17 | 17 | decodeReviewThreadsJson, |
| 18 | 18 | decodeViewerPermissionsJson, |
| 19 | + decodeWorkflowRunApprovalsJson, | |
| 19 | 20 | reviewThreadConversation, |
| 20 | 21 | REVIEW_THREADS_GRAPHQL_QUERY, |
| 21 | 22 | } from "./gitHubPullRequestJson.ts"; |
| @@ -223,18 +224,56 @@ describe("pull request detail decoding", () => { · 201 unmodified lines | ||
| 223 | 224 | ]); |
| 224 | 225 | }); |
| 225 | 226 | |
| 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", () => { | |
| 227 | 267 | const raw = JSON.parse(detailJson) as Record<string, unknown>; |
| 228 | 268 | const armed = (entry: Record<string, unknown>) => |
| 229 | − expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))) | |
| 230 | − .autoMergeEnabled; | |
| 269 | + expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))); | |
| 231 | 270 | |
| 232 | 271 | expect( |
| 233 | 272 | 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); | |
| 236 | 275 | // `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(); | |
| 238 | 277 | }); |
| 239 | 278 | |
| 240 | 279 | it("shows a re-running check once, as the run that is happening now", () => { |
| @@ -419,6 +458,36 @@ describe("review thread decoding", () => { · 178 unmodified lines | ||
| 419 | 458 | ]); |
| 420 | 459 | }); |
| 421 | 460 | |
| 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 | + | |
| 422 | 491 | it("decodes the newest commits off the same connection, oldest to newest", () => { |
| 423 | 492 | const result = expectSuccess( |
| 424 | 493 | decodeReviewThreadsJson( |
| @@ -11,6 +11,7 @@ import type { · 10 unmodified lines | ||
| 11 | 11 | PullRequestCommit, |
| 12 | 12 | PullRequestLabel, |
| 13 | 13 | PullRequestMergeCapabilities, |
| 14 | + PullRequestMergeMethod, | |
| 14 | 15 | PullRequestOmittedFileStat, |
| 15 | 16 | PullRequestMergeability, |
| 16 | 17 | PullRequestReaction, |
| @@ -359,17 +360,32 @@ const RawCommitSchema = Schema.Struct({ · 342 unmodified lines | ||
| 359 | 360 | |
| 360 | 361 | const RawDetailSchema = Schema.Struct({ |
| 361 | 362 | ...RawListItemSchema.fields, |
| 363 | + /** GitHub's explicit distinction between a fork head and a branch in the base repository. */ | |
| 364 | + isCrossRepository: Schema.optional(Schema.Boolean), | |
| 362 | 365 | /** Names the fork a pull request came from, which is what qualifies its head ref. */ |
| 363 | 366 | 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)), | |
| 364 | 369 | body: Schema.optional(Schema.String), |
| 365 | 370 | changedFiles: Schema.optional(Schema.Int), |
| 366 | 371 | closedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 367 | − /** | |
| 368 | − * The standing instruction to merge once GitHub's own requirements are met, which is an object | |
| 369 | − * describing who armed it and how, and a JSON null where nobody has. Nothing inside it is read: | |
| 370 | − * the question the page asks is whether one exists. | |
| 371 | − */ | |
| 372 | − autoMergeRequest: Schema.optional(Schema.NullOr(Schema.Unknown)), | |
| 372 | + /** The standing instruction and strategy GitHub will use once its requirements are met. */ | |
| 373 | + autoMergeRequest: Schema.optional( | |
| 374 | + Schema.NullOr(Schema.Struct({ mergeMethod: Schema.optional(Schema.NullOr(Schema.String)) })), | |
| 375 | + ), | |
| 376 | +}); | |
| 377 | + | |
| 378 | +const RawWorkflowRunApprovalSchema = Schema.Struct({ | |
| 379 | + databaseId: Schema.Int, | |
| 380 | + workflowName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 381 | + url: Schema.optional(Schema.NullOr(Schema.String)), | |
| 382 | +}); | |
| 383 | + | |
| 384 | +const RawPullRequestHeadSchema = Schema.Struct({ | |
| 385 | + number: Schema.Int, | |
| 386 | + headRefOid: Schema.String, | |
| 387 | + isCrossRepository: Schema.optional(Schema.Boolean), | |
| 388 | + headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), | |
| 373 | 389 | }); |
| 374 | 390 | |
| 375 | 391 | const RawActivitySchema = Schema.Struct({ |
| @@ -508,6 +524,9 @@ const RawReviewThreadsSchema = Schema.Struct({ · 132 unmodified lines | ||
| 508 | 524 | committedDate: Schema.optional(Schema.NullOr(Schema.String)), |
| 509 | 525 | additions: Schema.optional(Schema.Int), |
| 510 | 526 | deletions: Schema.optional(Schema.Int), |
| 527 | + parents: Schema.optional( | |
| 528 | + Schema.NullOr(Schema.Struct({ totalCount: Schema.optional(Schema.Int) })), | |
| 529 | + ), | |
| 511 | 530 | authors: Schema.optional( |
| 512 | 531 | Schema.NullOr( |
| 513 | 532 | Schema.Struct({ |
| @@ -599,7 +618,7 @@ export function decodeActorAvatarsJson( · 85 unmodified lines | ||
| 599 | 618 | export const PULL_REQUEST_LIST_JSON_FIELDS = |
| 600 | 619 | "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; |
| 601 | 620 | |
| 602 | −export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest`; | |
| 621 | +export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; | |
| 603 | 622 | export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; |
| 604 | 623 | |
| 605 | 624 | /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ |
| @@ -731,6 +750,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin · 125 unmodified lines | ||
| 731 | 750 | committedDate |
| 732 | 751 | additions |
| 733 | 752 | deletions |
| 753 | + parents(first: 1) { totalCount } | |
| 734 | 754 | authors(first: 3) { nodes { name avatarUrl user { login } } } |
| 735 | 755 | } |
| 736 | 756 | } |
| @@ -879,6 +899,13 @@ export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID · 142 unmodified lines | ||
| 879 | 899 | } |
| 880 | 900 | }`; |
| 881 | 901 | |
| 902 | +/** Creates a new pull request that reverses a merged pull request. */ | |
| 903 | +export const REVERT_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!) { | |
| 904 | + revertPullRequest(input: { pullRequestId: $pullRequestId }) { | |
| 905 | + revertPullRequest { id } | |
| 906 | + } | |
| 907 | +}`; | |
| 908 | + | |
| 882 | 909 | /** |
| 883 | 910 | * The two comment mutations name their comment differently. The variable is spelled the same in |
| 884 | 911 | * both, so a rewrite sends one set of variables whichever kind of remark it is. |
| @@ -1016,8 +1043,11 @@ export interface GitHubPullRequestListItem { · 131 unmodified lines | ||
| 1016 | 1043 | } |
| 1017 | 1044 | |
| 1018 | 1045 | export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { |
| 1046 | + /** True only when GitHub says the head belongs to another repository. */ | |
| 1047 | + readonly isCrossRepository?: boolean; | |
| 1019 | 1048 | /** The owner of the head branch's repository; null where `gh` did not say. */ |
| 1020 | 1049 | readonly headRepositoryOwner: string | null; |
| 1050 | + readonly headSha?: string | null; | |
| 1021 | 1051 | readonly body: string; |
| 1022 | 1052 | readonly changedFiles: number; |
| 1023 | 1053 | readonly mergedAt: string | null; |
| @@ -1025,6 +1055,21 @@ export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { · 1 unmodified line | ||
| 1025 | 1055 | readonly checks: ReadonlyArray<PullRequestCheck>; |
| 1026 | 1056 | /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ |
| 1027 | 1057 | readonly autoMergeEnabled?: boolean; |
| 1058 | + /** Absent where auto-merge is off or GitHub did not report the stored strategy. */ | |
| 1059 | + readonly autoMergeMethod?: PullRequestMergeMethod; | |
| 1060 | +} | |
| 1061 | + | |
| 1062 | +export interface GitHubWorkflowRunApproval { | |
| 1063 | + readonly id: number; | |
| 1064 | + readonly name: string; | |
| 1065 | + readonly url: string | null; | |
| 1066 | +} | |
| 1067 | + | |
| 1068 | +export interface GitHubPullRequestHead { | |
| 1069 | + readonly number: number; | |
| 1070 | + readonly headSha: string; | |
| 1071 | + readonly isCrossRepository?: boolean; | |
| 1072 | + readonly headRepositoryOwner: string | null; | |
| 1028 | 1073 | } |
| 1029 | 1074 | |
| 1030 | 1075 | export interface GitHubPullRequestActivity { |
| @@ -1114,6 +1159,19 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili · 83 unmodified lines | ||
| 1114 | 1159 | } |
| 1115 | 1160 | } |
| 1116 | 1161 | |
| 1162 | +function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod | undefined { | |
| 1163 | + switch (value?.trim().toUpperCase()) { | |
| 1164 | + case "MERGE": | |
| 1165 | + return "merge"; | |
| 1166 | + case "SQUASH": | |
| 1167 | + return "squash"; | |
| 1168 | + case "REBASE": | |
| 1169 | + return "rebase"; | |
| 1170 | + default: | |
| 1171 | + return undefined; | |
| 1172 | + } | |
| 1173 | +} | |
| 1174 | + | |
| 1117 | 1175 | function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { |
| 1118 | 1176 | switch (value?.trim().toUpperCase()) { |
| 1119 | 1177 | case "APPROVED": |
| @@ -1169,12 +1227,12 @@ function toCheckStatus(raw: Schema.Schema.Type<typeof RawCheckSchema>): PullRequ · 49 unmodified lines | ||
| 1169 | 1227 | switch ((raw.conclusion ?? raw.state)?.trim().toUpperCase()) { |
| 1170 | 1228 | case "SUCCESS": |
| 1171 | 1229 | return "success"; |
| 1230 | + case "ACTION_REQUIRED": | |
| 1231 | + return "action-required"; | |
| 1172 | 1232 | case "FAILURE": |
| 1173 | 1233 | case "ERROR": |
| 1174 | 1234 | case "TIMED_OUT": |
| 1175 | 1235 | case "STARTUP_FAILURE": |
| 1176 | − // A completed check asking for manual intervention is blocking, not neutral. | |
| 1177 | − case "ACTION_REQUIRED": | |
| 1178 | 1236 | return "failure"; |
| 1179 | 1237 | case "CANCELLED": |
| 1180 | 1238 | return "cancelled"; |
| @@ -1253,7 +1311,7 @@ function rollupChecksState( · 72 unmodified lines | ||
| 1253 | 1311 | ]; |
| 1254 | 1312 | if (statuses.length === 0) return null; |
| 1255 | 1313 | if (statuses.includes("failure")) return "failing"; |
| 1256 | − if (statuses.includes("pending")) return "pending"; | |
| 1314 | + if (statuses.includes("pending") || statuses.includes("action-required")) return "pending"; | |
| 1257 | 1315 | return statuses.includes("success") ? "passing" : null; |
| 1258 | 1316 | } |
| 1259 | 1317 | |
| @@ -1361,9 +1419,14 @@ function toListItem(raw: Schema.Schema.Type<typeof RawListItemSchema>): GitHubPu · 101 unmodified lines | ||
| 1361 | 1419 | } |
| 1362 | 1420 | |
| 1363 | 1421 | function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRequestDetail { |
| 1422 | + const autoMergeMethod = toMergeMethod(raw.autoMergeRequest?.mergeMethod); | |
| 1364 | 1423 | return { |
| 1365 | 1424 | ...toListItem(raw), |
| 1425 | + ...(typeof raw.isCrossRepository === "boolean" | |
| 1426 | + ? { isCrossRepository: raw.isCrossRepository } | |
| 1427 | + : {}), | |
| 1366 | 1428 | headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login), |
| 1429 | + headSha: trimmed(raw.headRefOid), | |
| 1367 | 1430 | body: raw.body ?? "", |
| 1368 | 1431 | changedFiles: raw.changedFiles ?? 0, |
| 1369 | 1432 | mergedAt: trimmed(raw.mergedAt), |
| @@ -1374,6 +1437,7 @@ function toDetail(raw: Schema.Schema.Type<typeof RawDetailSchema>): GitHubPullRe · 4 unmodified lines | ||
| 1374 | 1437 | ...(raw.autoMergeRequest === undefined |
| 1375 | 1438 | ? {} |
| 1376 | 1439 | : { autoMergeEnabled: raw.autoMergeRequest !== null }), |
| 1440 | + ...(autoMergeMethod === undefined ? {} : { autoMergeMethod }), | |
| 1377 | 1441 | }; |
| 1378 | 1442 | } |
| 1379 | 1443 | |
| @@ -1391,6 +1455,8 @@ const decodeSearch = decodeJsonResult(RawSearchSchema); · 11 unmodified lines | ||
| 1391 | 1455 | const decodeSearchItem = Schema.decodeUnknownExit(RawSearchItemSchema); |
| 1392 | 1456 | const decodeStats = decodeJsonResult(RawStatsSchema); |
| 1393 | 1457 | const decodeDetail = decodeJsonResult(RawDetailSchema); |
| 1458 | +const decodeWorkflowRunApprovals = decodeJsonResult(Schema.Array(RawWorkflowRunApprovalSchema)); | |
| 1459 | +const decodePullRequestHeads = decodeJsonResult(Schema.Array(RawPullRequestHeadSchema)); | |
| 1394 | 1460 | const decodeActivity = decodeJsonResult(RawActivitySchema); |
| 1395 | 1461 | const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); |
| 1396 | 1462 | const decodeRepositoryAccess = decodeJsonResult(RawRepositoryAccessSchema); |
| @@ -1551,6 +1617,37 @@ export function decodePullRequestDetailJson( · 154 unmodified lines | ||
| 1551 | 1617 | : Result.fail(decoded.failure); |
| 1552 | 1618 | } |
| 1553 | 1619 | |
| 1620 | +export function decodeWorkflowRunApprovalsJson( | |
| 1621 | + raw: string, | |
| 1622 | +): Result.Result<ReadonlyArray<GitHubWorkflowRunApproval>, DecodeFailure> { | |
| 1623 | + const decoded = decodeWorkflowRunApprovals(raw); | |
| 1624 | + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 1625 | + return Result.succeed( | |
| 1626 | + decoded.success.map((run) => ({ | |
| 1627 | + id: run.databaseId, | |
| 1628 | + name: trimmed(run.workflowName) ?? `Workflow run ${run.databaseId}`, | |
| 1629 | + url: trimmed(run.url), | |
| 1630 | + })), | |
| 1631 | + ); | |
| 1632 | +} | |
| 1633 | + | |
| 1634 | +export function decodePullRequestHeadsJson( | |
| 1635 | + raw: string, | |
| 1636 | +): Result.Result<ReadonlyArray<GitHubPullRequestHead>, DecodeFailure> { | |
| 1637 | + const decoded = decodePullRequestHeads(raw); | |
| 1638 | + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | |
| 1639 | + return Result.succeed( | |
| 1640 | + decoded.success.map((pullRequest) => ({ | |
| 1641 | + number: pullRequest.number, | |
| 1642 | + headSha: pullRequest.headRefOid, | |
| 1643 | + ...(typeof pullRequest.isCrossRepository === "boolean" | |
| 1644 | + ? { isCrossRepository: pullRequest.isCrossRepository } | |
| 1645 | + : {}), | |
| 1646 | + headRepositoryOwner: trimmed(pullRequest.headRepositoryOwner?.login), | |
| 1647 | + })), | |
| 1648 | + ); | |
| 1649 | +} | |
| 1650 | + | |
| 1554 | 1651 | export function decodePullRequestActivityJson( |
| 1555 | 1652 | raw: string, |
| 1556 | 1653 | ): Result.Result<GitHubPullRequestActivity, DecodeFailure> { |
| @@ -1788,7 +1885,14 @@ export function decodeReviewThreadsJson( · 231 unmodified lines | ||
| 1788 | 1885 | const commit = node.commit; |
| 1789 | 1886 | const oid = trimmed(commit.oid); |
| 1790 | 1887 | 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 | + ) { | |
| 1792 | 1896 | commitStats.set(oid, { |
| 1793 | 1897 | additions: Math.max(0, commit.additions), |
| 1794 | 1898 | deletions: Math.max(0, commit.deletions), |
| @@ -192,6 +192,28 @@ describe("decodeMergeRequestDetailJson", () => { · 191 unmodified lines | ||
| 192 | 192 | expect(armed({})).toBeUndefined(); |
| 193 | 193 | }); |
| 194 | 194 | |
| 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 | + | |
| 195 | 217 | it("keeps a divergence GitLab did not count apart from a divergence of none", () => { |
| 196 | 218 | const behind = (entry: Record<string, unknown>) => |
| 197 | 219 | expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; |
| @@ -11,6 +11,7 @@ import type { · 10 unmodified lines | ||
| 11 | 11 | PullRequestLabel, |
| 12 | 12 | PullRequestMergeability, |
| 13 | 13 | PullRequestMergeCapabilities, |
| 14 | + PullRequestMergeMethod, | |
| 14 | 15 | PullRequestReaction, |
| 15 | 16 | PullRequestReactionContent, |
| 16 | 17 | PullRequestReviewThread, |
| @@ -80,6 +81,9 @@ const RawMergeRequestSchema = Schema.Struct({ · 63 unmodified lines | ||
| 80 | 81 | */ |
| 81 | 82 | merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 82 | 83 | 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)), | |
| 83 | 87 | /** |
| 84 | 88 | * How far the target branch has moved on since this one left it, which is the same number |
| 85 | 89 | * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab |
| @@ -228,6 +232,8 @@ export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { · 142 unmodified lines | ||
| 228 | 232 | readonly reviewerIds: ReadonlyArray<number>; |
| 229 | 233 | /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ |
| 230 | 234 | readonly autoMergeEnabled?: boolean; |
| 235 | + /** GitLab only exposes the stored strategy separately when that strategy is squash. */ | |
| 236 | + readonly autoMergeMethod?: PullRequestMergeMethod; | |
| 231 | 237 | /** |
| 232 | 238 | * Absent where GitLab did not count, which is not the same as a branch that has nothing behind |
| 233 | 239 | * it: an install too old to answer must not be read as saying the branch is current. |
| @@ -379,6 +385,9 @@ function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLab · 145 unmodified lines | ||
| 379 | 385 | reviewer.id === undefined ? [] : [reviewer.id], |
| 380 | 386 | ), |
| 381 | 387 | ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), |
| 388 | + ...(autoMerge === true && raw.squash_on_merge === true | |
| 389 | + ? { autoMergeMethod: "squash" as const } | |
| 390 | + : {}), | |
| 382 | 391 | ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), |
| 383 | 392 | }; |
| 384 | 393 | } |
| @@ -65,7 +65,7 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) { · 64 unmodified lines | ||
| 65 | 65 | <TooltipPopup side="top">{check.description ?? check.name}</TooltipPopup> |
| 66 | 66 | </Tooltip> |
| 67 | 67 | <span className="shrink-0 text-muted-foreground"> |
| 68 | − {pullRequestCheckStatusLabel(check.status)} | |
| 68 | + {pullRequestCheckStatusLabel(check)} | |
| 69 | 69 | </span> |
| 70 | 70 | {check.url === null ? null : ( |
| 71 | 71 | <button |
| @@ -112,6 +112,7 @@ export function PullRequestChecksPopover({ · 40 unmodified lines | ||
| 112 | 112 | not valid inside one. The click is stopped here so opening the checks does not also |
| 113 | 113 | select the row it sits on. */} |
| 114 | 114 | <PopoverTrigger |
| 115 | + nativeButton={false} | |
| 115 | 116 | render={ |
| 116 | 117 | <span |
| 117 | 118 | role="button" |
| @@ -33,7 +33,9 @@ import { · 32 unmodified lines | ||
| 33 | 33 | MoreHorizontalIcon, |
| 34 | 34 | PanelRightIcon, |
| 35 | 35 | PencilIcon, |
| 36 | + PlayIcon, | |
| 36 | 37 | RefreshCwIcon, |
| 38 | + RotateCcwIcon, | |
| 37 | 39 | ServerIcon, |
| 38 | 40 | TriangleAlertIcon, |
| 39 | 41 | } from "lucide-react"; |
| @@ -118,6 +120,7 @@ import { · 78 unmodified lines | ||
| 118 | 120 | pullRequestFindingKey, |
| 119 | 121 | pullRequestHandoffLabels, |
| 120 | 122 | readableFailure, |
| 123 | + resolvePullRequestPrimaryControl, | |
| 121 | 124 | resolveBaseFreshness, |
| 122 | 125 | type PullRequestFinding, |
| 123 | 126 | shouldRefreshPullRequestActivity, |
| @@ -154,6 +157,8 @@ const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = { · 30 unmodified lines | ||
| 154 | 157 | "enable-auto-merge": |
| 155 | 158 | "Auto-merge turned on — merges as soon as this is ready, sooner if it already is", |
| 156 | 159 | "disable-auto-merge": "Auto-merge turned off", |
| 160 | + revert: "Revert pull request opened", | |
| 161 | + "approve-workflows": "Workflows approved", | |
| 157 | 162 | }; |
| 158 | 163 | |
| 159 | 164 | const MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, string> = { |
| @@ -172,6 +177,8 @@ const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = { · 12 unmodified lines | ||
| 172 | 177 | "update-branch": "Could not update this branch", |
| 173 | 178 | "enable-auto-merge": "Could not turn on auto-merge", |
| 174 | 179 | "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", | |
| 175 | 182 | }; |
| 176 | 183 | |
| 177 | 184 | /** What to try, for the times the host says only that it refused. */ |
| @@ -193,6 +200,10 @@ const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = { · 15 unmodified lines | ||
| 193 | 200 | "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.", |
| 194 | 201 | "disable-auto-merge": |
| 195 | 202 | "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.", | |
| 196 | 207 | }; |
| 197 | 208 | |
| 198 | 209 | /** |
| @@ -518,10 +529,18 @@ export function PullRequestDetailPanel({ · 319 unmodified lines | ||
| 518 | 529 | compensationRef.current = null; |
| 519 | 530 | if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); |
| 520 | 531 | }, [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 | + }; | |
| 522 | 541 | const [confirmation, setConfirmation] = useState<{ |
| 523 | 542 | readonly open: boolean; |
| 524 | − readonly action: "merge" | "close" | "enable-auto-merge"; | |
| 543 | + readonly action: "merge" | "close" | "enable-auto-merge" | "revert" | "approve-workflows"; | |
| 525 | 544 | }>({ open: false, action: "merge" }); |
| 526 | 545 | const confirmAction = confirmation.action; |
| 527 | 546 | // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself |
| @@ -558,6 +577,9 @@ export function PullRequestDetailPanel({ · 30 unmodified lines | ||
| 558 | 577 | }, |
| 559 | 578 | [activity, coreDetail], |
| 560 | 579 | ); |
| 580 | + useEffect(() => { | |
| 581 | + if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); | |
| 582 | + }, [detail?.autoMergeMethod, pullRequestKey]); | |
| 561 | 583 | const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); |
| 562 | 584 | const checkoutCommand = detail |
| 563 | 585 | ? pullRequestCheckoutCommand( |
| @@ -636,6 +658,7 @@ export function PullRequestDetailPanel({ · 72 unmodified lines | ||
| 636 | 658 | void refreshFromHost(); |
| 637 | 659 | }, [forcedRefreshToken, refreshFromHost]); |
| 638 | 660 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); |
| 661 | + const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); | |
| 639 | 662 | // Which action is in flight, not merely that one is: every control here is disabled while any |
| 640 | 663 | // of them runs, but only the button that was pressed may say what it is doing. |
| 641 | 664 | const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null); |
| @@ -689,13 +712,11 @@ export function PullRequestDetailPanel({ · 47 unmodified lines | ||
| 689 | 712 | cwd: acting?.workspaceRoot ?? detail?.workspaceRoot ?? null, |
| 690 | 713 | }); |
| 691 | 714 | |
| 692 | − const perform = async ( | |
| 715 | + const finishAction = async ( | |
| 693 | 716 | action: PullRequestAction, |
| 694 | 717 | method?: PullRequestMergeMethod, |
| 695 | 718 | updateMethod?: PullRequestUpdateMethod, |
| 696 | 719 | ) => { |
| 697 | − if (pendingAction !== null) return; | |
| 698 | − setPendingAction(action); | |
| 699 | 720 | const result = await runAction({ |
| 700 | 721 | environmentId, |
| 701 | 722 | input: { |
| @@ -723,7 +744,7 @@ export function PullRequestDetailPanel({ · 21 unmodified lines | ||
| 723 | 744 | title: ACTION_FAILURE_LABELS[action], |
| 724 | 745 | description: readableFailure(failure, hint), |
| 725 | 746 | }); |
| 726 | − return; | |
| 747 | + return false; | |
| 727 | 748 | } |
| 728 | 749 | toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); |
| 729 | 750 | // A branch update moves the head commit, which leaves the diff atom pointed at a comparison |
| @@ -737,6 +758,36 @@ export function PullRequestDetailPanel({ · 7 unmodified lines | ||
| 737 | 758 | refreshDetail(); |
| 738 | 759 | } |
| 739 | 760 | 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 }; | |
| 740 | 791 | }; |
| 741 | 792 | |
| 742 | 793 | const saveTitle = async (next: string) => { |
| @@ -1094,10 +1145,17 @@ export function PullRequestDetailPanel({ · 351 unmodified lines | ||
| 1094 | 1145 | ? mergeMethod |
| 1095 | 1146 | : (allowedMergeMethods[0] ?? "merge"); |
| 1096 | 1147 | const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod]; |
| 1148 | + const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`; | |
| 1097 | 1149 | const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; |
| 1098 | 1150 | // Only an outright yes arms it. A host that reports nothing has not said the merge is already |
| 1099 | 1151 | // spoken for, and an off switch for something that may not be on says the wrong thing twice. |
| 1100 | 1152 | 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; | |
| 1101 | 1159 | // Out of date with the base, and still cleanly mergeable — the one pairing an update button |
| 1102 | 1160 | // exists for. Null everywhere else, including hosts that cannot compare at all. |
| 1103 | 1161 | const freshness = detail === null ? null : resolveBaseFreshness(detail); |
| @@ -1118,20 +1176,22 @@ export function PullRequestDetailPanel({ · 14 unmodified lines | ||
| 1118 | 1176 | const can = (action: PullRequestAction) => |
| 1119 | 1177 | detail?.capabilities.actions.includes(action) === true && |
| 1120 | 1178 | 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; | |
| 1135 | 1195 | // What the menu's action group holds. Named once so the separators around it are drawn from |
| 1136 | 1196 | // the same answer as its contents, rather than on the assumption that it has any. |
| 1137 | 1197 | const showsDraftToggle = |
| @@ -1142,10 +1202,18 @@ export function PullRequestDetailPanel({ · 4 unmodified lines | ||
| 1142 | 1202 | detail?.state === "open" && |
| 1143 | 1203 | ((autoMergeArmed && can("disable-auto-merge")) || |
| 1144 | 1204 | (!autoMergeArmed && |
| 1205 | + primaryAction !== "enable-auto-merge" && | |
| 1145 | 1206 | !detail.isDraft && |
| 1146 | 1207 | !conflicting && |
| 1147 | 1208 | can("enable-auto-merge") && |
| 1148 | 1209 | 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; | |
| 1149 | 1217 | const showsMergeMethods = |
| 1150 | 1218 | detail?.state === "open" && |
| 1151 | 1219 | can("merge") && |
| @@ -1158,7 +1226,6 @@ export function PullRequestDetailPanel({ · 6 unmodified lines | ||
| 1158 | 1226 | ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) |
| 1159 | 1227 | : null; |
| 1160 | 1228 | const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; |
| 1161 | − const checksState = detail ? pullRequestChecksState(detail.checks) : null; | |
| 1162 | 1229 | // Approvals that still stand, and only those. A superseded one is dimmed beside the reviewer |
| 1163 | 1230 | // who gave it, so counting it here would have the header assert in a number what the row next |
| 1164 | 1231 | // to it has just qualified. |
| @@ -1335,18 +1402,28 @@ export function PullRequestDetailPanel({ · 170 unmodified lines | ||
| 1335 | 1402 | </MenuPopup> |
| 1336 | 1403 | </Menu> |
| 1337 | 1404 | ) : 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} | |
| 1338 | 1418 | {/* Said where the Merge button is, because it is the answer to why nobody has |
| 1339 | 1419 | pressed it: the merge is already asked for, and the host is holding it. */} |
| 1340 | − {autoMergeArmed ? ( | |
| 1420 | + {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( | |
| 1341 | 1421 | <Tooltip> |
| 1342 | 1422 | <TooltipTrigger |
| 1343 | 1423 | 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} | |
| 1350 | 1427 | </Badge> |
| 1351 | 1428 | } |
| 1352 | 1429 | /> |
| @@ -1369,6 +1446,29 @@ export function PullRequestDetailPanel({ · 16 unmodified lines | ||
| 1369 | 1446 | <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}> |
| 1370 | 1447 | Ready for review |
| 1371 | 1448 | </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> | |
| 1372 | 1472 | ) : primaryAction === "merge" ? ( |
| 1373 | 1473 | <Button |
| 1374 | 1474 | size="xs" |
| @@ -1377,6 +1477,12 @@ export function PullRequestDetailPanel({ · 2 unmodified lines | ||
| 1377 | 1477 | > |
| 1378 | 1478 | {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} |
| 1379 | 1479 | </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> | |
| 1380 | 1486 | ) : null} |
| 1381 | 1487 | <Menu> |
| 1382 | 1488 | <MenuTrigger |
| @@ -1447,11 +1553,18 @@ export function PullRequestDetailPanel({ · 64 unmodified lines | ||
| 1447 | 1553 | {detail.isDraft ? "Ready for review" : "Convert to draft"} |
| 1448 | 1554 | </MenuItem> |
| 1449 | 1555 | ) : 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. */} | |
| 1455 | 1568 | {autoMergeArmed && can("disable-auto-merge") ? ( |
| 1456 | 1569 | <MenuItem |
| 1457 | 1570 | disabled={actionPending} |
| @@ -1460,11 +1573,7 @@ export function PullRequestDetailPanel({ · 2 unmodified lines | ||
| 1460 | 1573 | <GitMergeIcon className="size-3.5" /> |
| 1461 | 1574 | Disable auto-merge |
| 1462 | 1575 | </MenuItem> |
| 1463 | − ) : !autoMergeArmed && | |
| 1464 | − !detail.isDraft && | |
| 1465 | − !conflicting && | |
| 1466 | − can("enable-auto-merge") && | |
| 1467 | − allowedMergeMethods.length > 0 ? ( | |
| 1576 | + ) : showsAutoMerge ? ( | |
| 1468 | 1577 | <MenuItem |
| 1469 | 1578 | disabled={actionPending} |
| 1470 | 1579 | onClick={() => |
| @@ -1485,7 +1594,9 @@ export function PullRequestDetailPanel({ · 14 unmodified lines | ||
| 1485 | 1594 | {/* Only below the draft control. A host with no draft of its own, or |
| 1486 | 1595 | a draft whose control is already the header button, would leave |
| 1487 | 1596 | this against the separator that opened the group. */} |
| 1488 | − {showsDraftToggle ? <MenuSeparator /> : null} | |
| 1597 | + {showsDraftToggle || showsMergeNow || showsAutoMerge ? ( | |
| 1598 | + <MenuSeparator /> | |
| 1599 | + ) : null} | |
| 1489 | 1600 | <MenuRadioGroup |
| 1490 | 1601 | value={selectedMergeMethod} |
| 1491 | 1602 | onValueChange={(method) => |
| @@ -1507,7 +1618,7 @@ export function PullRequestDetailPanel({ · 15 unmodified lines | ||
| 1507 | 1618 | ) : null} |
| 1508 | 1619 | {pullRequestActionMenuHasGroup( |
| 1509 | 1620 | showsDraftToggle, |
| 1510 | − showsAutoMerge, | |
| 1621 | + showsAutoMerge || showsMergeNow, | |
| 1511 | 1622 | showsMergeMethods, |
| 1512 | 1623 | ) ? ( |
| 1513 | 1624 | <MenuSeparator /> |
| @@ -1542,6 +1653,17 @@ export function PullRequestDetailPanel({ · 28 unmodified lines | ||
| 1542 | 1653 | Reopen pull request |
| 1543 | 1654 | </MenuItem> |
| 1544 | 1655 | </> |
| 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 | + </> | |
| 1545 | 1667 | ) : null} |
| 1546 | 1668 | </MenuPopup> |
| 1547 | 1669 | </Menu> |
| @@ -1987,6 +2109,8 @@ export function PullRequestDetailPanel({ · 439 unmodified lines | ||
| 1987 | 2109 | fixFindingLabel={handoffLabels.fixFinding} |
| 1988 | 2110 | fixCheckLabel={handoffLabels.fixCheck} |
| 1989 | 2111 | onFixFinding={startFixFinding} |
| 2112 | + actionPending={actionPending} | |
| 2113 | + onCommentAction={performCommentAction} | |
| 1990 | 2114 | onRefresh={refreshDetail} |
| 1991 | 2115 | /> |
| 1992 | 2116 | </div> |
| @@ -2049,7 +2173,11 @@ export function PullRequestDetailPanel({ · 56 unmodified lines | ||
| 2049 | 2173 | ? "Merge pull request?" |
| 2050 | 2174 | : confirmAction === "enable-auto-merge" |
| 2051 | 2175 | ? "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?"} | |
| 2053 | 2181 | </AlertDialogTitle> |
| 2054 | 2182 | <AlertDialogDescription> |
| 2055 | 2183 | {confirmAction === "merge" |
| @@ -2059,7 +2187,11 @@ export function PullRequestDetailPanel({ · 3 unmodified lines | ||
| 2059 | 2187 | // may be immediately — there is no telling from here whether anything is |
| 2060 | 2188 | // still outstanding. |
| 2061 | 2189 | `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.`} | |
| 2063 | 2195 | </AlertDialogDescription> |
| 2064 | 2196 | </AlertDialogHeader> |
| 2065 | 2197 | <AlertDialogFooter> |
| @@ -2076,6 +2208,8 @@ export function PullRequestDetailPanel({ · 10 unmodified lines | ||
| 2076 | 2208 | if (action === "merge") void perform("merge", selectedMergeMethod); |
| 2077 | 2209 | if (action === "enable-auto-merge") |
| 2078 | 2210 | void perform("enable-auto-merge", selectedMergeMethod); |
| 2211 | + if (action === "revert") void perform("revert"); | |
| 2212 | + if (action === "approve-workflows") void perform("approve-workflows"); | |
| 2079 | 2213 | if (action === "close") void perform("close"); |
| 2080 | 2214 | }} |
| 2081 | 2215 | > |
| @@ -2083,7 +2217,11 @@ export function PullRequestDetailPanel({ · 1 unmodified line | ||
| 2083 | 2217 | ? selectedMergeMethodLabel |
| 2084 | 2218 | : confirmAction === "enable-auto-merge" |
| 2085 | 2219 | ? "Enable auto-merge" |
| 2086 | − : "Close"} | |
| 2220 | + : confirmAction === "revert" | |
| 2221 | + ? "Create revert PR" | |
| 2222 | + : confirmAction === "approve-workflows" | |
| 2223 | + ? "Approve and run" | |
| 2224 | + : "Close"} | |
| 2087 | 2225 | </Button> |
| 2088 | 2226 | </AlertDialogFooter> |
| 2089 | 2227 | </AlertDialogPopup> |
| @@ -524,7 +524,7 @@ export function PullRequestFiltersMenu({ · 523 unmodified lines | ||
| 524 | 524 | <ListFilterIcon className="size-4" /> |
| 525 | 525 | <span>Filters</span> |
| 526 | 526 | {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"> | |
| 528 | 528 | {filterCount} |
| 529 | 529 | </span> |
| 530 | 530 | ) : null} |
| @@ -9,9 +9,11 @@ import { · 8 unmodified lines | ||
| 9 | 9 | ArrowDownUpIcon, |
| 10 | 10 | ChevronDownIcon, |
| 11 | 11 | ChevronRightIcon, |
| 12 | + GitPullRequestClosedIcon, | |
| 12 | 13 | HammerIcon, |
| 13 | 14 | MessageSquareIcon, |
| 14 | 15 | PencilIcon, |
| 16 | + RotateCcwIcon, | |
| 15 | 17 | SendIcon, |
| 16 | 18 | TagIcon, |
| 17 | 19 | UsersIcon, |
| @@ -308,20 +310,43 @@ function Section({ · 290 unmodified lines | ||
| 308 | 310 | function CommentComposer({ |
| 309 | 311 | environmentId, |
| 310 | 312 | detail, |
| 313 | + actionPending, | |
| 314 | + onCommentAction, | |
| 311 | 315 | onCommented, |
| 312 | 316 | }: { |
| 313 | 317 | environmentId: EnvironmentId; |
| 314 | 318 | detail: PullRequestDetailView; |
| 319 | + actionPending: boolean; | |
| 320 | + onCommentAction: ( | |
| 321 | + body: string, | |
| 322 | + action: "close" | "reopen", | |
| 323 | + ) => Promise<{ readonly commentPosted: boolean }>; | |
| 315 | 324 | onCommented: () => void; |
| 316 | 325 | }) { |
| 317 | 326 | const [body, setBody] = useState(""); |
| 318 | − const [posting, setPosting] = useState(false); | |
| 327 | + const [submitting, setSubmitting] = useState<"comment" | "close" | "reopen" | null>(null); | |
| 319 | 328 | 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; | |
| 320 | 339 | |
| 321 | − const submit = async () => { | |
| 340 | + const submit = async (action: "comment" | "close" | "reopen") => { | |
| 322 | 341 | 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 | + } | |
| 325 | 350 | const result = await postComment({ |
| 326 | 351 | environmentId, |
| 327 | 352 | input: { |
| @@ -331,12 +356,13 @@ function CommentComposer({ · 3 unmodified lines | ||
| 331 | 356 | body: trimmed, |
| 332 | 357 | }, |
| 333 | 358 | }); |
| 334 | − setPosting(false); | |
| 335 | 359 | if (result._tag === "Failure") { |
| 360 | + setSubmitting(null); | |
| 336 | 361 | toastManager.add({ type: "error", title: "Could not post the comment" }); |
| 337 | 362 | return; |
| 338 | 363 | } |
| 339 | 364 | setBody(""); |
| 365 | + setSubmitting(null); | |
| 340 | 366 | onCommented(); |
| 341 | 367 | }; |
| 342 | 368 | |
| @@ -345,22 +371,43 @@ function CommentComposer({ · 2 unmodified lines | ||
| 345 | 371 | <Textarea |
| 346 | 372 | // Locked while posting: the body is cleared on success, which would otherwise throw |
| 347 | 373 | // away a new draft typed while the request was still in flight. |
| 348 | − disabled={posting} | |
| 374 | + disabled={submitting !== null || actionPending} | |
| 349 | 375 | value={body} |
| 350 | 376 | rows={3} |
| 351 | 377 | placeholder="Leave a comment" |
| 352 | 378 | aria-label="Comment on this pull request" |
| 353 | 379 | onChange={(event) => setBody(event.target.value)} |
| 354 | 380 | /> |
| 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 | + )} | |
| 356 | 403 | <Button |
| 357 | 404 | size="xs" |
| 358 | 405 | 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")} | |
| 361 | 408 | > |
| 362 | 409 | <SendIcon className="size-3.5" /> |
| 363 | − {posting ? "Posting..." : "Comment"} | |
| 410 | + {submitting === "comment" ? "Posting..." : "Comment"} | |
| 364 | 411 | </Button> |
| 365 | 412 | </div> |
| 366 | 413 | </div> |
| @@ -383,6 +430,8 @@ export function PullRequestSummaryTab({ · 16 unmodified lines | ||
| 383 | 430 | fixFindingLabel = "Fix in a thread", |
| 384 | 431 | fixCheckLabel = "Fix", |
| 385 | 432 | onFixFinding, |
| 433 | + actionPending, | |
| 434 | + onCommentAction, | |
| 386 | 435 | onRefresh, |
| 387 | 436 | }: { |
| 388 | 437 | environmentId: EnvironmentId; |
| @@ -395,6 +444,11 @@ export function PullRequestSummaryTab({ · 6 unmodified lines | ||
| 395 | 444 | fixFindingLabel?: string; |
| 396 | 445 | fixCheckLabel?: string; |
| 397 | 446 | onFixFinding?: (finding: PullRequestFinding) => void; |
| 447 | + actionPending: boolean; | |
| 448 | + onCommentAction: ( | |
| 449 | + body: string, | |
| 450 | + action: "close" | "reopen", | |
| 451 | + ) => Promise<{ readonly commentPosted: boolean }>; | |
| 398 | 452 | onRefresh: () => void; |
| 399 | 453 | }) { |
| 400 | 454 | // Keyed by the pull request, so opening another one starts at the end of its conversation |
| @@ -718,7 +772,7 @@ export function PullRequestSummaryTab({ · 317 unmodified lines | ||
| 718 | 772 | <PullRequestCheckStatusIcon status={check.status} /> |
| 719 | 773 | <span className="min-w-0 flex-1 truncate">{check.name}</span> |
| 720 | 774 | <span className="shrink-0 text-muted-foreground"> |
| 721 | − {pullRequestCheckStatusLabel(check.status)} | |
| 775 | + {pullRequestCheckStatusLabel(check)} | |
| 722 | 776 | </span> |
| 723 | 777 | </button> |
| 724 | 778 | {/* Only where there is something to fix. A passing check has no failure to |
| @@ -919,6 +973,8 @@ export function PullRequestSummaryTab({ · 194 unmodified lines | ||
| 919 | 973 | key={`${environmentId}:${detail.projectId}/${detail.repository}#${detail.number}`} |
| 920 | 974 | environmentId={environmentId} |
| 921 | 975 | detail={detail} |
| 976 | + actionPending={actionPending} | |
| 977 | + onCommentAction={onCommentAction} | |
| 922 | 978 | onCommented={onRefresh} |
| 923 | 979 | /> |
| 924 | 980 | ) : null} |
| @@ -5,10 +5,17 @@ import { describe, expect, it } from "vite-plus/test"; · 4 unmodified lines | ||
| 5 | 5 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 6 | 6 | import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; |
| 7 | 7 | import { PullRequestRow } from "./PullRequestRow"; |
| 8 | −import { pullRequestChecksState } from "./pullRequestPresentation"; | |
| 8 | +import { | |
| 9 | + pullRequestChecksState, | |
| 10 | + pullRequestCheckStatusLabel, | |
| 11 | + summarizePullRequestChecks, | |
| 12 | +} from "./pullRequestPresentation"; | |
| 9 | 13 | |
| 10 | −function check(status: PullRequestCheck["status"]): PullRequestCheck { | |
| 11 | − return { name: `check-${status}`, status, description: null, url: null }; | |
| 14 | +function check( | |
| 15 | + status: PullRequestCheck["status"], | |
| 16 | + overrides: Partial<PullRequestCheck> = {}, | |
| 17 | +): PullRequestCheck { | |
| 18 | + return { name: `check-${status}`, status, description: null, url: null, ...overrides }; | |
| 12 | 19 | } |
| 13 | 20 | |
| 14 | 21 | describe("pullRequestChecksState", () => { |
| @@ -18,11 +25,31 @@ describe("pullRequestChecksState", () => { · 3 unmodified lines | ||
| 18 | 25 | ); |
| 19 | 26 | expect(pullRequestChecksState([check("success"), check("cancelled")])).toBe("failing"); |
| 20 | 27 | expect(pullRequestChecksState([check("success"), check("pending")])).toBe("pending"); |
| 28 | + expect(pullRequestChecksState([check("success"), check("action-required")])).toBe("pending"); | |
| 21 | 29 | expect(pullRequestChecksState([check("success")])).toBe("passing"); |
| 22 | 30 | // Skipped and neutral are neither a pass nor a failure, so they are no verdict at all. |
| 23 | 31 | expect(pullRequestChecksState([check("skipped"), check("neutral")])).toBe(null); |
| 24 | 32 | expect(pullRequestChecksState([])).toBe(null); |
| 25 | 33 | }); |
| 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 | + }); | |
| 26 | 53 | }); |
| 27 | 54 | |
| 28 | 55 | /** Every element of the tree the row returned, so a nested indicator can be looked for. */ |
| @@ -31,6 +31,7 @@ import { · 30 unmodified lines | ||
| 31 | 31 | pullRequestHandoffLabels, |
| 32 | 32 | pullRequestReviewOutcome, |
| 33 | 33 | readableFailure, |
| 34 | + resolvePullRequestPrimaryControl, | |
| 34 | 35 | shouldRefreshPullRequestActivity, |
| 35 | 36 | resolveBaseFreshness, |
| 36 | 37 | buildPullRequestTimeline, |
| @@ -146,6 +147,54 @@ describe("pull request action menu", () => { · 109 unmodified lines | ||
| 146 | 147 | }); |
| 147 | 148 | }); |
| 148 | 149 | |
| 150 | +describe("pull request primary control", () => { | |
| 151 | + const open = { | |
| 152 | + state: "open" as const, | |
| 153 | + isDraft: false, | |
| 154 | + mergeability: "mergeable" as const, | |
| 155 | + checksState: "passing" as const, | |
| 156 | + autoMergeEnabled: false, | |
| 157 | + hasMergeMethod: true, | |
| 158 | + canMerge: true, | |
| 159 | + canMarkReady: true, | |
| 160 | + canEnableAutoMerge: true, | |
| 161 | + }; | |
| 162 | + | |
| 163 | + it("moves pending and failing checks to auto-merge", () => { | |
| 164 | + expect(resolvePullRequestPrimaryControl({ ...open, checksState: "pending" })).toBe( | |
| 165 | + "enable-auto-merge", | |
| 166 | + ); | |
| 167 | + expect(resolvePullRequestPrimaryControl({ ...open, checksState: "failing" })).toBe( | |
| 168 | + "enable-auto-merge", | |
| 169 | + ); | |
| 170 | + }); | |
| 171 | + | |
| 172 | + it("does not offer auto-merge while the host state is unknown", () => { | |
| 173 | + expect( | |
| 174 | + resolvePullRequestPrimaryControl({ | |
| 175 | + ...open, | |
| 176 | + checksState: "pending", | |
| 177 | + autoMergeEnabled: undefined, | |
| 178 | + }), | |
| 179 | + ).toBe("merge"); | |
| 180 | + }); | |
| 181 | + | |
| 182 | + it("keeps armed and terminal states in the merge button slot", () => { | |
| 183 | + expect(resolvePullRequestPrimaryControl({ ...open, autoMergeEnabled: true })).toBe( | |
| 184 | + "auto-merge-armed", | |
| 185 | + ); | |
| 186 | + expect(resolvePullRequestPrimaryControl({ ...open, state: "merged" })).toBe("merged"); | |
| 187 | + expect(resolvePullRequestPrimaryControl({ ...open, state: "closed" })).toBe("closed"); | |
| 188 | + }); | |
| 189 | + | |
| 190 | + it("keeps conflicts and drafts actionable before merge", () => { | |
| 191 | + expect(resolvePullRequestPrimaryControl({ ...open, mergeability: "conflicting" })).toBe( | |
| 192 | + "resolve", | |
| 193 | + ); | |
| 194 | + expect(resolvePullRequestPrimaryControl({ ...open, isDraft: true })).toBe("ready"); | |
| 195 | + }); | |
| 196 | +}); | |
| 197 | + | |
| 149 | 198 | describe("pull request state description", () => { |
| 150 | 199 | it("keeps draft and conflicts orthogonal to the terminal states", () => { |
| 151 | 200 | expect(describePullRequestState("open", true)).toBe("Draft"); |
| @@ -1243,7 +1292,9 @@ describe("which actions need the host read again after they run", () => { · 1091 unmodified lines | ||
| 1243 | 1292 | // Imported from the contract rather than hand-listed, so a new PullRequestAction fails this |
| 1244 | 1293 | // test until somebody decides which side of the diff it belongs on. |
| 1245 | 1294 | 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 | + ), | |
| 1247 | 1298 | ); |
| 1248 | 1299 | }); |
| 1249 | 1300 | |
| @@ -1260,6 +1311,7 @@ describe("which actions need the host read again after they run", () => { · 10 unmodified lines | ||
| 1260 | 1311 | "enable-auto-merge", |
| 1261 | 1312 | "disable-auto-merge", |
| 1262 | 1313 | "merge", |
| 1314 | + "revert", | |
| 1263 | 1315 | ] as const) { |
| 1264 | 1316 | expect(pullRequestActionNeedsHostRefresh(action)).toBe(false); |
| 1265 | 1317 | } |
| @@ -3,6 +3,7 @@ import type { · 2 unmodified lines | ||
| 3 | 3 | PullRequestActor, |
| 4 | 4 | PullRequestBaseComparison, |
| 5 | 5 | PullRequestCheck, |
| 6 | + PullRequestChecksState, | |
| 6 | 7 | PullRequestComment, |
| 7 | 8 | PullRequestCommit, |
| 8 | 9 | PullRequestDetailView, |
| @@ -20,6 +21,45 @@ import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/re · 11 unmodified lines | ||
| 20 | 21 | const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; |
| 21 | 22 | const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; |
| 22 | 23 | |
| 24 | +export type PullRequestPrimaryControl = | |
| 25 | + | "resolve" | |
| 26 | + | "ready" | |
| 27 | + | "merge" | |
| 28 | + | "enable-auto-merge" | |
| 29 | + | "auto-merge-armed" | |
| 30 | + | "merged" | |
| 31 | + | "closed" | |
| 32 | + | null; | |
| 33 | + | |
| 34 | +/** The one merge-area state shown in the header, including terminal and deferred states. */ | |
| 35 | +export function resolvePullRequestPrimaryControl(input: { | |
| 36 | + readonly state: PullRequestState; | |
| 37 | + readonly isDraft: boolean; | |
| 38 | + readonly mergeability: PullRequestMergeability; | |
| 39 | + readonly checksState: PullRequestChecksState | null; | |
| 40 | + readonly autoMergeEnabled: boolean | undefined; | |
| 41 | + readonly hasMergeMethod: boolean; | |
| 42 | + readonly canMerge: boolean; | |
| 43 | + readonly canMarkReady: boolean; | |
| 44 | + readonly canEnableAutoMerge: boolean; | |
| 45 | +}): PullRequestPrimaryControl { | |
| 46 | + if (input.state === "merged") return "merged"; | |
| 47 | + if (input.state === "closed") return "closed"; | |
| 48 | + if (input.mergeability === "conflicting") return "resolve"; | |
| 49 | + if (input.isDraft) return input.canMarkReady ? "ready" : null; | |
| 50 | + if (input.autoMergeEnabled) return "auto-merge-armed"; | |
| 51 | + if (!input.hasMergeMethod) return null; | |
| 52 | + if ( | |
| 53 | + input.autoMergeEnabled === false && | |
| 54 | + input.checksState !== null && | |
| 55 | + input.checksState !== "passing" && | |
| 56 | + input.canEnableAutoMerge | |
| 57 | + ) { | |
| 58 | + return "enable-auto-merge"; | |
| 59 | + } | |
| 60 | + return input.canMerge ? "merge" : null; | |
| 61 | +} | |
| 62 | + | |
| 23 | 63 | export function pullRequestCheckoutCommand( |
| 24 | 64 | provider: SourceControlProviderKind, |
| 25 | 65 | number: number, |
| @@ -949,11 +989,9 @@ export function resolveBaseFreshness(detail: { · 923 unmodified lines | ||
| 949 | 989 | } |
| 950 | 990 | |
| 951 | 991 | /** |
| 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. | |
| 957 | 995 | */ |
| 958 | 996 | const ACTION_NEEDS_HOST_REFRESH: Record<PullRequestAction, boolean> = { |
| 959 | 997 | "update-branch": true, |
| @@ -964,6 +1002,8 @@ const ACTION_NEEDS_HOST_REFRESH: Record<PullRequestAction, boolean> = { · 4 unmodified lines | ||
| 964 | 1002 | reopen: false, |
| 965 | 1003 | "enable-auto-merge": false, |
| 966 | 1004 | "disable-auto-merge": false, |
| 1005 | + revert: false, | |
| 1006 | + "approve-workflows": true, | |
| 967 | 1007 | }; |
| 968 | 1008 | |
| 969 | 1009 | export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): boolean { |
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | readPullRequestListSnapshot, |
| 22 | 22 | writePullRequestListSnapshot, |
| 23 | 23 | rankPullRequestMatches, |
| 24 | + rankPullRequestsByMergeReadiness, | |
| 24 | 25 | scorePullRequestMatch, |
| 25 | 26 | retainVisiblePullRequestStatsBatches, |
| 26 | 27 | withDiffStat, |
| @@ -29,6 +30,11 @@ import { · 2 unmodified lines | ||
| 29 | 30 | resolveSelectedEnvironmentId, |
| 30 | 31 | type EnvironmentPullRequestEntry, |
| 31 | 32 | } from "./pullRequestList.logic"; |
| 33 | +import { | |
| 34 | + pullRequestListPreferences, | |
| 35 | + readPullRequestListPreferences, | |
| 36 | + writePullRequestListPreferences, | |
| 37 | +} from "./pullRequestListPreferences"; | |
| 32 | 38 | |
| 33 | 39 | const VIEWERS = { "github.com": "Bilal" } as const; |
| 34 | 40 | const NO_VIEWERS = {} as const; |
| @@ -656,6 +662,99 @@ describe("ranking what a search found", () => { · 621 unmodified lines | ||
| 656 | 662 | }); |
| 657 | 663 | }); |
| 658 | 664 | |
| 665 | +describe("default merge-readiness ranking", () => { | |
| 666 | + it("puts ready work first, finished work after open work, and every conflict last", () => { | |
| 667 | + const conflict = entry({ | |
| 668 | + number: 1, | |
| 669 | + mergeability: "conflicting", | |
| 670 | + checksState: "passing", | |
| 671 | + reviewDecision: "approved", | |
| 672 | + updatedAt: "2026-09-01T00:00:00Z", | |
| 673 | + }); | |
| 674 | + const other = entry({ | |
| 675 | + number: 2, | |
| 676 | + checksState: "pending", | |
| 677 | + updatedAt: "2026-08-01T00:00:00Z", | |
| 678 | + }); | |
| 679 | + const green = entry({ | |
| 680 | + number: 3, | |
| 681 | + checksState: "passing", | |
| 682 | + reviewDecision: "review-required", | |
| 683 | + updatedAt: "2026-07-01T00:00:00Z", | |
| 684 | + }); | |
| 685 | + const approved = entry({ | |
| 686 | + number: 4, | |
| 687 | + checksState: "passing", | |
| 688 | + reviewDecision: "approved", | |
| 689 | + updatedAt: "2026-06-01T00:00:00Z", | |
| 690 | + }); | |
| 691 | + const draft = entry({ | |
| 692 | + number: 5, | |
| 693 | + isDraft: true, | |
| 694 | + checksState: "passing", | |
| 695 | + reviewDecision: "approved", | |
| 696 | + updatedAt: "2026-09-02T00:00:00Z", | |
| 697 | + }); | |
| 698 | + const finished = entry({ | |
| 699 | + number: 6, | |
| 700 | + state: "merged", | |
| 701 | + checksState: "passing", | |
| 702 | + reviewDecision: "approved", | |
| 703 | + updatedAt: "2026-09-03T00:00:00Z", | |
| 704 | + }); | |
| 705 | + | |
| 706 | + expect( | |
| 707 | + rankPullRequestsByMergeReadiness([conflict, other, green, approved, draft, finished]).map( | |
| 708 | + (row) => row.number, | |
| 709 | + ), | |
| 710 | + ).toEqual([4, 3, 5, 2, 6, 1]); | |
| 711 | + }); | |
| 712 | + | |
| 713 | + it("uses recency inside one readiness tier", () => { | |
| 714 | + const older = entry({ number: 1, checksState: "passing" }); | |
| 715 | + const newer = entry({ | |
| 716 | + number: 2, | |
| 717 | + checksState: "passing", | |
| 718 | + updatedAt: "2026-08-01T00:00:00Z", | |
| 719 | + }); | |
| 720 | + | |
| 721 | + expect(rankPullRequestsByMergeReadiness([older, newer]).map((row) => row.number)).toEqual([ | |
| 722 | + 2, 1, | |
| 723 | + ]); | |
| 724 | + }); | |
| 725 | + | |
| 726 | + it("puts the smallest measured change first inside a readiness tier", () => { | |
| 727 | + const larger = entry({ | |
| 728 | + number: 1, | |
| 729 | + checksState: "passing", | |
| 730 | + reviewDecision: "approved", | |
| 731 | + additions: 40, | |
| 732 | + deletions: 10, | |
| 733 | + updatedAt: "2026-09-01T00:00:00Z", | |
| 734 | + }); | |
| 735 | + const smaller = entry({ | |
| 736 | + number: 2, | |
| 737 | + checksState: "passing", | |
| 738 | + reviewDecision: "approved", | |
| 739 | + additions: 3, | |
| 740 | + deletions: 2, | |
| 741 | + updatedAt: "2026-08-01T00:00:00Z", | |
| 742 | + }); | |
| 743 | + const unknown = entry({ | |
| 744 | + number: 3, | |
| 745 | + checksState: "passing", | |
| 746 | + reviewDecision: "approved", | |
| 747 | + additions: 0, | |
| 748 | + deletions: 0, | |
| 749 | + updatedAt: "2026-09-02T00:00:00Z", | |
| 750 | + }); | |
| 751 | + | |
| 752 | + expect( | |
| 753 | + rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), | |
| 754 | + ).toEqual([2, 1, 3]); | |
| 755 | + }); | |
| 756 | +}); | |
| 757 | + | |
| 659 | 758 | describe("line counts that arrive after the rows", () => { |
| 660 | 759 | const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]); |
| 661 | 760 | |
| @@ -858,6 +957,74 @@ describe("the list snapshot across a reload", () => { · 196 unmodified lines | ||
| 858 | 957 | }); |
| 859 | 958 | }); |
| 860 | 959 | |
| 960 | +describe("remembered pull request list controls", () => { | |
| 961 | + const makeStorage = () => { | |
| 962 | + const held = new Map<string, string>(); | |
| 963 | + return { | |
| 964 | + getItem: (key: string) => held.get(key) ?? null, | |
| 965 | + setItem: (key: string, value: string) => void held.set(key, value), | |
| 966 | + }; | |
| 967 | + }; | |
| 968 | + | |
| 969 | + it("restores every filter and the selected sort", () => { | |
| 970 | + const storage = makeStorage(); | |
| 971 | + const preferences = { | |
| 972 | + involvement: "reviewing", | |
| 973 | + state: "merged", | |
| 974 | + environmentId: "env-1" as EnvironmentId, | |
| 975 | + projectId: "project-1" as ProjectId, | |
| 976 | + host: "github.com", | |
| 977 | + q: "workflow", | |
| 978 | + draft: "hide", | |
| 979 | + review: "approved", | |
| 980 | + checks: "passing", | |
| 981 | + author: "octocat", | |
| 982 | + labels: ["bug", "priority"], | |
| 983 | + sort: "largest", | |
| 984 | + } as const; | |
| 985 | + | |
| 986 | + writePullRequestListPreferences(preferences, storage); | |
| 987 | + expect(readPullRequestListPreferences(storage)).toEqual(preferences); | |
| 988 | + }); | |
| 989 | + | |
| 990 | + it("omits the default sort from storage", () => { | |
| 991 | + expect( | |
| 992 | + pullRequestListPreferences({ | |
| 993 | + involvement: "all", | |
| 994 | + state: "open", | |
| 995 | + sort: "ready", | |
| 996 | + }), | |
| 997 | + ).toEqual({ involvement: "all", state: "open" }); | |
| 998 | + }); | |
| 999 | + | |
| 1000 | + it("keeps an explicit recency sort", () => { | |
| 1001 | + expect( | |
| 1002 | + pullRequestListPreferences({ involvement: "all", state: "open", sort: "updated" }), | |
| 1003 | + ).toEqual({ involvement: "all", state: "open", sort: "updated" }); | |
| 1004 | + }); | |
| 1005 | + | |
| 1006 | + it("falls back to the default controls when storage is corrupt", () => { | |
| 1007 | + const storage = makeStorage(); | |
| 1008 | + storage.setItem("t3.pullRequests.preferences", "{not json"); | |
| 1009 | + expect(readPullRequestListPreferences(storage)).toEqual({ involvement: "all", state: "open" }); | |
| 1010 | + }); | |
| 1011 | + | |
| 1012 | + it("falls back when browser policy denies storage", () => { | |
| 1013 | + const denied = { | |
| 1014 | + getItem: () => { | |
| 1015 | + throw new Error("storage denied"); | |
| 1016 | + }, | |
| 1017 | + setItem: () => { | |
| 1018 | + throw new Error("storage denied"); | |
| 1019 | + }, | |
| 1020 | + }; | |
| 1021 | + expect(readPullRequestListPreferences(denied)).toEqual({ involvement: "all", state: "open" }); | |
| 1022 | + expect(() => | |
| 1023 | + writePullRequestListPreferences({ involvement: "all", state: "open" }, denied), | |
| 1024 | + ).not.toThrow(); | |
| 1025 | + }); | |
| 1026 | +}); | |
| 1027 | + | |
| 861 | 1028 | const ENV_1 = "env-1" as EnvironmentId; |
| 862 | 1029 | const ENV_2 = "env-2" as EnvironmentId; |
| 863 | 1030 | |
| @@ -985,7 +985,7 @@ export function scorePullRequestMatch(entry: PullRequestListEntry, query: string · 984 unmodified lines | ||
| 985 | 985 | |
| 986 | 986 | /** |
| 987 | 987 | * 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. | |
| 989 | 989 | */ |
| 990 | 990 | export function rankPullRequestMatches<Entry extends PullRequestListEntry>( |
| 991 | 991 | entries: ReadonlyArray<Entry>, |
| @@ -998,6 +998,35 @@ export function rankPullRequestMatches<Entry extends PullRequestListEntry>( · 6 unmodified lines | ||
| 998 | 998 | }); |
| 999 | 999 | } |
| 1000 | 1000 | |
| 1001 | +/** | |
| 1002 | + * The default review queue: work that is green and approved, then green work still waiting on a | |
| 1003 | + * verdict, then everything else still open. Drafts stay in that third tier because their author | |
| 1004 | + * has not made them mergeable yet. Finished work follows open work when all states are visible. A | |
| 1005 | + * known conflict is never ready, whatever its checks, review or state say, so it stays at the | |
| 1006 | + * bottom. Smaller measured changes come first within a tier; recency only breaks a remaining tie. | |
| 1007 | + */ | |
| 1008 | +export function rankPullRequestsByMergeReadiness<Entry extends PullRequestListEntry>( | |
| 1009 | + entries: ReadonlyArray<Entry>, | |
| 1010 | + hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, | |
| 1011 | +): ReadonlyArray<Entry> { | |
| 1012 | + const tier = (entry: Entry) => { | |
| 1013 | + if (entry.mergeability === "conflicting") return 4; | |
| 1014 | + if (entry.state !== "open") return 3; | |
| 1015 | + if (entry.isDraft) return 2; | |
| 1016 | + if (entry.checksState === "passing" && entry.reviewDecision === "approved") return 0; | |
| 1017 | + if (entry.checksState === "passing") return 1; | |
| 1018 | + return 2; | |
| 1019 | + }; | |
| 1020 | + return entries.toSorted((left, right) => { | |
| 1021 | + const byTier = tier(left) - tier(right); | |
| 1022 | + if (byTier !== 0) return byTier; | |
| 1023 | + const byMeasurement = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); | |
| 1024 | + if (byMeasurement !== 0) return byMeasurement; | |
| 1025 | + const bySize = left.additions + left.deletions - (right.additions + right.deletions); | |
| 1026 | + return bySize !== 0 ? bySize : right.updatedAt.localeCompare(left.updatedAt); | |
| 1027 | + }); | |
| 1028 | +} | |
| 1029 | + | |
| 1001 | 1030 | /** |
| 1002 | 1031 | * A row with the line counts that arrived after it did. Only where the host left them out — a |
| 1003 | 1032 | * listing that carried them is not second-guessed — and only where they have arrived, since a row |
| @@ -0,0 +1,122 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + EnvironmentId, | |
| 5 | + ProjectId, | |
| 6 | + PullRequestInvolvement, | |
| 7 | + PullRequestListFilters, | |
| 8 | + PullRequestListState, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | + | |
| 11 | +export const PullRequestListSort = Schema.Literals([ | |
| 12 | + "ready", | |
| 13 | + "updated", | |
| 14 | + "newest", | |
| 15 | + "oldest", | |
| 16 | + "largest", | |
| 17 | + "smallest", | |
| 18 | +]); | |
| 19 | +export type PullRequestListSort = typeof PullRequestListSort.Type; | |
| 20 | + | |
| 21 | +export interface PullRequestListPreferences { | |
| 22 | + readonly involvement: PullRequestInvolvement; | |
| 23 | + readonly state: PullRequestListState; | |
| 24 | + readonly environmentId?: EnvironmentId; | |
| 25 | + readonly projectId?: ProjectId; | |
| 26 | + readonly host?: string; | |
| 27 | + readonly q?: string; | |
| 28 | + readonly draft?: "only" | "hide"; | |
| 29 | + readonly review?: NonNullable<PullRequestListFilters["review"]>; | |
| 30 | + readonly checks?: NonNullable<PullRequestListFilters["checks"]>; | |
| 31 | + readonly author?: string; | |
| 32 | + readonly labels?: ReadonlyArray<string>; | |
| 33 | + readonly sort?: PullRequestListSort; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export type PullRequestListPreferencePatch = { | |
| 37 | + [Key in keyof PullRequestListPreferences]?: PullRequestListPreferences[Key] | undefined; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +export const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { | |
| 41 | + involvement: "all", | |
| 42 | + state: "open", | |
| 43 | +} as const satisfies PullRequestListPreferences; | |
| 44 | + | |
| 45 | +const BoundedPreference = Schema.String.check(Schema.isMaxLength(200)); | |
| 46 | +const PullRequestListPreferencesSchema = Schema.Struct({ | |
| 47 | + involvement: PullRequestInvolvement, | |
| 48 | + state: PullRequestListState, | |
| 49 | + environmentId: Schema.optional(EnvironmentId), | |
| 50 | + projectId: Schema.optional(ProjectId), | |
| 51 | + host: Schema.optional(BoundedPreference), | |
| 52 | + q: Schema.optional(BoundedPreference), | |
| 53 | + draft: PullRequestListFilters.fields.draft, | |
| 54 | + review: PullRequestListFilters.fields.review, | |
| 55 | + checks: PullRequestListFilters.fields.checks, | |
| 56 | + author: Schema.optional(BoundedPreference), | |
| 57 | + labels: Schema.optional(Schema.Array(BoundedPreference).check(Schema.isMaxLength(10))), | |
| 58 | + sort: Schema.optional(PullRequestListSort), | |
| 59 | +}); | |
| 60 | + | |
| 61 | +const decodePullRequestListPreferences = Schema.decodeUnknownOption( | |
| 62 | + PullRequestListPreferencesSchema, | |
| 63 | +); | |
| 64 | +const PULL_REQUEST_LIST_PREFERENCES_STORAGE_KEY = "t3.pullRequests.preferences"; | |
| 65 | +type PreferenceStorage = Pick<Storage, "getItem" | "setItem">; | |
| 66 | + | |
| 67 | +function resolvePreferenceStorage( | |
| 68 | + storage: PreferenceStorage | undefined, | |
| 69 | +): PreferenceStorage | undefined { | |
| 70 | + return storage ?? (typeof window === "undefined" ? undefined : window.localStorage); | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** Only list controls are remembered. The selected row remains a URL and right-panel concern. */ | |
| 74 | +export function pullRequestListPreferences( | |
| 75 | + search: PullRequestListPreferences | Schema.Schema.Type<typeof PullRequestListPreferencesSchema>, | |
| 76 | +): PullRequestListPreferences { | |
| 77 | + return { | |
| 78 | + involvement: search.involvement, | |
| 79 | + state: search.state, | |
| 80 | + ...(search.environmentId ? { environmentId: search.environmentId } : {}), | |
| 81 | + ...(search.projectId ? { projectId: search.projectId } : {}), | |
| 82 | + ...(search.host ? { host: search.host } : {}), | |
| 83 | + ...(search.q ? { q: search.q } : {}), | |
| 84 | + ...(search.draft ? { draft: search.draft } : {}), | |
| 85 | + ...(search.review ? { review: search.review } : {}), | |
| 86 | + ...(search.checks ? { checks: search.checks } : {}), | |
| 87 | + ...(search.author ? { author: search.author } : {}), | |
| 88 | + ...(search.labels && search.labels.length > 0 ? { labels: search.labels } : {}), | |
| 89 | + ...(search.sort && search.sort !== "ready" ? { sort: search.sort } : {}), | |
| 90 | + }; | |
| 91 | +} | |
| 92 | + | |
| 93 | +export function readPullRequestListPreferences( | |
| 94 | + storage?: PreferenceStorage, | |
| 95 | +): PullRequestListPreferences { | |
| 96 | + try { | |
| 97 | + const raw = resolvePreferenceStorage(storage)?.getItem( | |
| 98 | + PULL_REQUEST_LIST_PREFERENCES_STORAGE_KEY, | |
| 99 | + ); | |
| 100 | + if (!raw) return DEFAULT_PULL_REQUEST_LIST_PREFERENCES; | |
| 101 | + const decoded = decodePullRequestListPreferences(JSON.parse(raw)); | |
| 102 | + return decoded._tag === "Some" | |
| 103 | + ? pullRequestListPreferences(decoded.value) | |
| 104 | + : DEFAULT_PULL_REQUEST_LIST_PREFERENCES; | |
| 105 | + } catch { | |
| 106 | + return DEFAULT_PULL_REQUEST_LIST_PREFERENCES; | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +export function writePullRequestListPreferences( | |
| 111 | + preferences: PullRequestListPreferences, | |
| 112 | + storage?: PreferenceStorage, | |
| 113 | +): void { | |
| 114 | + try { | |
| 115 | + resolvePreferenceStorage(storage)?.setItem( | |
| 116 | + PULL_REQUEST_LIST_PREFERENCES_STORAGE_KEY, | |
| 117 | + JSON.stringify(preferences), | |
| 118 | + ); | |
| 119 | + } catch { | |
| 120 | + // Storage can be full or denied; the URL remains the source of truth for this visit. | |
| 121 | + } | |
| 122 | +} | |
| @@ -120,6 +120,11 @@ export function PullRequestStateGlyph({ · 119 unmodified lines | ||
| 120 | 120 | |
| 121 | 121 | const CHECK_STATUS_PRESENTATION = { |
| 122 | 122 | 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 | + }, | |
| 123 | 128 | success: { |
| 124 | 129 | label: "Passed", |
| 125 | 130 | Icon: CircleCheckIcon, |
| @@ -134,8 +139,20 @@ const CHECK_STATUS_PRESENTATION = { · 8 unmodified lines | ||
| 134 | 139 | { label: string; Icon: typeof CircleCheckIcon; toneClassName: string } |
| 135 | 140 | >; |
| 136 | 141 | |
| 137 | −export function pullRequestCheckStatusLabel(status: PullRequestCheckStatus): string { | |
| 138 | − return CHECK_STATUS_PRESENTATION[status].label; | |
| 142 | +function isWorkflowApprovalCheck(check: Pick<PullRequestCheck, "status" | "url">): boolean { | |
| 143 | + return ( | |
| 144 | + check.status === "action-required" && | |
| 145 | + check.url !== null && | |
| 146 | + /\/actions\/runs\/\d+(?:\/|$)/u.test(check.url) | |
| 147 | + ); | |
| 148 | +} | |
| 149 | + | |
| 150 | +export function pullRequestCheckStatusLabel( | |
| 151 | + check: Pick<PullRequestCheck, "status" | "url">, | |
| 152 | +): string { | |
| 153 | + return isWorkflowApprovalCheck(check) | |
| 154 | + ? "Awaiting approval" | |
| 155 | + : CHECK_STATUS_PRESENTATION[check.status].label; | |
| 139 | 156 | } |
| 140 | 157 | |
| 141 | 158 | export function PullRequestCheckStatusIcon({ status }: { status: PullRequestCheckStatus }) { |
| @@ -187,10 +204,10 @@ export function pullRequestChecksState( · 45 unmodified lines | ||
| 187 | 204 | checks: ReadonlyArray<PullRequestCheck>, |
| 188 | 205 | ): PullRequestChecksState | null { |
| 189 | 206 | 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; | |
| 194 | 211 | } |
| 195 | 212 | |
| 196 | 213 | /** |
| @@ -435,12 +452,24 @@ export function PullRequestMetaLine({ · 238 unmodified lines | ||
| 435 | 452 | |
| 436 | 453 | export function summarizePullRequestChecks(checks: ReadonlyArray<PullRequestCheck>): string { |
| 437 | 454 | 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; | |
| 438 | 458 | const failed = checks.filter( |
| 439 | 459 | (check) => check.status === "failure" || check.status === "cancelled", |
| 440 | 460 | ).length; |
| 441 | 461 | const pending = checks.filter((check) => check.status === "pending").length; |
| 442 | 462 | const passed = checks.filter((check) => check.status === "success").length; |
| 443 | 463 | 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 | + } | |
| 444 | 473 | if (pending > 0) return `${pending} of ${checks.length} running`; |
| 445 | 474 | return passed === checks.length ? "All checks passed" : `${passed} of ${checks.length} passing`; |
| 446 | 475 | } |
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | ||
| 30 | 30 | useSidebar, |
| 31 | 31 | } from "../ui/sidebar"; |
| 32 | 32 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 33 | +import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; | |
| 33 | 34 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; |
| 34 | 35 | import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; |
| 35 | 36 | |
| @@ -157,7 +158,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { · 121 unmodified lines | ||
| 157 | 158 | }, [isMobile, setOpenMobile]); |
| 158 | 159 | const handlePullRequestsClick = useCallback(() => { |
| 159 | 160 | closeMobileSidebar(); |
| 160 | − void navigate({ to: "/pull-requests", search: { involvement: "all", state: "open" } }); | |
| 161 | + void navigate({ | |
| 162 | + to: "/pull-requests", | |
| 163 | + search: readPullRequestListPreferences(), | |
| 164 | + }); | |
| 161 | 165 | }, [closeMobileSidebar, navigate]); |
| 162 | 166 | const handleSettingsClick = useCallback(() => { |
| 163 | 167 | closeMobileSidebar(); |
| @@ -15,6 +15,8 @@ const badgeVariants = cva( · 14 unmodified lines | ||
| 15 | 15 | }, |
| 16 | 16 | variants: { |
| 17 | 17 | 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", | |
| 18 | 20 | default: |
| 19 | 21 | "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", |
| 20 | 22 | 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", |
| @@ -53,6 +53,8 @@ const buttonVariants = cva( · 52 unmodified lines | ||
| 53 | 53 | "[--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", |
| 54 | 54 | secondary: |
| 55 | 55 | "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", | |
| 56 | 58 | }, |
| 57 | 59 | }, |
| 58 | 60 | }, |
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | GitPullRequestClosedIcon, |
| 26 | 26 | GitPullRequestIcon, |
| 27 | 27 | LayersIcon, |
| 28 | + ListChecksIcon, | |
| 28 | 29 | PenLineIcon, |
| 29 | 30 | LoaderIcon, |
| 30 | 31 | Maximize2Icon, |
| @@ -57,6 +58,7 @@ import { · 26 unmodified lines | ||
| 57 | 58 | pullRequestEntryKey, |
| 58 | 59 | pullRequestEntryViewer, |
| 59 | 60 | rankPullRequestMatches, |
| 61 | + rankPullRequestsByMergeReadiness, | |
| 60 | 62 | pullRequestEnvironmentSetKey, |
| 61 | 63 | readPullRequestListSnapshot, |
| 62 | 64 | resolveProjectScope, |
| @@ -76,6 +78,13 @@ import { · 13 unmodified lines | ||
| 76 | 78 | type PullRequestStatsScope, |
| 77 | 79 | type PullRequestPartitionsSnapshot, |
| 78 | 80 | } from "../components/pullRequest/pullRequestList.logic"; |
| 81 | +import { | |
| 82 | + pullRequestListPreferences, | |
| 83 | + type PullRequestListPreferencePatch, | |
| 84 | + type PullRequestListPreferences, | |
| 85 | + type PullRequestListSort, | |
| 86 | + writePullRequestListPreferences, | |
| 87 | +} from "../components/pullRequest/pullRequestListPreferences"; | |
| 79 | 88 | import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; |
| 80 | 89 | import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; |
| 81 | 90 | import { |
| @@ -129,9 +138,7 @@ import { useAtomCommand } from "../state/use-atom-command"; · 47 unmodified lines | ||
| 129 | 138 | import { cn } from "~/lib/utils"; |
| 130 | 139 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 131 | 140 | |
| 132 | −export interface PullRequestsSearch { | |
| 133 | − readonly involvement: PullRequestInvolvement; | |
| 134 | − readonly state: PullRequestListState; | |
| 141 | +export interface PullRequestsSearch extends PullRequestListPreferences { | |
| 135 | 142 | /** |
| 136 | 143 | * Narrows the list to one server. Absent means every connected one, which is the default the |
| 137 | 144 | * page has now — so a link written before servers could be chosen still opens the whole list. |
| @@ -153,21 +160,8 @@ export interface PullRequestsSearch { · 15 unmodified lines | ||
| 153 | 160 | * link without it still opens, resolved by project id alone where that is unambiguous. |
| 154 | 161 | */ |
| 155 | 162 | 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; | |
| 167 | 163 | } |
| 168 | 164 | |
| 169 | −type PullRequestListSort = "updated" | "newest" | "oldest" | "largest" | "smallest"; | |
| 170 | − | |
| 171 | 165 | // The state filters wear the same glyphs the rows do, so the two read as one vocabulary. |
| 172 | 166 | const INVOLVEMENT_TABS = [ |
| 173 | 167 | { value: "all", label: "All", Icon: LayersIcon }, |
| @@ -183,6 +177,7 @@ const STATE_TABS = [ · 9 unmodified lines | ||
| 183 | 177 | ] as const satisfies ReadonlyArray<PullRequestFilterOption<PullRequestListState>>; |
| 184 | 178 | |
| 185 | 179 | const SORT_OPTIONS = [ |
| 180 | + { value: "ready", label: "Merge readiness", Icon: ListChecksIcon }, | |
| 186 | 181 | { value: "updated", label: "Recently updated", Icon: ClockIcon }, |
| 187 | 182 | { value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon }, |
| 188 | 183 | { value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon }, |
| @@ -287,9 +282,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ · 98 unmodified lines | ||
| 287 | 282 | |
| 288 | 283 | function PullRequestsRouteView() { |
| 289 | 284 | const search = Route.useSearch(); |
| 290 | − const sort = search.sort ?? "updated"; | |
| 285 | + const sort = search.sort ?? "ready"; | |
| 291 | 286 | const statsPolicy: PullRequestStatsPolicy = |
| 292 | − sort === "largest" || sort === "smallest" ? "eager" : "visible"; | |
| 287 | + sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible"; | |
| 293 | 288 | const navigate = useNavigate({ from: Route.fullPath }); |
| 294 | 289 | const { environments } = useEnvironments(); |
| 295 | 290 | // Every connected environment that has said it can list pull requests. Sorted, so the query |
| @@ -487,7 +482,7 @@ function PullRequestsRouteView() { · 191 unmodified lines | ||
| 487 | 482 | return { |
| 488 | 483 | involvement: next.involvement ?? previous.involvement, |
| 489 | 484 | state: next.state ?? previous.state, |
| 490 | − ...(next.sort && next.sort !== "updated" ? { sort: next.sort } : {}), | |
| 485 | + ...(next.sort && next.sort !== "ready" ? { sort: next.sort } : {}), | |
| 491 | 486 | ...(next.repository ? { repository: next.repository } : {}), |
| 492 | 487 | ...(next.number ? { number: next.number } : {}), |
| 493 | 488 | ...(next.projectId ? { projectId: next.projectId } : {}), |
| @@ -510,22 +505,25 @@ function PullRequestsRouteView() { · 16 unmodified lines | ||
| 510 | 505 | [navigate], |
| 511 | 506 | ); |
| 512 | 507 | |
| 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. | |
| 515 | 508 | const clearedSelection = { |
| 516 | 509 | repository: undefined, |
| 517 | 510 | number: undefined, |
| 518 | 511 | selectedProjectId: undefined, |
| 519 | 512 | selectedEnvironmentId: undefined, |
| 520 | 513 | }; |
| 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); | |
| 529 | 527 | }; |
| 530 | 528 | |
| 531 | 529 | // Searching asks the hosts, which takes a round trip, so the text is held for a moment before |
| @@ -1420,7 +1418,24 @@ function PullRequestsRouteView() { · 888 unmodified lines | ||
| 1420 | 1418 | ...group, |
| 1421 | 1419 | entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)), |
| 1422 | 1420 | })); |
| 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; | |
| 1424 | 1439 | const entries = enriched.flatMap((group) => group.entries); |
| 1425 | 1440 | const hasSize = (entry: (typeof entries)[number]) => |
| 1426 | 1441 | entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)); |
| @@ -1448,7 +1463,7 @@ function PullRequestsRouteView() { · 21 unmodified lines | ||
| 1448 | 1463 | }), |
| 1449 | 1464 | }, |
| 1450 | 1465 | ]; |
| 1451 | − }, [groups, sort, statsByRow]); | |
| 1466 | + }, [groups, sort, statsByRow, typedParsed.text]); | |
| 1452 | 1467 | |
| 1453 | 1468 | const linkedSelection = useMemo( |
| 1454 | 1469 | () => |
| @@ -1567,7 +1582,7 @@ function PullRequestsRouteView() { · 112 unmodified lines | ||
| 1567 | 1582 | <PullRequestSearchInput |
| 1568 | 1583 | value={search.q ?? ""} |
| 1569 | 1584 | busy={typedQuery.length > 0 && (!querySettled || showingCarried)} |
| 1570 | − onChange={(query) => updateSearch({ q: query || undefined })} | |
| 1585 | + onChange={(query) => updateListScope({ q: query || undefined })} | |
| 1571 | 1586 | /> |
| 1572 | 1587 | ); |
| 1573 | 1588 | const panelToggleControls = ( |
| @@ -1635,7 +1650,7 @@ function PullRequestsRouteView() { · 61 unmodified lines | ||
| 1635 | 1650 | searching={typedQuery.length > 0 && (!querySettled || showingCarried)} |
| 1636 | 1651 | canLoadMore={listData?.truncated === true && (canContinue || pageSize < MAX_PAGE_SIZE)} |
| 1637 | 1652 | loadingMore={loadingMore} |
| 1638 | − onClearQuery={() => updateSearch({ q: undefined })} | |
| 1653 | + onClearQuery={() => updateListScope({ q: undefined })} | |
| 1639 | 1654 | onLoadMore={loadMore} |
| 1640 | 1655 | /> |
| 1641 | 1656 | ) : ( |
| @@ -1739,7 +1754,7 @@ function PullRequestsRouteView() { · 97 unmodified lines | ||
| 1739 | 1754 | outlined |
| 1740 | 1755 | value={sort} |
| 1741 | 1756 | options={SORT_OPTIONS} |
| 1742 | − onChange={(next) => updateSearch({ sort: next })} | |
| 1757 | + onChange={(next) => updateListScope({ sort: next })} | |
| 1743 | 1758 | /> |
| 1744 | 1759 | ); |
| 1745 | 1760 | const filtersMenu = ( |
| @@ -41,9 +41,19 @@ T3 Code works with the platforms your team already uses: · 40 unmodified lines | ||
| 41 | 41 | |
| 42 | 42 | - See if your current branch already has an open PR/MR |
| 43 | 43 | - 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. | |
| 44 | 47 | - 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 | |
| 47 | 57 | - While working in a thread, open linked reviews in the same compact right-panel tabs without |
| 48 | 58 | leaving the conversation |
| 49 | 59 | - Enable **Settings → General → Proactive panels** to open a newly linked review automatically and |
| @@ -56,6 +66,8 @@ T3 Code works with the platforms your team already uses: · 6 unmodified lines | ||
| 56 | 66 | |
| 57 | 67 | **Fix what you wrote, in place** |
| 58 | 68 | |
| 69 | +- Comment while closing an open pull request or reopening a closed one when the host offers that | |
| 70 | + action | |
| 59 | 71 | - Rewrite a pull request's title and description from the review itself, in Markdown, with a |
| 60 | 72 | preview before you save |
| 61 | 73 | - Rewrite your own comments the same way, wherever they are shown |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult); |
| 14 | 14 | const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput); |
| 15 | 15 | const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput); |
| 16 | +const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput); | |
| 16 | 17 | |
| 17 | 18 | const LIST_RESULT: PullRequestListResult = { |
| 18 | 19 | viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" }, |
| @@ -158,7 +159,6 @@ describe("PullRequestReviewerRequestInput", () => { · 139 unmodified lines | ||
| 158 | 159 | }); |
| 159 | 160 | |
| 160 | 161 | describe("updating a branch that has fallen behind its base", () => { |
| 161 | − const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput); | |
| 162 | 162 | const ref = { projectId: "project-1", repository: "acme/web", number: 7 }; |
| 163 | 163 | |
| 164 | 164 | it("carries the way the branch should be brought up to date", () => { |
| @@ -182,7 +182,6 @@ describe("updating a branch that has fallen behind its base", () => { · 17 unmodified lines | ||
| 182 | 182 | }); |
| 183 | 183 | |
| 184 | 184 | describe("leaving a merge for the host to make once it is ready", () => { |
| 185 | − const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput); | |
| 186 | 185 | const ref = { projectId: "project-1", repository: "acme/web", number: 7 }; |
| 187 | 186 | |
| 188 | 187 | it("carries the strategy the deferred merge should use, as merging now does", () => { |
| @@ -196,6 +195,33 @@ describe("leaving a merge for the host to make once it is ready", () => { · 7 unmodified lines | ||
| 196 | 195 | }); |
| 197 | 196 | }); |
| 198 | 197 | |
| 198 | +describe("reverting a merged pull request", () => { | |
| 199 | + it("carries the revert action without merge options", () => { | |
| 200 | + const action = decodeAction({ | |
| 201 | + projectId: "project-1", | |
| 202 | + repository: "acme/web", | |
| 203 | + number: 7, | |
| 204 | + action: "revert", | |
| 205 | + }); | |
| 206 | + | |
| 207 | + expect(action.action).toBe("revert"); | |
| 208 | + expect(action.mergeMethod).toBeUndefined(); | |
| 209 | + }); | |
| 210 | +}); | |
| 211 | + | |
| 212 | +describe("approving fork workflows", () => { | |
| 213 | + it("carries workflow approval as its own action", () => { | |
| 214 | + const action = decodeAction({ | |
| 215 | + projectId: "project-1", | |
| 216 | + repository: "acme/web", | |
| 217 | + number: 7, | |
| 218 | + action: "approve-workflows", | |
| 219 | + }); | |
| 220 | + | |
| 221 | + expect(action.action).toBe("approve-workflows"); | |
| 222 | + }); | |
| 223 | +}); | |
| 224 | + | |
| 199 | 225 | describe("PullRequestCapabilities", () => { |
| 200 | 226 | const decodeCapabilities = Schema.decodeUnknownSync(PullRequestCapabilities); |
| 201 | 227 | const base = { |
| @@ -93,6 +93,10 @@ export const PullRequestAction = Schema.Literals([ · 92 unmodified lines | ||
| 93 | 93 | "enable-auto-merge", |
| 94 | 94 | /** Take the standing instruction back, which leaves the change request where it was. */ |
| 95 | 95 | "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", | |
| 96 | 100 | ]); |
| 97 | 101 | export type PullRequestAction = typeof PullRequestAction.Type; |
| 98 | 102 | |
| @@ -131,6 +135,7 @@ export type PullRequestLabel = typeof PullRequestLabel.Type; · 32 unmodified lines | ||
| 131 | 135 | |
| 132 | 136 | export const PullRequestCheckStatus = Schema.Literals([ |
| 133 | 137 | "pending", |
| 138 | + "action-required", | |
| 134 | 139 | "success", |
| 135 | 140 | "failure", |
| 136 | 141 | "skipped", |
| @@ -709,6 +714,10 @@ export const PullRequestDetail = Schema.Struct({ · 572 unmodified lines | ||
| 709 | 714 | * arm something that is already armed, and a second arming is a write nobody asked for. |
| 710 | 715 | */ |
| 711 | 716 | 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), | |
| 712 | 721 | }); |
| 713 | 722 | export type PullRequestDetail = typeof PullRequestDetail.Type; |
| 714 | 723 | |