bellwether 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude-plugin/plugin.json +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/SKILL.md +92 -0
  5. package/dist/bin.d.ts +3 -0
  6. package/dist/bin.d.ts.map +1 -0
  7. package/dist/bin.js +17 -0
  8. package/dist/bin.js.map +1 -0
  9. package/dist/cli.d.ts +13 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +36 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/commands/check.d.ts +191 -0
  14. package/dist/commands/check.d.ts.map +1 -0
  15. package/dist/commands/check.js +186 -0
  16. package/dist/commands/check.js.map +1 -0
  17. package/dist/commands/ci.d.ts +8 -0
  18. package/dist/commands/ci.d.ts.map +1 -0
  19. package/dist/commands/ci.js +28 -0
  20. package/dist/commands/ci.js.map +1 -0
  21. package/dist/commands/hook-add.d.ts +2 -0
  22. package/dist/commands/hook-add.d.ts.map +1 -0
  23. package/dist/commands/hook-add.js +97 -0
  24. package/dist/commands/hook-add.js.map +1 -0
  25. package/dist/commands/hook-check.d.ts +2 -0
  26. package/dist/commands/hook-check.d.ts.map +1 -0
  27. package/dist/commands/hook-check.js +29 -0
  28. package/dist/commands/hook-check.js.map +1 -0
  29. package/dist/commands/reviews.d.ts +74 -0
  30. package/dist/commands/reviews.d.ts.map +1 -0
  31. package/dist/commands/reviews.js +133 -0
  32. package/dist/commands/reviews.js.map +1 -0
  33. package/dist/context.d.ts +13 -0
  34. package/dist/context.d.ts.map +1 -0
  35. package/dist/context.js +53 -0
  36. package/dist/context.js.map +1 -0
  37. package/dist/github/auth.d.ts +9 -0
  38. package/dist/github/auth.d.ts.map +1 -0
  39. package/dist/github/auth.js +49 -0
  40. package/dist/github/auth.js.map +1 -0
  41. package/dist/github/checks.d.ts +19 -0
  42. package/dist/github/checks.d.ts.map +1 -0
  43. package/dist/github/checks.js +112 -0
  44. package/dist/github/checks.js.map +1 -0
  45. package/dist/github/comments.d.ts +86 -0
  46. package/dist/github/comments.d.ts.map +1 -0
  47. package/dist/github/comments.js +309 -0
  48. package/dist/github/comments.js.map +1 -0
  49. package/dist/github/fetch.d.ts +21 -0
  50. package/dist/github/fetch.d.ts.map +1 -0
  51. package/dist/github/fetch.js +177 -0
  52. package/dist/github/fetch.js.map +1 -0
  53. package/dist/github/index.d.ts +6 -0
  54. package/dist/github/index.d.ts.map +1 -0
  55. package/dist/github/index.js +6 -0
  56. package/dist/github/index.js.map +1 -0
  57. package/dist/github/repo.d.ts +27 -0
  58. package/dist/github/repo.d.ts.map +1 -0
  59. package/dist/github/repo.js +72 -0
  60. package/dist/github/repo.js.map +1 -0
  61. package/hooks/hooks.json +29 -0
  62. package/package.json +65 -0
  63. package/skills/bellwether/SKILL.md +92 -0
  64. package/src/bin.ts +15 -0
  65. package/src/cli.ts +39 -0
  66. package/src/commands/check.ts +251 -0
  67. package/src/commands/ci.ts +44 -0
  68. package/src/commands/hook-add.ts +139 -0
  69. package/src/commands/hook-check.ts +35 -0
  70. package/src/commands/reviews.ts +225 -0
  71. package/src/context.ts +86 -0
  72. package/src/github/auth.ts +40 -0
  73. package/src/github/checks.ts +187 -0
  74. package/src/github/comments.ts +522 -0
  75. package/src/github/fetch.ts +233 -0
  76. package/src/github/index.ts +35 -0
  77. package/src/github/repo.ts +146 -0
