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,233 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import { readFile, rm } from "node:fs/promises";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const USER_AGENT = "bellwether";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export type ProxyFetch = (url: string, options?: ProxyFetchOptions) => Promise<ProxyFetchResponse>;
|
|
16
|
+
|
|
17
|
+
export interface ProxyFetchOptions {
|
|
18
|
+
method?: string;
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
body?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface HeaderMap {
|
|
24
|
+
get(name: string): string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ProxyFetchResponse {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
status: number;
|
|
30
|
+
headers: HeaderMap;
|
|
31
|
+
text(): Promise<string>;
|
|
32
|
+
// eslint-disable-next-line typescript/no-explicit-any -- GitHub API responses are untyped; callers cast at call sites
|
|
33
|
+
json(): Promise<any>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Header parsing (for curl responses & proxied headers)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function parseHeaderMap(rawHeaders: string): HeaderMap {
|
|
41
|
+
const lines = rawHeaders.split(/\r?\n/).filter(Boolean);
|
|
42
|
+
const map = new Map<string, string>();
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
const idx = line.indexOf(":");
|
|
45
|
+
if (idx === -1) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
49
|
+
const value = line.slice(idx + 1).trim();
|
|
50
|
+
map.set(key, value);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
get(name: string) {
|
|
54
|
+
return map.get(String(name).toLowerCase()) ?? null;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseLastHeaderBlock(headerContent: string): string {
|
|
60
|
+
const blocks = headerContent
|
|
61
|
+
.split(/\r?\n\r?\n/)
|
|
62
|
+
.map((b) => b.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
for (let i = blocks.length - 1; i >= 0; i -= 1) {
|
|
65
|
+
const block = blocks[i];
|
|
66
|
+
if (block?.startsWith("HTTP/")) {
|
|
67
|
+
return block;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Curl-based fetch (fallback when undici is unavailable behind a proxy)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function createCurlFetch(): ProxyFetch {
|
|
78
|
+
return async (url: string, options: ProxyFetchOptions = {}) => {
|
|
79
|
+
const tempDir = mkdtempSync(join(tmpdir(), "bellwether-"));
|
|
80
|
+
const headersFile = join(tempDir, "headers.txt");
|
|
81
|
+
const bodyFile = join(tempDir, "body.txt");
|
|
82
|
+
|
|
83
|
+
const args = [
|
|
84
|
+
"--silent",
|
|
85
|
+
"--show-error",
|
|
86
|
+
"--location",
|
|
87
|
+
"--connect-timeout",
|
|
88
|
+
"10",
|
|
89
|
+
"--max-time",
|
|
90
|
+
"60",
|
|
91
|
+
"--dump-header",
|
|
92
|
+
headersFile,
|
|
93
|
+
"--output",
|
|
94
|
+
bodyFile,
|
|
95
|
+
"--request",
|
|
96
|
+
options.method ?? "GET",
|
|
97
|
+
"--write-out",
|
|
98
|
+
"%{http_code}",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
if (options.headers) {
|
|
102
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
103
|
+
args.push("--header", `${key}: ${value}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (options.body) {
|
|
107
|
+
args.push("--data", options.body);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
args.push(String(url));
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const result = spawnSync("curl", args, {
|
|
114
|
+
encoding: "utf-8",
|
|
115
|
+
timeout: 65_000,
|
|
116
|
+
});
|
|
117
|
+
if (result.error) {
|
|
118
|
+
throw new Error(`curl failed to start: ${result.error.message}`);
|
|
119
|
+
}
|
|
120
|
+
if (result.status !== 0) {
|
|
121
|
+
const stderr = String(result.stderr).trim();
|
|
122
|
+
throw new Error(
|
|
123
|
+
`curl exited with status ${String(result.status)}${stderr ? `: ${stderr}` : ""}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const statusCodeRaw = String(result.stdout).trim();
|
|
127
|
+
const status = Number.parseInt(statusCodeRaw, 10);
|
|
128
|
+
if (!statusCodeRaw || Number.isNaN(status)) {
|
|
129
|
+
throw new Error("curl did not return a valid HTTP status code");
|
|
130
|
+
}
|
|
131
|
+
const body = await readFile(bodyFile, "utf-8");
|
|
132
|
+
const headersRaw = await readFile(headersFile, "utf-8");
|
|
133
|
+
const lastHeaderBlock = parseLastHeaderBlock(headersRaw);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ok: status >= 200 && status < 300,
|
|
137
|
+
status,
|
|
138
|
+
headers: parseHeaderMap(lastHeaderBlock),
|
|
139
|
+
async text() {
|
|
140
|
+
return body;
|
|
141
|
+
},
|
|
142
|
+
async json() {
|
|
143
|
+
return JSON.parse(body || "null");
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
} finally {
|
|
147
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Proxy-aware fetch factory
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export function getProxyFetch(): ProxyFetch {
|
|
157
|
+
const proxyUrl = process.env.HTTPS_PROXY ?? process.env.https_proxy;
|
|
158
|
+
if (proxyUrl) {
|
|
159
|
+
try {
|
|
160
|
+
const { ProxyAgent, fetch: undiciFetch } = require("undici");
|
|
161
|
+
const agent = new ProxyAgent(proxyUrl);
|
|
162
|
+
return ((url: string, options: ProxyFetchOptions = {}) =>
|
|
163
|
+
undiciFetch(url, { ...options, dispatcher: agent })) as ProxyFetch;
|
|
164
|
+
} catch {
|
|
165
|
+
return createCurlFetch();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return async (url: string, options: ProxyFetchOptions = {}) => {
|
|
170
|
+
const response = await fetch(url, options);
|
|
171
|
+
return {
|
|
172
|
+
ok: response.ok,
|
|
173
|
+
status: response.status,
|
|
174
|
+
headers: { get: (name: string) => response.headers.get(name) },
|
|
175
|
+
text: () => response.text(),
|
|
176
|
+
json: () => response.json(),
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Authenticated GitHub fetch + pagination
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function authHeaders(token: string): Record<string, string> {
|
|
186
|
+
return {
|
|
187
|
+
Authorization: `Bearer ${token}`,
|
|
188
|
+
Accept: "application/vnd.github.v3+json",
|
|
189
|
+
"User-Agent": USER_AGENT,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function ghFetch(
|
|
194
|
+
url: string,
|
|
195
|
+
token: string,
|
|
196
|
+
proxyFetch: ProxyFetch,
|
|
197
|
+
options: ProxyFetchOptions = {},
|
|
198
|
+
): Promise<ProxyFetchResponse> {
|
|
199
|
+
return proxyFetch(url, {
|
|
200
|
+
...options,
|
|
201
|
+
headers: { ...authHeaders(token), ...options.headers },
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function fetchAllPages<T>(
|
|
206
|
+
url: string,
|
|
207
|
+
token: string,
|
|
208
|
+
proxyFetch: ProxyFetch,
|
|
209
|
+
): Promise<T[]> {
|
|
210
|
+
const results: T[] = [];
|
|
211
|
+
let nextUrl: string | null = url;
|
|
212
|
+
|
|
213
|
+
while (nextUrl) {
|
|
214
|
+
const response = await ghFetch(nextUrl, token, proxyFetch);
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
throw new Error(`API request failed: ${response.status}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const data = (await response.json()) as T[];
|
|
220
|
+
results.push(...data);
|
|
221
|
+
|
|
222
|
+
const linkHeader = response.headers.get("link");
|
|
223
|
+
nextUrl = null;
|
|
224
|
+
if (linkHeader) {
|
|
225
|
+
const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
|
|
226
|
+
if (nextMatch?.[1]) {
|
|
227
|
+
nextUrl = nextMatch[1];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return results;
|
|
233
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ProxyFetch,
|
|
3
|
+
type ProxyFetchOptions,
|
|
4
|
+
type ProxyFetchResponse,
|
|
5
|
+
getProxyFetch,
|
|
6
|
+
ghFetch,
|
|
7
|
+
fetchAllPages,
|
|
8
|
+
} from "./fetch.js";
|
|
9
|
+
|
|
10
|
+
export { getGitHubToken } from "./auth.js";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
type RepoInfo,
|
|
14
|
+
type PR,
|
|
15
|
+
type PRMergeState,
|
|
16
|
+
getRepoRoot,
|
|
17
|
+
getRepoInfo,
|
|
18
|
+
getCurrentBranch,
|
|
19
|
+
findPRForBranch,
|
|
20
|
+
listOpenPRs,
|
|
21
|
+
fetchPRMergeState,
|
|
22
|
+
} from "./repo.js";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
type ProcessedComment,
|
|
26
|
+
type Reply,
|
|
27
|
+
type FilterOptions,
|
|
28
|
+
fetchPRComments,
|
|
29
|
+
processComments,
|
|
30
|
+
filterComments,
|
|
31
|
+
replyToComment,
|
|
32
|
+
resolveThread,
|
|
33
|
+
} from "./comments.js";
|
|
34
|
+
|
|
35
|
+
export { type FailingCheck, type CIStatus, fetchCIStatus } from "./checks.js";
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { ghFetch, type ProxyFetch } from "./fetch.js";
|
|
3
|
+
|
|
4
|
+
function spawnText(cmd: string[]): string | null {
|
|
5
|
+
const [command, ...args] = cmd;
|
|
6
|
+
if (!command) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const result = spawnSync(command, args, { encoding: "utf-8" });
|
|
10
|
+
if (result.status !== 0) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return result.stdout.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Local git state
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export function getRepoRoot(): string | null {
|
|
21
|
+
return spawnText(["git", "rev-parse", "--show-toplevel"]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RepoInfo {
|
|
25
|
+
owner: string;
|
|
26
|
+
repo: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getRepoInfo(): RepoInfo | null {
|
|
30
|
+
const envRepo = process.env.GH_REPO;
|
|
31
|
+
if (envRepo) {
|
|
32
|
+
const match = envRepo.match(/^([^/]+)\/([^/]+)$/);
|
|
33
|
+
if (match?.[1] && match[2]) {
|
|
34
|
+
return { owner: match[1], repo: match[2] };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const remoteUrl = spawnText(["git", "remote", "get-url", "origin"]);
|
|
39
|
+
if (!remoteUrl) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// SSH, HTTPS, and proxy URL formats
|
|
44
|
+
const sshMatch = remoteUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
45
|
+
const httpsMatch = remoteUrl.match(/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
46
|
+
const proxyMatch = remoteUrl.match(/\/git\/([^/]+)\/([^/]+)$/);
|
|
47
|
+
|
|
48
|
+
const match = sshMatch ?? httpsMatch ?? proxyMatch;
|
|
49
|
+
if (match?.[1] && match[2]) {
|
|
50
|
+
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getCurrentBranch(): string | null {
|
|
57
|
+
return spawnText(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// PR helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface PR {
|
|
65
|
+
number: number;
|
|
66
|
+
title: string;
|
|
67
|
+
html_url: string;
|
|
68
|
+
head: { ref: string; sha: string };
|
|
69
|
+
state: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function findPRForBranch(
|
|
73
|
+
owner: string,
|
|
74
|
+
repo: string,
|
|
75
|
+
branch: string,
|
|
76
|
+
token: string,
|
|
77
|
+
proxyFetch: ProxyFetch,
|
|
78
|
+
): Promise<PR | null> {
|
|
79
|
+
const response = await ghFetch(
|
|
80
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls?head=${owner}:${branch}&state=open`,
|
|
81
|
+
token,
|
|
82
|
+
proxyFetch,
|
|
83
|
+
);
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`Failed to find PR: ${response.status}`);
|
|
86
|
+
}
|
|
87
|
+
const prs = (await response.json()) as PR[];
|
|
88
|
+
return prs[0] ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function listOpenPRs(
|
|
92
|
+
owner: string,
|
|
93
|
+
repo: string,
|
|
94
|
+
token: string,
|
|
95
|
+
proxyFetch: ProxyFetch,
|
|
96
|
+
): Promise<PR[]> {
|
|
97
|
+
const response = await ghFetch(
|
|
98
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls?state=open&per_page=30`,
|
|
99
|
+
token,
|
|
100
|
+
proxyFetch,
|
|
101
|
+
);
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
throw new Error(`Failed to list PRs: ${response.status}`);
|
|
104
|
+
}
|
|
105
|
+
return (await response.json()) as PR[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// PR merge state
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export interface PRMergeState {
|
|
113
|
+
state: "open" | "closed" | "merged";
|
|
114
|
+
mergeable: boolean | null;
|
|
115
|
+
mergeableState: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface RawPRMergeData {
|
|
119
|
+
state: string;
|
|
120
|
+
merged: boolean;
|
|
121
|
+
mergeable: boolean | null;
|
|
122
|
+
mergeable_state?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function fetchPRMergeState(
|
|
126
|
+
owner: string,
|
|
127
|
+
repo: string,
|
|
128
|
+
prNumber: number,
|
|
129
|
+
token: string,
|
|
130
|
+
proxyFetch: ProxyFetch,
|
|
131
|
+
): Promise<PRMergeState> {
|
|
132
|
+
const response = await ghFetch(
|
|
133
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
|
|
134
|
+
token,
|
|
135
|
+
proxyFetch,
|
|
136
|
+
);
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
throw new Error(`Failed to fetch PR merge state: ${response.status}`);
|
|
139
|
+
}
|
|
140
|
+
const pr = (await response.json()) as RawPRMergeData;
|
|
141
|
+
return {
|
|
142
|
+
state: pr.merged ? "merged" : (pr.state as "open" | "closed"),
|
|
143
|
+
mergeable: pr.mergeable,
|
|
144
|
+
mergeableState: pr.mergeable_state ?? "unknown",
|
|
145
|
+
};
|
|
146
|
+
}
|