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,139 @@
|
|
|
1
|
+
// Standalone hook installer — bypasses incur.
|
|
2
|
+
// Configures PostToolUse hooks in Claude Code and Codex globally.
|
|
3
|
+
// Idempotent: checks if already configured, updates if needed.
|
|
4
|
+
|
|
5
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
|
|
10
|
+
const HOOK_COMMAND = "npx -y bellwether@latest hook-check";
|
|
11
|
+
const HOOK_TIMEOUT = 15;
|
|
12
|
+
const BELLWETHER_MARKER = "bellwether@latest hook-check";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Claude Code: ~/.claude/settings.json
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
interface ClaudeHookEntry {
|
|
19
|
+
type: string;
|
|
20
|
+
command: string;
|
|
21
|
+
if?: string;
|
|
22
|
+
timeout?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ClaudeMatcherGroup {
|
|
26
|
+
matcher: string;
|
|
27
|
+
hooks: ClaudeHookEntry[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildClaudeHooks(): ClaudeMatcherGroup {
|
|
31
|
+
return {
|
|
32
|
+
matcher: "Bash",
|
|
33
|
+
hooks: [
|
|
34
|
+
{ type: "command", if: "Bash(git push*)", command: HOOK_COMMAND, timeout: HOOK_TIMEOUT },
|
|
35
|
+
{ type: "command", if: "Bash(gh pr create*)", command: HOOK_COMMAND, timeout: HOOK_TIMEOUT },
|
|
36
|
+
{ type: "command", if: "Bash(gh pr ready*)", command: HOOK_COMMAND, timeout: HOOK_TIMEOUT },
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function configureClaude(): Promise<string> {
|
|
42
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
43
|
+
let settings: Record<string, unknown> = {};
|
|
44
|
+
|
|
45
|
+
if (existsSync(settingsPath)) {
|
|
46
|
+
settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record<string, unknown>;
|
|
47
|
+
} else {
|
|
48
|
+
await mkdir(join(homedir(), ".claude"), { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const hooks = (settings.hooks ?? {}) as Record<string, unknown[]>;
|
|
52
|
+
const postToolUse = (hooks.PostToolUse ?? []) as ClaudeMatcherGroup[];
|
|
53
|
+
|
|
54
|
+
// Remove any existing bellwether hooks
|
|
55
|
+
const cleaned: ClaudeMatcherGroup[] = [];
|
|
56
|
+
for (const group of postToolUse) {
|
|
57
|
+
const filtered = group.hooks.filter((h) => !h.command.includes(BELLWETHER_MARKER));
|
|
58
|
+
if (filtered.length > 0) {
|
|
59
|
+
cleaned.push({ ...group, hooks: filtered });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Add fresh bellwether hooks
|
|
64
|
+
cleaned.push(buildClaudeHooks());
|
|
65
|
+
|
|
66
|
+
hooks.PostToolUse = cleaned;
|
|
67
|
+
settings.hooks = hooks;
|
|
68
|
+
|
|
69
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
70
|
+
return settingsPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Codex: ~/.codex/hooks.json
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async function configureCodex(): Promise<string | null> {
|
|
78
|
+
const codexDir = join(homedir(), ".codex");
|
|
79
|
+
if (!existsSync(codexDir)) {
|
|
80
|
+
return null; // Codex not installed
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const hooksPath = join(codexDir, "hooks.json");
|
|
84
|
+
let config: Record<string, unknown> = {};
|
|
85
|
+
|
|
86
|
+
if (existsSync(hooksPath)) {
|
|
87
|
+
config = JSON.parse(await readFile(hooksPath, "utf-8")) as Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface CodexMatcherGroup {
|
|
91
|
+
matcher: string;
|
|
92
|
+
hooks: { command: string; type: string; timeout?: number }[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const hooks = (config.hooks ?? {}) as Record<string, unknown[]>;
|
|
96
|
+
const postToolUse = (hooks.PostToolUse ?? []) as CodexMatcherGroup[];
|
|
97
|
+
|
|
98
|
+
// Remove any existing bellwether hooks
|
|
99
|
+
const cleaned: CodexMatcherGroup[] = [];
|
|
100
|
+
for (const group of postToolUse) {
|
|
101
|
+
const filtered = group.hooks.filter((h) => !h.command.includes(BELLWETHER_MARKER));
|
|
102
|
+
if (filtered.length > 0) {
|
|
103
|
+
cleaned.push({ ...group, hooks: filtered });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Add fresh codex hooks (no `if` — codex doesn't support it)
|
|
108
|
+
cleaned.push({
|
|
109
|
+
matcher: "^Bash$",
|
|
110
|
+
hooks: [{ type: "command", command: HOOK_COMMAND, timeout: HOOK_TIMEOUT }],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
hooks.PostToolUse = cleaned;
|
|
114
|
+
config.hooks = hooks;
|
|
115
|
+
|
|
116
|
+
await writeFile(hooksPath, JSON.stringify(config, null, 2) + "\n");
|
|
117
|
+
return hooksPath;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Main
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export async function run(): Promise<void> {
|
|
125
|
+
console.log("Configuring bellwether hooks...\n");
|
|
126
|
+
|
|
127
|
+
const claudePath = await configureClaude();
|
|
128
|
+
console.log(` Claude Code: ${claudePath}`);
|
|
129
|
+
|
|
130
|
+
const codexPath = await configureCodex();
|
|
131
|
+
if (codexPath) {
|
|
132
|
+
console.log(` Codex: ${codexPath}`);
|
|
133
|
+
} else {
|
|
134
|
+
console.log(" Codex: skipped (not installed)");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log("\nHooks will trigger when git push or gh pr create/ready is detected.");
|
|
138
|
+
console.log("The LLM will be prompted to run bellwether to monitor the PR.");
|
|
139
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Standalone hook handler — bypasses incur for speed.
|
|
2
|
+
// Reads Claude/Codex PostToolUse JSON from stdin.
|
|
3
|
+
// If the command is a PR-related action, outputs additionalContext
|
|
4
|
+
// telling the LLM to start monitoring with bellwether.
|
|
5
|
+
|
|
6
|
+
const PR_PATTERN = /\bgit\s+push\b|\bgh\s+pr\s+(create|ready)\b/;
|
|
7
|
+
|
|
8
|
+
export async function run(): Promise<void> {
|
|
9
|
+
const chunks: Buffer[] = [];
|
|
10
|
+
for await (const chunk of process.stdin) {
|
|
11
|
+
chunks.push(chunk as Buffer);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let command = "";
|
|
15
|
+
try {
|
|
16
|
+
const input = JSON.parse(Buffer.concat(chunks).toString()) as {
|
|
17
|
+
tool_input?: { command?: string };
|
|
18
|
+
hook_event_name?: string;
|
|
19
|
+
};
|
|
20
|
+
command = input.tool_input?.command ?? "";
|
|
21
|
+
|
|
22
|
+
if (PR_PATTERN.test(command)) {
|
|
23
|
+
const output = {
|
|
24
|
+
hookSpecificOutput: {
|
|
25
|
+
hookEventName: input.hook_event_name ?? "PostToolUse",
|
|
26
|
+
additionalContext:
|
|
27
|
+
"PR pushed. Run `npx -y bellwether@latest check --watch` to monitor CI and reviews until merge-ready.",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
process.stdout.write(JSON.stringify(output));
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// Malformed input — exit silently
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Context } from "../context.js";
|
|
3
|
+
import {
|
|
4
|
+
fetchPRComments,
|
|
5
|
+
processComments,
|
|
6
|
+
filterComments,
|
|
7
|
+
replyToComment,
|
|
8
|
+
resolveThread,
|
|
9
|
+
type ProcessedComment,
|
|
10
|
+
type ProxyFetch,
|
|
11
|
+
} from "../github/index.js";
|
|
12
|
+
|
|
13
|
+
export const replySchema = z.object({
|
|
14
|
+
id: z.number().describe("Reply ID"),
|
|
15
|
+
user: z.string().describe("Author login"),
|
|
16
|
+
body: z.string().describe("Reply body (cleaned)"),
|
|
17
|
+
createdAt: z.string().describe("ISO timestamp"),
|
|
18
|
+
isBot: z.boolean().describe("Whether author is a bot"),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const commentSchema = z.object({
|
|
22
|
+
id: z.number().describe("Comment ID (use with --detail or --reply)"),
|
|
23
|
+
type: z
|
|
24
|
+
.enum(["review_comment", "issue_comment", "review"])
|
|
25
|
+
.describe("CODE = inline, COMMENT = PR-level, REVIEW = review summary"),
|
|
26
|
+
user: z.string().describe("Author login"),
|
|
27
|
+
isBot: z.boolean().describe("Whether author is a bot"),
|
|
28
|
+
path: z.string().nullable().describe("File path (inline comments only)"),
|
|
29
|
+
line: z.number().nullable().describe("Line number (inline comments only)"),
|
|
30
|
+
diffHunk: z.string().nullable().describe("Surrounding diff context"),
|
|
31
|
+
body: z.string().describe("Comment body (cleaned of bot boilerplate)"),
|
|
32
|
+
createdAt: z.string().describe("ISO timestamp"),
|
|
33
|
+
updatedAt: z.string().describe("ISO timestamp of last update"),
|
|
34
|
+
url: z.string().describe("GitHub URL"),
|
|
35
|
+
replies: z.array(replySchema).describe("Thread replies"),
|
|
36
|
+
hasHumanReply: z.boolean().describe("Whether a human has replied"),
|
|
37
|
+
hasAnyReply: z.boolean().describe("Whether any reply exists"),
|
|
38
|
+
isResolved: z.boolean().describe("Whether the thread is resolved"),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Pure logic functions
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export async function getReviewsList(
|
|
46
|
+
ctx: Context,
|
|
47
|
+
prNumber: number,
|
|
48
|
+
filterOpts: {
|
|
49
|
+
unresolved: boolean;
|
|
50
|
+
unanswered: boolean;
|
|
51
|
+
botsOnly: boolean;
|
|
52
|
+
humansOnly: boolean;
|
|
53
|
+
},
|
|
54
|
+
): Promise<{ comments: ProcessedComment[]; total: number }> {
|
|
55
|
+
const { token, repoInfo, proxyFetch } = ctx;
|
|
56
|
+
|
|
57
|
+
const rawData = await fetchPRComments(repoInfo.owner, repoInfo.repo, prNumber, token, proxyFetch);
|
|
58
|
+
const processed = processComments(rawData);
|
|
59
|
+
const filtered = filterComments(processed, {
|
|
60
|
+
botsOnly: filterOpts.botsOnly,
|
|
61
|
+
humansOnly: filterOpts.humansOnly,
|
|
62
|
+
filter: filterOpts.unresolved ? "unresolved" : filterOpts.unanswered ? "unanswered" : null,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return { comments: filtered, total: filtered.length };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function getReviewDetail(
|
|
69
|
+
ctx: Context,
|
|
70
|
+
prNumber: number,
|
|
71
|
+
commentId: number,
|
|
72
|
+
): Promise<ProcessedComment | undefined> {
|
|
73
|
+
const { token, repoInfo, proxyFetch } = ctx;
|
|
74
|
+
|
|
75
|
+
const rawData = await fetchPRComments(repoInfo.owner, repoInfo.repo, prNumber, token, proxyFetch);
|
|
76
|
+
const processed = processComments(rawData);
|
|
77
|
+
return processed.find((cm) => cm.id === commentId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function postReply(
|
|
81
|
+
ctx: Context,
|
|
82
|
+
prNumber: number,
|
|
83
|
+
replyStr: string,
|
|
84
|
+
shouldResolve: boolean,
|
|
85
|
+
): Promise<{
|
|
86
|
+
replied: boolean;
|
|
87
|
+
commentId: number;
|
|
88
|
+
message: string;
|
|
89
|
+
url: string;
|
|
90
|
+
resolved?: boolean;
|
|
91
|
+
resolveError?: string;
|
|
92
|
+
}> {
|
|
93
|
+
const { token, repoInfo, proxyFetch } = ctx;
|
|
94
|
+
|
|
95
|
+
const colonIdx = replyStr.indexOf(":");
|
|
96
|
+
if (colonIdx === -1) {
|
|
97
|
+
throw new Error("--reply format is <id>:<message>");
|
|
98
|
+
}
|
|
99
|
+
const commentId = Number(replyStr.slice(0, colonIdx));
|
|
100
|
+
const message = replyStr.slice(colonIdx + 1);
|
|
101
|
+
|
|
102
|
+
const result = await replyToComment(
|
|
103
|
+
repoInfo.owner,
|
|
104
|
+
repoInfo.repo,
|
|
105
|
+
prNumber,
|
|
106
|
+
commentId,
|
|
107
|
+
message,
|
|
108
|
+
token,
|
|
109
|
+
proxyFetch,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
let resolved: boolean | undefined;
|
|
113
|
+
let resolveError: string | undefined;
|
|
114
|
+
if (shouldResolve) {
|
|
115
|
+
try {
|
|
116
|
+
const res = await resolveThread(
|
|
117
|
+
repoInfo.owner,
|
|
118
|
+
repoInfo.repo,
|
|
119
|
+
prNumber,
|
|
120
|
+
commentId,
|
|
121
|
+
token,
|
|
122
|
+
proxyFetch,
|
|
123
|
+
);
|
|
124
|
+
resolved = "resolved" in res && res.resolved;
|
|
125
|
+
} catch (error: unknown) {
|
|
126
|
+
resolved = false;
|
|
127
|
+
resolveError = error instanceof Error ? error.message : "Failed to resolve thread";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { replied: true, commentId, message, url: result.html_url, resolved, resolveError };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Format helper
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export function formatReviewsSection(
|
|
139
|
+
comments: ProcessedComment[],
|
|
140
|
+
): Record<string, string | number> {
|
|
141
|
+
const unresolvedCount = comments.filter((c) => !(c.isResolved || c.hasHumanReply)).length;
|
|
142
|
+
const unansweredCount = comments.filter((c) => !c.hasAnyReply).length;
|
|
143
|
+
|
|
144
|
+
const result: Record<string, string | number> = {
|
|
145
|
+
total: `${unresolvedCount} unresolved, ${unansweredCount} unanswered`,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const comment of comments) {
|
|
149
|
+
const location = comment.path
|
|
150
|
+
? `${comment.path}${comment.line ? `:${comment.line}` : ""}`
|
|
151
|
+
: null;
|
|
152
|
+
const key = location ? `REVIEW ${comment.id} ${location}` : `REVIEW ${comment.id}`;
|
|
153
|
+
|
|
154
|
+
const body = comment.body;
|
|
155
|
+
|
|
156
|
+
result[key] = body;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Watch helper
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
export async function watchForComments(
|
|
167
|
+
context: {
|
|
168
|
+
owner: string;
|
|
169
|
+
repo: string;
|
|
170
|
+
prNumber: number;
|
|
171
|
+
token: string;
|
|
172
|
+
proxyFetch: ProxyFetch;
|
|
173
|
+
},
|
|
174
|
+
options: {
|
|
175
|
+
botsOnly?: boolean;
|
|
176
|
+
humansOnly?: boolean;
|
|
177
|
+
filter?: "unresolved" | "unanswered" | null;
|
|
178
|
+
watchInterval: number;
|
|
179
|
+
watchTimeout: number;
|
|
180
|
+
},
|
|
181
|
+
) {
|
|
182
|
+
const { owner, repo, prNumber, token, proxyFetch } = context;
|
|
183
|
+
const seenIds = new Set<number>();
|
|
184
|
+
const startTime = Date.now();
|
|
185
|
+
|
|
186
|
+
const initialData = await fetchPRComments(owner, repo, prNumber, token, proxyFetch);
|
|
187
|
+
const initialProcessed = processComments(initialData);
|
|
188
|
+
const initialFiltered = filterComments(initialProcessed, options);
|
|
189
|
+
for (const comment of initialFiltered) {
|
|
190
|
+
seenIds.add(comment.id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
while (true) {
|
|
194
|
+
await new Promise<void>((r) => setTimeout(r, options.watchInterval * 1000));
|
|
195
|
+
|
|
196
|
+
const rawData = await fetchPRComments(owner, repo, prNumber, token, proxyFetch);
|
|
197
|
+
const processed = processComments(rawData);
|
|
198
|
+
const filtered = filterComments(processed, options);
|
|
199
|
+
const newComments = filtered.filter((cm) => !seenIds.has(cm.id));
|
|
200
|
+
|
|
201
|
+
if (newComments.length > 0) {
|
|
202
|
+
for (const cm of newComments) {
|
|
203
|
+
seenIds.add(cm.id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Grace period for bot batches
|
|
207
|
+
await new Promise<void>((r) => setTimeout(r, 5_000));
|
|
208
|
+
const graceData = await fetchPRComments(owner, repo, prNumber, token, proxyFetch);
|
|
209
|
+
const graceProcessed = processComments(graceData);
|
|
210
|
+
const graceFiltered = filterComments(graceProcessed, options);
|
|
211
|
+
for (const cm of graceFiltered) {
|
|
212
|
+
if (!seenIds.has(cm.id)) {
|
|
213
|
+
seenIds.add(cm.id);
|
|
214
|
+
newComments.push(cm);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { newComments, total: newComments.length, timedOut: false };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (Math.round((Date.now() - startTime) / 1000) >= options.watchTimeout) {
|
|
222
|
+
return { newComments: [], total: 0, timedOut: true };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as clack from "@clack/prompts";
|
|
2
|
+
import {
|
|
3
|
+
getGitHubToken,
|
|
4
|
+
getProxyFetch,
|
|
5
|
+
getRepoInfo,
|
|
6
|
+
getCurrentBranch,
|
|
7
|
+
findPRForBranch,
|
|
8
|
+
listOpenPRs,
|
|
9
|
+
type RepoInfo,
|
|
10
|
+
type ProxyFetch,
|
|
11
|
+
} from "./github/index.js";
|
|
12
|
+
|
|
13
|
+
export interface Context {
|
|
14
|
+
token: string;
|
|
15
|
+
repoInfo: RepoInfo;
|
|
16
|
+
proxyFetch: ProxyFetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function bootstrap(): Promise<Context> {
|
|
20
|
+
const token = await getGitHubToken();
|
|
21
|
+
if (!token) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
[
|
|
24
|
+
"GitHub token not found.",
|
|
25
|
+
"Fix: set GITHUB_TOKEN or GH_TOKEN env var, add to .env.local, or run `gh auth login`.",
|
|
26
|
+
].join(" "),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const repoInfo = getRepoInfo();
|
|
31
|
+
if (!repoInfo) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
[
|
|
34
|
+
"Could not determine repository.",
|
|
35
|
+
"Fix: run from a git repo with a github.com remote, or set GH_REPO=owner/repo.",
|
|
36
|
+
].join(" "),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { token, repoInfo, proxyFetch: getProxyFetch() };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resolvePR(
|
|
44
|
+
ctx: Context,
|
|
45
|
+
prArg?: number,
|
|
46
|
+
): Promise<{ prNumber: number; prUrl: string; headSha?: string }> {
|
|
47
|
+
const { repoInfo, token, proxyFetch } = ctx;
|
|
48
|
+
|
|
49
|
+
if (prArg) {
|
|
50
|
+
return {
|
|
51
|
+
prNumber: prArg,
|
|
52
|
+
prUrl: `https://github.com/${repoInfo.owner}/${repoInfo.repo}/pull/${prArg}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const branch = getCurrentBranch();
|
|
57
|
+
if (branch && branch !== "main" && branch !== "master") {
|
|
58
|
+
const pr = await findPRForBranch(repoInfo.owner, repoInfo.repo, branch, token, proxyFetch);
|
|
59
|
+
if (pr) {
|
|
60
|
+
return { prNumber: pr.number, prUrl: pr.html_url, headSha: pr.head.sha };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const prs = await listOpenPRs(repoInfo.owner, repoInfo.repo, token, proxyFetch);
|
|
65
|
+
if (prs.length === 0) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"No open PRs found. Fix: pass a PR number as argument, e.g. `bellwether check 123`.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const selected = await clack.select({
|
|
72
|
+
message: "Select a PR",
|
|
73
|
+
options: prs.map((pr) => ({
|
|
74
|
+
value: pr.number,
|
|
75
|
+
label: `#${pr.number} ${pr.title}`,
|
|
76
|
+
hint: pr.head.ref,
|
|
77
|
+
})),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (clack.isCancel(selected)) {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pr = prs.find((p) => p.number === selected)!;
|
|
85
|
+
return { prNumber: pr.number, prUrl: pr.html_url, headSha: pr.head.sha };
|
|
86
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readFile, access } from "node:fs/promises";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { getRepoRoot } from "./repo.js";
|
|
5
|
+
|
|
6
|
+
function spawnText(cmd: string[]): string | null {
|
|
7
|
+
const [command, ...args] = cmd;
|
|
8
|
+
if (!command) {return null;}
|
|
9
|
+
const result = spawnSync(command, args, { encoding: "utf-8" });
|
|
10
|
+
if (result.status !== 0) {return null;}
|
|
11
|
+
return result.stdout.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a GitHub token from (in priority order):
|
|
16
|
+
* 1. GITHUB_TOKEN env var
|
|
17
|
+
* 2. GH_TOKEN env var
|
|
18
|
+
* 3. .env.local file in the repo root
|
|
19
|
+
* 4. `gh auth token` CLI
|
|
20
|
+
*/
|
|
21
|
+
export async function getGitHubToken(): Promise<string | null> {
|
|
22
|
+
if (process.env.GITHUB_TOKEN) {return process.env.GITHUB_TOKEN;}
|
|
23
|
+
if (process.env.GH_TOKEN) {return process.env.GH_TOKEN;}
|
|
24
|
+
|
|
25
|
+
const root = getRepoRoot();
|
|
26
|
+
if (root) {
|
|
27
|
+
const envPath = join(root, ".env.local");
|
|
28
|
+
try {
|
|
29
|
+
await access(envPath);
|
|
30
|
+
const content = await readFile(envPath, "utf-8");
|
|
31
|
+
const match = content.match(/^GITHUB_TOKEN=["']?([^"'\n]+)["']?/m);
|
|
32
|
+
if (match?.[1]) {return match[1];}
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const token = spawnText(["gh", "auth", "token"]);
|
|
37
|
+
if (token) {return token;}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|