@@ -0,0 +1,72 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { ghFetch } from "./fetch.js";
3
+ function spawnText(cmd) {
4
+ const [command, ...args] = cmd;
5
+ if (!command) {
6
+ return null;
7
+ }
8
+ const result = spawnSync(command, args, { encoding: "utf-8" });
9
+ if (result.status !== 0) {
10
+ return null;
11
+ }
12
+ return result.stdout.trim();
13
+ }
14
+ // ---------------------------------------------------------------------------
15
+ // Local git state
16
+ // ---------------------------------------------------------------------------
17
+ export function getRepoRoot() {
18
+ return spawnText(["git", "rev-parse", "--show-toplevel"]);
19
+ }
20
+ export function getRepoInfo() {
21
+ const envRepo = process.env.GH_REPO;
22
+ if (envRepo) {
23
+ const match = envRepo.match(/^([^/]+)\/([^/]+)$/);
24
+ if (match?.[1] && match[2]) {
25
+ return { owner: match[1], repo: match[2] };
26
+ }
27
+ }
28
+ const remoteUrl = spawnText(["git", "remote", "get-url", "origin"]);
29
+ if (!remoteUrl) {
30
+ return null;
31
+ }
32
+ // SSH, HTTPS, and proxy URL formats
33
+ const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
34
+ const httpsMatch = remoteUrl.match(/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
35
+ const proxyMatch = remoteUrl.match(/\/git\/([^/]+)\/([^/]+)$/);
36
+ const match = sshMatch ?? httpsMatch ?? proxyMatch;
37
+ if (match?.[1] && match[2]) {
38
+ return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
39
+ }
40
+ return null;
41
+ }
42
+ export function getCurrentBranch() {
43
+ return spawnText(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
44
+ }
45
+ export async function findPRForBranch(owner, repo, branch, token, proxyFetch) {
46
+ const response = await ghFetch(`https://api.github.com/repos/${owner}/${repo}/pulls?head=${owner}:${branch}&state=open`, token, proxyFetch);
47
+ if (!response.ok) {
48
+ throw new Error(`Failed to find PR: ${response.status}`);
49
+ }
50
+ const prs = (await response.json());
51
+ return prs[0] ?? null;
52
+ }
53
+ export async function listOpenPRs(owner, repo, token, proxyFetch) {
54
+ const response = await ghFetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=open&per_page=30`, token, proxyFetch);
55
+ if (!response.ok) {
56
+ throw new Error(`Failed to list PRs: ${response.status}`);
57
+ }
58
+ return (await response.json());
59
+ }
60
+ export async function fetchPRMergeState(owner, repo, prNumber, token, proxyFetch) {
61
+ const response = await ghFetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, token, proxyFetch);
62
+ if (!response.ok) {
63
+ throw new Error(`Failed to fetch PR merge state: ${response.status}`);
64
+ }
65
+ const pr = (await response.json());
66
+ return {
67
+ state: pr.merged ? "merged" : pr.state,
68
+ mergeable: pr.mergeable,
69
+ mergeableState: pr.mergeable_state ?? "unknown",
70
+ };
71
+ }
72
+ //# sourceMappingURL=repo.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repo.js","sourceRoot":"","sources":["../../src/github/repo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAmB,MAAM,YAAY,CAAC;AAEtD,SAAS,SAAS,CAAC,GAAa;IAC9B,MAAM,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,GAAG,GAAG,CAAC;IAC/B,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,MAAM,GAAG,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAC;IAC/D,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;AAC9B,CAAC;AAED,8EAA8E;AAC9E,kBAAkB;AAClB,8EAA8E;AAE9E,MAAM,UAAU,WAAW;IACzB,OAAO,SAAS,CAAC,CAAC,KAAK,EAAE,WAAW,EAAE,iBAAiB,CAAC,CAAC,CAAC;AAC5D,CAAC;AAOD,MAAM,UAAU,WAAW;IACzB,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC;IACpC,IAAI,OAAO,EAAE,CAAC;QACZ,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,oBAAoB,CAAC,CAAC;QAClD,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;YAC3B,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7C,CAAC;IACH,CAAC;IAED,MAAM,SAAS,GAAG,SAAS,CAAC,CAAC,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC;IACpE,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,IAAI,CAAC;IACd,CAAC;IAED,oCAAoC;IACpC,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC,2CAA2C,CAAC,CAAC;IAC9E,MAAM,UAAU,GAAG,SAAS,CAAC,KAAK,CAAC,wCAAwC,CAAC,CAAC;IAC7E,MAAM,UAAU,GAAG,SAAS,CAAC,KAAK,CAAC,0BAA0B,CAAC,CAAC;IAE/D,MAAM,KAAK,GAAG,QAAQ,IAAI,UAAU,IAAI,UAAU,CAAC;IACnD,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3B,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,EAAE,CAAC;IACnE,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,OAAO,SAAS,CAAC,CAAC,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,CAAC,CAAC,CAAC;AACjE,CAAC;AAcD,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,KAAa,EACb,IAAY,EACZ,MAAc,EACd,KAAa,EACb,UAAsB;IAEtB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC5B,gCAAgC,KAAK,IAAI,IAAI,eAAe,KAAK,IAAI,MAAM,aAAa,EACxF,KAAK,EACL,UAAU,CACX,CAAC;IACF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,sBAAsB,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IAC3D,CAAC;IACD,MAAM,GAAG,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAS,CAAC;IAC5C,OAAO,GAAG,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AACxB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,KAAa,EACb,IAAY,EACZ,KAAa,EACb,UAAsB;IAEtB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC5B,gCAAgC,KAAK,IAAI,IAAI,+BAA+B,EAC5E,KAAK,EACL,UAAU,CACX,CAAC;IACF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,uBAAuB,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAS,CAAC;AACzC,CAAC;AAmBD,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,KAAa,EACb,IAAY,EACZ,QAAgB,EAChB,KAAa,EACb,UAAsB;IAEtB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAC5B,gCAAgC,KAAK,IAAI,IAAI,UAAU,QAAQ,EAAE,EACjE,KAAK,EACL,UAAU,CACX,CAAC;IACF,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,mCAAmC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IACxE,CAAC;IACD,MAAM,EAAE,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAmB,CAAC;IACrD,OAAO;QACL,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAE,EAAE,CAAC,KAA2B;QAC7D,SAAS,EAAE,EAAE,CAAC,SAAS;QACvB,cAAc,EAAE,EAAE,CAAC,eAAe,IAAI,SAAS;KAChD,CAAC;AACJ,CAAC"}
@@ -0,0 +1,29 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Bash",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "if": "Bash(git push*)",
10
+ "command": "npx -y bellwether@latest hook-check",
11
+ "timeout": 15
12
+ },
13
+ {
14
+ "type": "command",
15
+ "if": "Bash(gh pr create*)",
16
+ "command": "npx -y bellwether@latest hook-check",
17
+ "timeout": 15
18
+ },
19
+ {
20
+ "type": "command",
21
+ "if": "Bash(gh pr ready*)",
22
+ "command": "npx -y bellwether@latest hook-check",
23
+ "timeout": 15
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "bellwether",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "description": "Drive GitHub PRs to merge-ready: watch CI, fix failures, resolve review comments.",
6
+ "private": false,
7
+ "author": {
8
+ "name": "Roderik van der Veer",
9
+ "url": "https://github.com/roderik"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/roderik/bellwether.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/roderik/bellwether/issues"
17
+ },
18
+ "homepage": "https://github.com/roderik/bellwether#readme",
19
+ "keywords": [
20
+ "ci",
21
+ "pr",
22
+ "review",
23
+ "merge",
24
+ "github",
25
+ "claude",
26
+ "agent",
27
+ "skill"
28
+ ],
29
+ "bin": {
30
+ "bellwether": "./dist/bin.js"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src",
35
+ "skills",
36
+ "hooks",
37
+ ".claude-plugin",
38
+ "SKILL.md"
39
+ ],
40
+ "devDependencies": {
41
+ "@types/node": "latest",
42
+ "@vitest/coverage-v8": "^4.1.2",
43
+ "oxfmt": "^0.42.0",
44
+ "oxlint": "^1.57.0",
45
+ "oxlint-tsgolint": "^0.18.1",
46
+ "vitest": "^4.1.2",
47
+ "zile": "latest",
48
+ "typescript": "^6.0.2"
49
+ },
50
+ "dependencies": {
51
+ "@clack/prompts": "^1.1.0",
52
+ "incur": "^0.3.13"
53
+ },
54
+ "scripts": {
55
+ "build": "zile",
56
+ "dev": "zile dev",
57
+ "format": "oxfmt --write",
58
+ "check": "oxlint",
59
+ "test": "vitest run",
60
+ "test:coverage": "vitest run --coverage"
61
+ },
62
+ "license": "MIT",
63
+ "sideEffects": false,
64
+ "exports": {}
65
+ }
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: bellwether
3
+ description: >-
4
+ Bring the current PR to a mergeable state: CI green, all review comments resolved,
5
+ no merge conflicts. Self-contained — watches CI, fixes issues, watches again until
6
+ merge-ready. Use when the user wants to keep a PR green, auto-fix CI, resolve
7
+ review comments, or says "get this merged".
8
+ user-invocable: true
9
+ ---
10
+
11
+ # Bellwether — Drive PR to Merge-Ready
12
+
13
+ Self-contained cycle: watch CI → fix failures → address reviews → watch again → until merge-ready.
14
+
15
+ ## The Loop
16
+
17
+ ```
18
+ 1. npx -y bellwether@latest check --watch (blocks until CI completes)
19
+ 2. If pr.ready=true → done, report "merge-ready"
20
+ 3. If pr.state=merged|closed → done, report status
21
+ 4. If CI failures → fix them (Step 2), push, go to 1
22
+ 5. If unresolved reviews → address them (Step 3), push, go to 1
23
+ 6. If pr.mergeable=dirty|behind → /sync, push, go to 1
24
+ 7. If timed out → go to 1 (restart watch)
25
+ ```
26
+
27
+ ## What `npx -y bellwether@latest check --watch` returns
28
+
29
+ Three sections:
30
+
31
+ - **pr** — `state` (open/closed/merged), `mergeable` (clean/dirty/behind/blocked/unstable), `ready` (true when all conditions met)
32
+ - **ci** — SHA, check summary, and for each failing check: the filtered error log with file paths and line numbers
33
+ - **reviews** — unresolved review comments with full body, file path, and line number
34
+
35
+ ## Step 2: Fix CI failures
36
+
37
+ For each `FAIL` key in the CI section:
38
+
39
+ 1. **Read the error log** — it contains actual compiler/test output with file paths and line numbers.
40
+ 2. **Fix the code** — minimal change that resolves the root cause.
41
+ 3. **Verify locally** — run the same check that failed.
42
+ 4. **Stage, commit, push** — stage files by name (never `git add -A`).
43
+ 5. **Go to step 1** — restart the watch. New CI runs, new bot comments may arrive.
44
+
45
+ ## Step 3: Address review comments
46
+
47
+ For each `REVIEW` key in the reviews section:
48
+
49
+ ### Classify
50
+
51
+ **Bot comments** (CodeRabbit, Copilot, Cursor Bugbot):
52
+ - **True positive** — real bug → fix the code
53
+ - **False positive** — bot doesn't understand the pattern → won't fix
54
+ - **Uncertain** — ask the user
55
+
56
+ **Human comments**:
57
+ - **Actionable** — fix the code
58
+ - **Discussion** — ask the user
59
+ - **Already addressed** — reply only
60
+
61
+ ### Fix and commit
62
+
63
+ Fix all true positives and actionable items in a single commit. Verify locally, push.
64
+
65
+ ### Reply
66
+
67
+ For inline code review comments (with file path), reply individually with `--resolve`:
68
+
69
+ ```bash
70
+ npx -y bellwether@latest check --reply "<id>:Fixed in <hash>. <description>" --resolve
71
+ ```
72
+
73
+ For top-level bot comments (no file path), post a single summary reply:
74
+
75
+ ```
76
+ Addressed review findings in <hash>:
77
+ - REVIEW 456: Fixed null check in src/foo.ts
78
+ - REVIEW 789: Won't fix — pattern is intentional
79
+ ```
80
+
81
+ Every comment gets a response. Use `--resolve` on every reply.
82
+
83
+ After all replies, go to step 1 — restart the watch.
84
+
85
+ ## Principles
86
+
87
+ - **One fix per watch cycle** — fix CI OR reviews, not both. Push and restart watch.
88
+ - **Minimal changes** — don't refactor unrelated code.
89
+ - **Every comment gets a response** — no silent ignores.
90
+ - **Ask when uncertain** — don't guess on architectural questions.
91
+ - **Verify before pushing** — always run the failing check locally first.
92
+ - **Never stop until terminal** — if `pr.ready` is false, keep going.
package/src/bin.ts ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+
3
+ const cmd = process.argv[2];
4
+
5
+ // Fast path for hook commands — bypass incur entirely for speed
6
+ if (cmd === "hook-check") {
7
+ const { run } = await import("./commands/hook-check.js");
8
+ await run();
9
+ } else if (cmd === "hook-add") {
10
+ const { run } = await import("./commands/hook-add.js");
11
+ await run();
12
+ } else {
13
+ const { cli } = await import("./cli.js");
14
+ void cli.serve();
15
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { Cli, z } from "incur";
2
+ import { bootstrap, type Context } from "./context.js";
3
+ import { checkCommand } from "./commands/check.js";
4
+
5
+ const cli = Cli.create("bellwether", {
6
+ version: "0.0.1",
7
+ description: "Monitor GitHub PRs — review comments and CI status",
8
+ vars: z.object({
9
+ ctx: z.custom<Context>(),
10
+ }),
11
+ env: z.object({
12
+ GITHUB_TOKEN: z
13
+ .string()
14
+ .optional()
15
+ .describe("GitHub personal access token (also reads GH_TOKEN, .env.local, gh CLI)"),
16
+ GH_TOKEN: z.string().optional().describe("Alternative GitHub token env var"),
17
+ GH_REPO: z.string().optional().describe("Override repository in owner/repo format"),
18
+ HTTPS_PROXY: z.string().optional().describe("HTTPS proxy URL for corporate/cloud environments"),
19
+ }),
20
+ sync: {
21
+ include: ["_root"],
22
+ suggestions: [
23
+ "check CI and reviews for this PR",
24
+ "check CI and reviews for PR 123",
25
+ "watch CI until complete",
26
+ "reply to review comment 12345",
27
+ ],
28
+ },
29
+ });
30
+
31
+ cli.use(async (c, next) => {
32
+ c.set("ctx", await bootstrap());
33
+ await next();
34
+ });
35
+
36
+ cli.command("check", checkCommand as unknown as Parameters<typeof cli.command>[1]);
37
+
38
+ export { cli };
39
+ export default cli;
@@ -0,0 +1,251 @@
1
+ import { z } from "incur";
2
+ import { resolvePR, type Context } from "../context.js";
3
+ import { fetchPRMergeState, type PRMergeState, type CIStatus } from "../github/index.js";
4
+ import { getCISection } from "./ci.js";
5
+ import {
6
+ getReviewsList,
7
+ getReviewDetail,
8
+ postReply,
9
+ formatReviewsSection,
10
+ commentSchema,
11
+ } from "./reviews.js";
12
+
13
+ function buildPRSection(
14
+ mergeState: PRMergeState,
15
+ ciStatus: CIStatus,
16
+ unresolvedCount: number,
17
+ ): { state: string; mergeable: string; ready: boolean } {
18
+ return {
19
+ state: mergeState.state,
20
+ mergeable: mergeState.mergeableState,
21
+ ready:
22
+ mergeState.state === "open" &&
23
+ mergeState.mergeableState === "clean" &&
24
+ ciStatus.failing === 0 &&
25
+ ciStatus.pending === 0 &&
26
+ unresolvedCount === 0,
27
+ };
28
+ }
29
+
30
+ interface CheckCommandContext {
31
+ var: { ctx: Context };
32
+ args: { pr?: number };
33
+ options: {
34
+ watch: boolean;
35
+ interval: number;
36
+ timeout: number;
37
+ unresolved: boolean;
38
+ unanswered: boolean;
39
+ botsOnly: boolean;
40
+ humansOnly: boolean;
41
+ reply?: string;
42
+ resolve: boolean;
43
+ detail?: number;
44
+ };
45
+ ok: (data: Record<string, unknown>, meta?: Record<string, unknown>) => unknown;
46
+ error: (data: { message: string }) => unknown;
47
+ }
48
+
49
+ export const checkCommand = {
50
+ description: "Show CI status and review comments for a PR",
51
+ hint: "Combines CI checks and review comments. Use --reply and --detail for review actions. With --watch, polls until CI completes.",
52
+ args: z.object({
53
+ pr: z.coerce.number().optional().describe("PR number (auto-detects from branch)"),
54
+ }),
55
+ options: z.object({
56
+ watch: z.boolean().default(false).describe("Poll until CI completes"),
57
+ interval: z.coerce.number().default(30).describe("Poll interval in seconds"),
58
+ timeout: z.coerce.number().default(1800).describe("Timeout in seconds"),
59
+ unresolved: z.boolean().default(false).describe("Show only unresolved comments"),
60
+ unanswered: z.boolean().default(false).describe("Show only unanswered comments"),
61
+ botsOnly: z.boolean().default(false).describe("Only bot comments"),
62
+ humansOnly: z.boolean().default(false).describe("Only human comments"),
63
+ reply: z.string().optional().describe("Reply to comment: <id>:<message>"),
64
+ resolve: z.boolean().default(false).describe("Resolve thread after replying"),
65
+ detail: z.coerce.number().optional().describe("Show full detail for comment ID"),
66
+ }),
67
+ alias: {
68
+ watch: "w",
69
+ interval: "i",
70
+ unresolved: "u",
71
+ unanswered: "a",
72
+ botsOnly: "b",
73
+ humansOnly: "H",
74
+ reply: "r",
75
+ detail: "d",
76
+ },
77
+ usage: [
78
+ {},
79
+ { args: { pr: true } },
80
+ { options: { watch: true } },
81
+ { options: { detail: true } },
82
+ { options: { reply: true } },
83
+ { options: { reply: true, resolve: true } },
84
+ ],
85
+ output: z.object({
86
+ pr: z
87
+ .object({
88
+ state: z.string().describe("open | closed | merged"),
89
+ mergeable: z
90
+ .string()
91
+ .describe("Merge state: clean, dirty, blocked, behind, unstable, unknown, has_hooks"),
92
+ ready: z
93
+ .boolean()
94
+ .describe(
95
+ "true when state=open, mergeableState=clean, all CI passing, zero unresolved reviews",
96
+ ),
97
+ })
98
+ .optional(),
99
+ ci: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
100
+ reviews: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
101
+ comment: commentSchema.optional(),
102
+ replied: z.boolean().optional(),
103
+ commentId: z.number().optional(),
104
+ message: z.string().optional(),
105
+ url: z.string().optional(),
106
+ resolved: z.boolean().optional(),
107
+ allPassing: z.boolean().optional(),
108
+ timedOut: z.boolean().optional(),
109
+ }),
110
+ examples: [
111
+ { description: "CI status + review comments for current branch" },
112
+ { options: { watch: true }, description: "Watch until CI completes" },
113
+ { options: { unresolved: true }, description: "Show only unresolved reviews" },
114
+ { options: { detail: 456 }, description: "Full detail for comment 456" },
115
+ { options: { reply: "456:Fixed in latest commit" }, description: "Reply to comment" },
116
+ { options: { reply: "456:Done", resolve: true }, description: "Reply and resolve" },
117
+ ],
118
+ async run(c: CheckCommandContext) {
119
+ const ctx: Context = c.var.ctx;
120
+ const { prNumber, headSha } = await resolvePR(ctx, c.args.pr);
121
+ const opts = c.options;
122
+
123
+ // Reply mode — no CI fetch needed
124
+ if (opts.reply) {
125
+ const result = await postReply(ctx, prNumber, opts.reply, opts.resolve);
126
+ return c.ok(result);
127
+ }
128
+
129
+ // Detail mode — no CI fetch needed
130
+ if (opts.detail) {
131
+ const comment = await getReviewDetail(ctx, prNumber, opts.detail);
132
+ if (!comment) {
133
+ return c.error({ message: `Comment ${opts.detail} not found` });
134
+ }
135
+ return c.ok({ comment });
136
+ }
137
+
138
+ const filterOpts = {
139
+ unresolved: opts.unresolved,
140
+ unanswered: opts.unanswered,
141
+ botsOnly: opts.botsOnly,
142
+ humansOnly: opts.humansOnly,
143
+ };
144
+
145
+ // Watch mode — poll until CI terminal
146
+ if (opts.watch) {
147
+ const start = Date.now();
148
+ while (true) {
149
+ const [mergeState, { status, flat: ciFlat }, reviewData] = await Promise.all([
150
+ fetchPRMergeState(
151
+ ctx.repoInfo.owner,
152
+ ctx.repoInfo.repo,
153
+ prNumber,
154
+ ctx.token,
155
+ ctx.proxyFetch,
156
+ ),
157
+ getCISection(ctx, prNumber, headSha),
158
+ getReviewsList(ctx, prNumber, filterOpts),
159
+ ]);
160
+
161
+ const reviewsFlat = formatReviewsSection(reviewData.comments);
162
+ const unresolvedCount = reviewData.comments.filter(
163
+ (cm) => !(cm.isResolved || cm.hasHumanReply),
164
+ ).length;
165
+ const prSection = buildPRSection(mergeState, status, unresolvedCount);
166
+
167
+ if (status.failing === 0 && status.pending === 0) {
168
+ return c.ok({
169
+ pr: prSection,
170
+ ci: { ...ciFlat, allPassing: true },
171
+ reviews: reviewsFlat,
172
+ });
173
+ }
174
+
175
+ if (status.failing > 0 && status.pending === 0) {
176
+ return c.ok(
177
+ {
178
+ pr: prSection,
179
+ ci: { ...ciFlat, allPassing: false },
180
+ reviews: reviewsFlat,
181
+ },
182
+ {
183
+ cta: {
184
+ description: "Failed checks detected:",
185
+ commands: [
186
+ { command: "check --unresolved", description: "Show unresolved reviews" },
187
+ ],
188
+ },
189
+ },
190
+ );
191
+ }
192
+
193
+ if ((Date.now() - start) / 1000 >= opts.timeout) {
194
+ return c.ok(
195
+ {
196
+ pr: prSection,
197
+ ci: { ...ciFlat, timedOut: true },
198
+ reviews: reviewsFlat,
199
+ },
200
+ {
201
+ cta: {
202
+ description: "Timed out, checks still running:",
203
+ commands: [
204
+ {
205
+ command: `check --watch --timeout ${opts.timeout * 2}`,
206
+ description: "Retry with longer timeout",
207
+ },
208
+ ],
209
+ },
210
+ },
211
+ );
212
+ }
213
+
214
+ await new Promise<void>((r) => setTimeout(r, opts.interval * 1000));
215
+ }
216
+ }
217
+
218
+ // Default: fetch all in parallel
219
+ const [mergeState, { status, flat: ciFlat }, reviewData] = await Promise.all([
220
+ fetchPRMergeState(ctx.repoInfo.owner, ctx.repoInfo.repo, prNumber, ctx.token, ctx.proxyFetch),
221
+ getCISection(ctx, prNumber, headSha),
222
+ getReviewsList(ctx, prNumber, filterOpts),
223
+ ]);
224
+
225
+ const reviewsFlat = formatReviewsSection(reviewData.comments);
226
+ const unresolvedCount = reviewData.comments.filter(
227
+ (cm) => !(cm.isResolved || cm.hasHumanReply),
228
+ ).length;
229
+ const prSection = buildPRSection(mergeState, status, unresolvedCount);
230
+
231
+ return c.ok(
232
+ { pr: prSection, ci: ciFlat, reviews: reviewsFlat },
233
+ {
234
+ cta:
235
+ status.pending > 0
236
+ ? {
237
+ description: "Checks still running:",
238
+ commands: [{ command: "check --watch", description: "Watch until complete" }],
239
+ }
240
+ : status.failing > 0
241
+ ? {
242
+ description: "Checks failing:",
243
+ commands: [
244
+ { command: "check --unresolved", description: "Show unresolved reviews" },
245
+ ],
246
+ }
247
+ : undefined,
248
+ },
249
+ );
250
+ },
251
+ };
@@ -0,0 +1,44 @@
1
+ import { type Context } from "../context.js";
2
+ import { fetchCIStatus, type CIStatus } from "../github/index.js";
3
+
4
+ export function flatten(
5
+ status: CIStatus,
6
+ extra?: Record<string, boolean>,
7
+ ): Record<string, string | number | boolean> {
8
+ const out: Record<string, string | number | boolean> = {
9
+ sha: status.sha,
10
+ checks: `${status.total} total, ${status.passing} passing, ${status.failing} failing, ${status.pending} pending`,
11
+ };
12
+ if (status.passed.length > 0) {
13
+ out.passed = status.passed.join(", ");
14
+ }
15
+ if (status.in_progress.length > 0) {
16
+ out.in_progress = status.in_progress.join(", ");
17
+ }
18
+ for (const f of status.failures) {
19
+ const label =
20
+ f.conclusion === "failure" ? `FAIL ${f.name}` : `${f.conclusion.toUpperCase()} ${f.name}`;
21
+ out[label] = f.log;
22
+ }
23
+ if (extra) {
24
+ Object.assign(out, extra);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ export async function getCISection(
30
+ ctx: Context,
31
+ prNumber: number,
32
+ headSha?: string,
33
+ ): Promise<{ status: CIStatus; flat: Record<string, string | number | boolean> }> {
34
+ const { token, repoInfo, proxyFetch } = ctx;
35
+ const status = await fetchCIStatus(
36
+ repoInfo.owner,
37
+ repoInfo.repo,
38
+ prNumber,
39
+ token,
40
+ proxyFetch,
41
+ headSha,
42
+ );
43
+ return { status, flat: flatten(status) };
44
+ }