@acme-skunkworks/agent-skills 1.0.0 → 1.1.0
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/README.md +5 -4
- package/package.json +2 -6
- package/skills/changelog/README.md +59 -0
- package/skills/changelog/SKILL.md +187 -0
- package/skills/changelog/config.example.json +5 -0
- package/skills/changelog/config.json +5 -0
- package/skills/changelog/package.json +31 -0
- package/skills/changelog/references/changelog-contract.md +121 -0
- package/skills/changelog/scripts/add-links.mjs +97 -0
- package/skills/changelog/scripts/lib/changelog.mjs +46 -0
- package/skills/changelog/scripts/lib/config.mjs +53 -0
- package/skills/changelog/scripts/lib/derive-packages.mjs +39 -0
- package/skills/changelog/scripts/lib/frontmatter.mjs +369 -0
- package/skills/changelog/scripts/preflight-changelog-ci.mjs +152 -0
- package/skills/changelog/scripts/set-affected-packages.mjs +99 -0
- package/skills/changelog/scripts/validate-changelog.mjs +264 -0
- package/skills/linear-sync/README.md +47 -0
- package/skills/linear-sync/SKILL.md +115 -0
- package/skills/linear-sync/config.example.json +4 -0
- package/skills/linear-sync/config.json +4 -0
- package/skills/linear-sync/package.json +31 -0
- package/skills/preflight/README.md +70 -0
- package/skills/preflight/SKILL.md +148 -0
- package/skills/preflight/config.example.json +6 -0
- package/skills/preflight/package.json +33 -0
- package/skills/preflight/scripts/classify-lint.mjs +176 -0
- package/skills/preflight/scripts/lib/diff-lines.mjs +83 -0
- package/skills/preflight/scripts/lib/paths.mjs +26 -0
- package/skills/preflight/scripts/lib/scope.mjs +530 -0
- package/skills/preflight/scripts/lint-fix.mjs +78 -0
- package/skills/preflight/scripts/preflight.mjs +416 -0
- package/skills/send-it/README.md +75 -0
- package/skills/send-it/SKILL.md +391 -0
- package/skills/send-it/config.example.json +5 -0
- package/skills/send-it/config.json +5 -0
- package/skills/send-it/package.json +33 -0
- package/skills/send-it/scripts/derive-bump.mjs +139 -0
- package/skills/triage-pr/README.md +56 -0
- package/skills/triage-pr/SKILL.md +291 -0
- package/skills/triage-pr/config.json +4 -0
- package/skills/triage-pr/package.json +32 -0
- package/skills/triage-pr/references/review-discipline.md +73 -0
- package/skills/triage-pr/scripts/review-threads.mjs +549 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unresolved-review-feedback fetcher for the triage-pr skill.
|
|
3
|
+
//
|
|
4
|
+
// Fetches a pull request's review feedback via `gh api graphql` and prints
|
|
5
|
+
// minimal JSON, so Phase B can triage findings without pulling whole comment
|
|
6
|
+
// payloads into context. Two shapes of feedback are surfaced separately because
|
|
7
|
+
// they live in different GitHub objects:
|
|
8
|
+
//
|
|
9
|
+
// - unresolvedThreads : inline review threads with `isResolved == false`,
|
|
10
|
+
// raised by a configured review bot. Each is trimmed to
|
|
11
|
+
// { threadId, path, line, isOutdated, author, comments }.
|
|
12
|
+
// - humanThreads : the same, for threads NOT raised by a review bot —
|
|
13
|
+
// surfaced so a human isn't silently dropped, but the
|
|
14
|
+
// skill does not auto-action them.
|
|
15
|
+
// - aiSummaryComments : issue-level comments authored by a review bot (the
|
|
16
|
+
// sticky `track_progress` / `use_sticky_comment` summary).
|
|
17
|
+
// These are NOT review threads and have no `isResolved`,
|
|
18
|
+
// so the reviewThreads query never returns them.
|
|
19
|
+
//
|
|
20
|
+
// The network layer (gh) is kept separate from the pure transform so the
|
|
21
|
+
// transform is unit-tested by `--self-test` with no network access.
|
|
22
|
+
//
|
|
23
|
+
// Usage:
|
|
24
|
+
// node review-threads.mjs <pr-number-or-url> # minimal JSON to stdout
|
|
25
|
+
// node review-threads.mjs <pr> --bots "a[bot],b[bot]" # override review-bot logins
|
|
26
|
+
// node review-threads.mjs <pr> --repo owner/name # set repo explicitly
|
|
27
|
+
// node review-threads.mjs <pr> --include-resolved # keep resolved threads too
|
|
28
|
+
// node review-threads.mjs --self-test # run built-in fixtures
|
|
29
|
+
|
|
30
|
+
import { execFileSync } from "node:child_process";
|
|
31
|
+
import { realpathSync } from "node:fs";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
|
|
34
|
+
// Common AI review-bot logins. GitHub's GraphQL API returns bot logins WITHOUT
|
|
35
|
+
// the `[bot]` suffix (e.g. `claude`, `coderabbitai`), whereas the REST API and
|
|
36
|
+
// many docs show the suffixed form (`claude[bot]`). `botMatches` normalises both
|
|
37
|
+
// sides, so a consumer's config can use either form.
|
|
38
|
+
const DEFAULT_BOTS = ["claude", "cursor", "coderabbitai", "github-actions"];
|
|
39
|
+
|
|
40
|
+
// ---- pure transform (no network) ----------------------------------------
|
|
41
|
+
|
|
42
|
+
/** Strip a trailing `[bot]` suffix so a login compares equal in either form. */
|
|
43
|
+
function normaliseBot(login) {
|
|
44
|
+
return String(login ?? "").replace(/\[bot\]$/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build a suffix-insensitive predicate matching a login against the bot list. */
|
|
48
|
+
function makeBotMatcher(bots) {
|
|
49
|
+
const set = new Set(bots.map(normaliseBot));
|
|
50
|
+
return (login) => set.has(normaliseBot(login));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reduce raw GraphQL comment nodes to the minimal `{ author, body }`. */
|
|
54
|
+
function trimComments(commentNodes) {
|
|
55
|
+
return (commentNodes ?? []).map((c) => ({
|
|
56
|
+
author: c.author?.login ?? "unknown",
|
|
57
|
+
body: c.body ?? "",
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reduce a raw review-thread node to its minimal shape for the report. */
|
|
62
|
+
function shapeThread(node) {
|
|
63
|
+
const comments = trimComments(node.comments?.nodes);
|
|
64
|
+
return {
|
|
65
|
+
threadId: node.id,
|
|
66
|
+
path: node.path ?? null,
|
|
67
|
+
line: node.line ?? node.originalLine ?? null,
|
|
68
|
+
isOutdated: Boolean(node.isOutdated),
|
|
69
|
+
author: comments[0]?.author ?? "unknown",
|
|
70
|
+
comments,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the minimal result from raw GraphQL nodes. Splitting bot threads from
|
|
76
|
+
* human threads honours the skill's "AI bots only" contract while still
|
|
77
|
+
* surfacing human threads for the report.
|
|
78
|
+
*/
|
|
79
|
+
export function buildResult({
|
|
80
|
+
number,
|
|
81
|
+
isDraft,
|
|
82
|
+
threadNodes,
|
|
83
|
+
commentNodes,
|
|
84
|
+
bots,
|
|
85
|
+
includeResolved = false,
|
|
86
|
+
}) {
|
|
87
|
+
const isBot = makeBotMatcher(bots);
|
|
88
|
+
const unresolvedThreads = [];
|
|
89
|
+
const humanThreads = [];
|
|
90
|
+
|
|
91
|
+
for (const node of threadNodes ?? []) {
|
|
92
|
+
if (!includeResolved && node.isResolved) continue;
|
|
93
|
+
const thread = shapeThread(node);
|
|
94
|
+
if (isBot(thread.author)) unresolvedThreads.push(thread);
|
|
95
|
+
else humanThreads.push(thread);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const aiSummaryComments = (commentNodes ?? [])
|
|
99
|
+
.filter((c) => isBot(c.author?.login))
|
|
100
|
+
.map((c) => ({
|
|
101
|
+
commentId: c.id,
|
|
102
|
+
author: c.author?.login ?? "unknown",
|
|
103
|
+
body: c.body ?? "",
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
pr: number,
|
|
108
|
+
isDraft: Boolean(isDraft),
|
|
109
|
+
unresolvedThreads,
|
|
110
|
+
humanThreads,
|
|
111
|
+
aiSummaryComments,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- argument parsing ----------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Parse argv into `{ pr, bots, repo, includeResolved }`; throws on a flag missing its value, an unknown `--flag`, or a malformed `--repo`. */
|
|
118
|
+
export function parseArgs(argv) {
|
|
119
|
+
const opts = { bots: DEFAULT_BOTS, repo: null, includeResolved: false, pr: null };
|
|
120
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
121
|
+
const arg = argv[i];
|
|
122
|
+
if (arg === "--include-resolved") opts.includeResolved = true;
|
|
123
|
+
else if (arg === "--bots") {
|
|
124
|
+
const value = argv[++i];
|
|
125
|
+
if (!value || value.startsWith("--")) {
|
|
126
|
+
throw new Error("--bots requires a comma-separated list of bot logins");
|
|
127
|
+
}
|
|
128
|
+
opts.bots = value
|
|
129
|
+
.split(",")
|
|
130
|
+
.map((s) => s.trim())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
if (opts.bots.length === 0) {
|
|
133
|
+
throw new Error("--bots requires at least one non-empty bot login");
|
|
134
|
+
}
|
|
135
|
+
} else if (arg === "--repo") {
|
|
136
|
+
const value = argv[++i];
|
|
137
|
+
if (!value || value.startsWith("--")) {
|
|
138
|
+
throw new Error("--repo requires an owner/name value");
|
|
139
|
+
}
|
|
140
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(value)) {
|
|
141
|
+
throw new Error("--repo must be exactly owner/name");
|
|
142
|
+
}
|
|
143
|
+
opts.repo = value;
|
|
144
|
+
} else if (!arg.startsWith("--") && opts.pr === null) opts.pr = arg;
|
|
145
|
+
else if (!arg.startsWith("--")) throw new Error(`unexpected argument: ${arg}`);
|
|
146
|
+
else throw new Error(`unknown option: ${arg}`);
|
|
147
|
+
}
|
|
148
|
+
return opts;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Accept a bare number or a full PR URL; return `{ number, repo }`. */
|
|
152
|
+
export function resolvePr(prArg, repoArg) {
|
|
153
|
+
if (prArg == null) throw new Error("no PR number or URL given");
|
|
154
|
+
const urlMatch = String(prArg).match(
|
|
155
|
+
/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,
|
|
156
|
+
);
|
|
157
|
+
if (urlMatch) {
|
|
158
|
+
return { number: Number(urlMatch[3]), repo: `${urlMatch[1]}/${urlMatch[2]}` };
|
|
159
|
+
}
|
|
160
|
+
const number = Number(prArg);
|
|
161
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
162
|
+
throw new Error(`not a PR number or URL: ${prArg}`);
|
|
163
|
+
}
|
|
164
|
+
return { number, repo: repoArg };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- network layer (gh) --------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/** Run a `gh` command and return stdout; 30s timeout so a stalled call can't hang. */
|
|
170
|
+
function gh(args) {
|
|
171
|
+
return execFileSync("gh", args, {
|
|
172
|
+
encoding: "utf8",
|
|
173
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
174
|
+
timeout: 30_000, // don't hang forever if a gh call stalls
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Run a GraphQL query via `gh api graphql`, typing each variable as -f/-F. */
|
|
179
|
+
function ghGraphQL(query, variables) {
|
|
180
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
181
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
182
|
+
if (value === null || value === undefined) continue;
|
|
183
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
184
|
+
args.push("-F", `${key}=${value}`);
|
|
185
|
+
} else {
|
|
186
|
+
args.push("-f", `${key}=${value}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return JSON.parse(gh(args));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Return the current repository as `owner/name`. */
|
|
193
|
+
function detectRepo() {
|
|
194
|
+
return gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$cursor:String){
|
|
198
|
+
repository(owner:$owner,name:$name){
|
|
199
|
+
pullRequest(number:$number){
|
|
200
|
+
isDraft
|
|
201
|
+
reviewThreads(first:100, after:$cursor){
|
|
202
|
+
pageInfo{ hasNextPage endCursor }
|
|
203
|
+
nodes{
|
|
204
|
+
id isResolved isOutdated path line originalLine
|
|
205
|
+
comments(first:100){ nodes{ author{ login } body } }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}`;
|
|
211
|
+
|
|
212
|
+
const COMMENTS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$cursor:String){
|
|
213
|
+
repository(owner:$owner,name:$name){
|
|
214
|
+
pullRequest(number:$number){
|
|
215
|
+
comments(first:100, after:$cursor){
|
|
216
|
+
pageInfo{ hasNextPage endCursor }
|
|
217
|
+
nodes{ id author{ login } body }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}`;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Page through a PR sub-connection, collecting every node. Also returns
|
|
225
|
+
* `isDraft`, which is meaningful only for queries that select it (the threads
|
|
226
|
+
* query) and `undefined` otherwise — callers read it from the threads call alone.
|
|
227
|
+
*/
|
|
228
|
+
function fetchAll(query, owner, name, number, pick) {
|
|
229
|
+
const nodes = [];
|
|
230
|
+
let cursor = null;
|
|
231
|
+
let isDraft;
|
|
232
|
+
do {
|
|
233
|
+
const data = ghGraphQL(query, { owner, name, number, cursor });
|
|
234
|
+
const pr = data.data.repository.pullRequest;
|
|
235
|
+
if (pr.isDraft !== undefined) isDraft = pr.isDraft;
|
|
236
|
+
const conn = pick(pr);
|
|
237
|
+
nodes.push(...conn.nodes);
|
|
238
|
+
cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
|
|
239
|
+
} while (cursor);
|
|
240
|
+
return { nodes, isDraft };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Fetch a PR's review threads and issue comments from GitHub. */
|
|
244
|
+
function fetchFromGitHub(number, repo) {
|
|
245
|
+
const nameWithOwner = repo ?? detectRepo();
|
|
246
|
+
const parts = nameWithOwner.split("/");
|
|
247
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
248
|
+
throw new Error(`could not resolve repo: ${nameWithOwner}`);
|
|
249
|
+
}
|
|
250
|
+
const [owner, name] = parts;
|
|
251
|
+
const threads = fetchAll(THREADS_QUERY, owner, name, number, (pr) => pr.reviewThreads);
|
|
252
|
+
const comments = fetchAll(COMMENTS_QUERY, owner, name, number, (pr) => pr.comments);
|
|
253
|
+
return { isDraft: threads.isDraft, threadNodes: threads.nodes, commentNodes: comments.nodes };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- self-test -----------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/** Run the built-in fixtures (no network) and exit non-zero on any failure. */
|
|
259
|
+
function selfTest() {
|
|
260
|
+
// GraphQL returns bot logins WITHOUT the `[bot]` suffix (e.g. `cursor`,
|
|
261
|
+
// `claude`, `coderabbitai`), so the fixtures use the bare form.
|
|
262
|
+
const threadNodes = [
|
|
263
|
+
{
|
|
264
|
+
id: "T_bot_unresolved",
|
|
265
|
+
isResolved: false,
|
|
266
|
+
isOutdated: false,
|
|
267
|
+
path: "skills/x/SKILL.md",
|
|
268
|
+
line: 42,
|
|
269
|
+
comments: { nodes: [{ author: { login: "cursor" }, body: "nit: typo" }] },
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: "T_bot_resolved",
|
|
273
|
+
isResolved: true,
|
|
274
|
+
isOutdated: false,
|
|
275
|
+
path: "a.ts",
|
|
276
|
+
line: 1,
|
|
277
|
+
comments: { nodes: [{ author: { login: "claude" }, body: "done" }] },
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: "T_bot_outdated",
|
|
281
|
+
isResolved: false,
|
|
282
|
+
isOutdated: true,
|
|
283
|
+
path: "b.ts",
|
|
284
|
+
line: null,
|
|
285
|
+
originalLine: 9,
|
|
286
|
+
comments: { nodes: [{ author: { login: "claude" }, body: "moved" }] },
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: "T_human",
|
|
290
|
+
isResolved: false,
|
|
291
|
+
isOutdated: false,
|
|
292
|
+
path: "c.ts",
|
|
293
|
+
line: 3,
|
|
294
|
+
comments: { nodes: [{ author: { login: "alice" }, body: "please rename" }] },
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
const commentNodes = [
|
|
298
|
+
{ id: "IC_summary", author: { login: "coderabbitai" }, body: "## Review summary" },
|
|
299
|
+
{ id: "IC_human", author: { login: "bob" }, body: "lgtm" },
|
|
300
|
+
];
|
|
301
|
+
const bots = DEFAULT_BOTS;
|
|
302
|
+
|
|
303
|
+
const result = buildResult({
|
|
304
|
+
number: 7,
|
|
305
|
+
isDraft: false,
|
|
306
|
+
threadNodes,
|
|
307
|
+
commentNodes,
|
|
308
|
+
bots,
|
|
309
|
+
});
|
|
310
|
+
const withResolved = buildResult({
|
|
311
|
+
number: 7,
|
|
312
|
+
isDraft: false,
|
|
313
|
+
threadNodes,
|
|
314
|
+
commentNodes,
|
|
315
|
+
bots,
|
|
316
|
+
includeResolved: true,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const ids = (arr) => arr.map((t) => t.threadId);
|
|
320
|
+
const cases = [
|
|
321
|
+
{
|
|
322
|
+
name: "unresolved bot thread is included",
|
|
323
|
+
ok: ids(result.unresolvedThreads).includes("T_bot_unresolved"),
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: "resolved bot thread is excluded by default",
|
|
327
|
+
ok: !ids(result.unresolvedThreads).includes("T_bot_resolved"),
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
name: "--include-resolved keeps the resolved bot thread",
|
|
331
|
+
ok: ids(withResolved.unresolvedThreads).includes("T_bot_resolved"),
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: "outdated flag and originalLine fallback are preserved",
|
|
335
|
+
ok:
|
|
336
|
+
result.unresolvedThreads.find((t) => t.threadId === "T_bot_outdated")
|
|
337
|
+
?.isOutdated === true &&
|
|
338
|
+
result.unresolvedThreads.find((t) => t.threadId === "T_bot_outdated")
|
|
339
|
+
?.line === 9,
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: "human thread goes to humanThreads, not unresolvedThreads",
|
|
343
|
+
ok:
|
|
344
|
+
ids(result.humanThreads).includes("T_human") &&
|
|
345
|
+
!ids(result.unresolvedThreads).includes("T_human"),
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
name: "comments are trimmed to author + body only",
|
|
349
|
+
ok: result.unresolvedThreads.every((t) =>
|
|
350
|
+
t.comments.every(
|
|
351
|
+
(c) => Object.keys(c).sort().join(",") === "author,body",
|
|
352
|
+
),
|
|
353
|
+
),
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: "thread author is taken from the first comment",
|
|
357
|
+
ok:
|
|
358
|
+
result.unresolvedThreads.find((t) => t.threadId === "T_bot_unresolved")
|
|
359
|
+
?.author === "cursor",
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: "sticky AI summary comment is picked up",
|
|
363
|
+
ok: result.aiSummaryComments.some((c) => c.commentId === "IC_summary"),
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: "human issue comment is not treated as an AI summary",
|
|
367
|
+
ok: !result.aiSummaryComments.some((c) => c.commentId === "IC_human"),
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
// A config entry written with the `[bot]` suffix must still match the bare
|
|
372
|
+
// login GraphQL returns (and vice versa).
|
|
373
|
+
const normalised = buildResult({
|
|
374
|
+
number: 7,
|
|
375
|
+
isDraft: false,
|
|
376
|
+
threadNodes: [
|
|
377
|
+
{
|
|
378
|
+
id: "T_norm",
|
|
379
|
+
isResolved: false,
|
|
380
|
+
isOutdated: false,
|
|
381
|
+
path: "d.ts",
|
|
382
|
+
line: 1,
|
|
383
|
+
comments: { nodes: [{ author: { login: "claude" }, body: "x" }] },
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
commentNodes: [],
|
|
387
|
+
bots: ["claude[bot]"],
|
|
388
|
+
});
|
|
389
|
+
cases.push({
|
|
390
|
+
name: "config '[bot]' suffix matches a bare GraphQL login",
|
|
391
|
+
ok: ids(normalised.unresolvedThreads).includes("T_norm"),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// argument + PR-resolution parsing
|
|
395
|
+
const parsed = parseArgs(["123", "--bots", "x[bot], y[bot]", "--include-resolved"]);
|
|
396
|
+
cases.push({
|
|
397
|
+
name: "parseArgs reads pr, bots (trimmed), and --include-resolved",
|
|
398
|
+
ok:
|
|
399
|
+
parsed.pr === "123" &&
|
|
400
|
+
parsed.includeResolved === true &&
|
|
401
|
+
parsed.bots.join(",") === "x[bot],y[bot]",
|
|
402
|
+
});
|
|
403
|
+
const fromUrl = resolvePr("https://github.com/acme/widgets/pull/88", null);
|
|
404
|
+
cases.push({
|
|
405
|
+
name: "resolvePr parses owner/repo/number from a PR URL",
|
|
406
|
+
ok: fromUrl.number === 88 && fromUrl.repo === "acme/widgets",
|
|
407
|
+
});
|
|
408
|
+
const fromNumber = resolvePr("12", "acme/widgets");
|
|
409
|
+
cases.push({
|
|
410
|
+
name: "resolvePr accepts a bare number with --repo",
|
|
411
|
+
ok: fromNumber.number === 12 && fromNumber.repo === "acme/widgets",
|
|
412
|
+
});
|
|
413
|
+
cases.push({
|
|
414
|
+
name: "resolvePr throws on a non-number, non-URL string",
|
|
415
|
+
ok: (() => {
|
|
416
|
+
try {
|
|
417
|
+
resolvePr("abc", null);
|
|
418
|
+
return false;
|
|
419
|
+
} catch {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
})(),
|
|
423
|
+
});
|
|
424
|
+
cases.push({
|
|
425
|
+
name: "parseArgs throws when --bots has no value",
|
|
426
|
+
ok: (() => {
|
|
427
|
+
try {
|
|
428
|
+
parseArgs(["123", "--bots"]);
|
|
429
|
+
return false;
|
|
430
|
+
} catch {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
})(),
|
|
434
|
+
});
|
|
435
|
+
cases.push({
|
|
436
|
+
name: "parseArgs throws when --repo has no value",
|
|
437
|
+
ok: (() => {
|
|
438
|
+
try {
|
|
439
|
+
parseArgs(["123", "--repo"]);
|
|
440
|
+
return false;
|
|
441
|
+
} catch {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
})(),
|
|
445
|
+
});
|
|
446
|
+
cases.push({
|
|
447
|
+
name: "parseArgs throws on an unknown --flag",
|
|
448
|
+
ok: (() => {
|
|
449
|
+
try {
|
|
450
|
+
parseArgs(["123", "--nope"]);
|
|
451
|
+
return false;
|
|
452
|
+
} catch {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
})(),
|
|
456
|
+
});
|
|
457
|
+
cases.push({
|
|
458
|
+
name: "parseArgs throws on a malformed --repo (extra segments)",
|
|
459
|
+
ok: (() => {
|
|
460
|
+
try {
|
|
461
|
+
parseArgs(["123", "--repo", "acme/widgets/extra"]);
|
|
462
|
+
return false;
|
|
463
|
+
} catch {
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
})(),
|
|
467
|
+
});
|
|
468
|
+
cases.push({
|
|
469
|
+
name: "parseArgs throws on an extra positional argument",
|
|
470
|
+
ok: (() => {
|
|
471
|
+
try {
|
|
472
|
+
parseArgs(["123", "456"]);
|
|
473
|
+
return false;
|
|
474
|
+
} catch {
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
})(),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
let failed = 0;
|
|
481
|
+
for (const { name, ok } of cases) {
|
|
482
|
+
if (ok) {
|
|
483
|
+
console.log(` ok ${name}`);
|
|
484
|
+
} else {
|
|
485
|
+
failed += 1;
|
|
486
|
+
console.log(` FAIL ${name}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
console.log(`\n${cases.length - failed}/${cases.length} passed`);
|
|
490
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---- main ----------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
/** CLI entry: parse args, fetch from GitHub, and print the minimal JSON. */
|
|
496
|
+
function main() {
|
|
497
|
+
const argv = process.argv.slice(2);
|
|
498
|
+
if (argv.includes("--self-test")) {
|
|
499
|
+
selfTest();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
let opts;
|
|
503
|
+
let pr;
|
|
504
|
+
try {
|
|
505
|
+
opts = parseArgs(argv);
|
|
506
|
+
pr = resolvePr(opts.pr, opts.repo);
|
|
507
|
+
} catch (e) {
|
|
508
|
+
console.error(`review-threads: ${e.message}`);
|
|
509
|
+
process.exit(2);
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
const { isDraft, threadNodes, commentNodes } = fetchFromGitHub(pr.number, pr.repo);
|
|
513
|
+
const result = buildResult({
|
|
514
|
+
number: pr.number,
|
|
515
|
+
isDraft,
|
|
516
|
+
threadNodes,
|
|
517
|
+
commentNodes,
|
|
518
|
+
bots: opts.bots,
|
|
519
|
+
includeResolved: opts.includeResolved,
|
|
520
|
+
});
|
|
521
|
+
console.log(JSON.stringify(result, null, 2));
|
|
522
|
+
} catch (e) {
|
|
523
|
+
// Non-zero exit so the skill can tell "couldn't fetch" from "no findings".
|
|
524
|
+
console.error(`review-threads: failed to fetch from GitHub — ${e.message}`);
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Detect "run directly as a CLI" vs "imported as a module". A raw
|
|
530
|
+
// `import.meta.url === file://${argv[1]}` string compare breaks two ways:
|
|
531
|
+
// `import.meta.url` percent-encodes characters such as spaces, and the ESM
|
|
532
|
+
// loader symlink-resolves it whereas `process.argv[1]` is left untouched (e.g.
|
|
533
|
+
// macOS /var → /private/var, pnpm's symlinked store). Normalise both sides
|
|
534
|
+
// through realpath before comparing.
|
|
535
|
+
function isCliEntry() {
|
|
536
|
+
if (!process.argv[1]) return false;
|
|
537
|
+
try {
|
|
538
|
+
return (
|
|
539
|
+
realpathSync(fileURLToPath(import.meta.url)) ===
|
|
540
|
+
realpathSync(process.argv[1])
|
|
541
|
+
);
|
|
542
|
+
} catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (isCliEntry()) {
|
|
548
|
+
main();
|
|
549
|
+
}
|