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.
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/SKILL.md +92 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +17 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/check.d.ts +191 -0
- package/dist/commands/check.d.ts.map +1 -0
- package/dist/commands/check.js +186 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/ci.d.ts +8 -0
- package/dist/commands/ci.d.ts.map +1 -0
- package/dist/commands/ci.js +28 -0
- package/dist/commands/ci.js.map +1 -0
- package/dist/commands/hook-add.d.ts +2 -0
- package/dist/commands/hook-add.d.ts.map +1 -0
- package/dist/commands/hook-add.js +97 -0
- package/dist/commands/hook-add.js.map +1 -0
- package/dist/commands/hook-check.d.ts +2 -0
- package/dist/commands/hook-check.d.ts.map +1 -0
- package/dist/commands/hook-check.js +29 -0
- package/dist/commands/hook-check.js.map +1 -0
- package/dist/commands/reviews.d.ts +74 -0
- package/dist/commands/reviews.d.ts.map +1 -0
- package/dist/commands/reviews.js +133 -0
- package/dist/commands/reviews.js.map +1 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +53 -0
- package/dist/context.js.map +1 -0
- package/dist/github/auth.d.ts +9 -0
- package/dist/github/auth.d.ts.map +1 -0
- package/dist/github/auth.js +49 -0
- package/dist/github/auth.js.map +1 -0
- package/dist/github/checks.d.ts +19 -0
- package/dist/github/checks.d.ts.map +1 -0
- package/dist/github/checks.js +112 -0
- package/dist/github/checks.js.map +1 -0
- package/dist/github/comments.d.ts +86 -0
- package/dist/github/comments.d.ts.map +1 -0
- package/dist/github/comments.js +309 -0
- package/dist/github/comments.js.map +1 -0
- package/dist/github/fetch.d.ts +21 -0
- package/dist/github/fetch.d.ts.map +1 -0
- package/dist/github/fetch.js +177 -0
- package/dist/github/fetch.js.map +1 -0
- package/dist/github/index.d.ts +6 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/github/index.js +6 -0
- package/dist/github/index.js.map +1 -0
- package/dist/github/repo.d.ts +27 -0
- package/dist/github/repo.d.ts.map +1 -0
- package/dist/github/repo.js +72 -0
- package/dist/github/repo.js.map +1 -0
- package/hooks/hooks.json +29 -0
- package/package.json +65 -0
- package/skills/bellwether/SKILL.md +92 -0
- package/src/bin.ts +15 -0
- package/src/cli.ts +39 -0
- package/src/commands/check.ts +251 -0
- package/src/commands/ci.ts +44 -0
- package/src/commands/hook-add.ts +139 -0
- package/src/commands/hook-check.ts +35 -0
- package/src/commands/reviews.ts +225 -0
- package/src/context.ts +86 -0
- package/src/github/auth.ts +40 -0
- package/src/github/checks.ts +187 -0
- package/src/github/comments.ts +522 -0
- package/src/github/fetch.ts +233 -0
- package/src/github/index.ts +35 -0
- 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":"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"}
|
package/hooks/hooks.json
ADDED
|
@@ -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
|
+
}
|