clud-bug 0.7.0-rc.6 → 0.7.0-rc.8
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/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +450 -0
- package/dist/cli/main.js.map +1 -1
- package/dist/core/auto-resolve.d.ts +166 -0
- package/dist/core/auto-resolve.d.ts.map +1 -0
- package/dist/core/auto-resolve.js +214 -0
- package/dist/core/auto-resolve.js.map +1 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +16 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/inline-threads.d.ts +160 -0
- package/dist/core/inline-threads.d.ts.map +1 -0
- package/dist/core/inline-threads.js +369 -0
- package/dist/core/inline-threads.js.map +1 -0
- package/dist/core/resolve-verifier.d.ts +46 -0
- package/dist/core/resolve-verifier.d.ts.map +1 -0
- package/dist/core/resolve-verifier.js +187 -0
- package/dist/core/resolve-verifier.js.map +1 -0
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/package.json +1 -1
- package/src/cli/main.ts +523 -0
- package/src/core/auto-resolve.ts +366 -0
- package/src/core/index.ts +53 -0
- package/src/core/inline-threads.ts +471 -0
- package/src/core/resolve-verifier.ts +228 -0
- package/src/core/version.ts +1 -1
- package/templates/workflow-py.yml.tmpl +31 -2
- package/templates/workflow-ts.yml.tmpl +31 -2
- package/templates/workflow.yml.tmpl +60 -2
package/dist/cli/main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":"AA0NA,iBAAe,IAAI,kBA4BlB;AA8yDD,OAAO,EAAE,IAAI,EAAE,CAAC"}
|
package/dist/cli/main.js
CHANGED
|
@@ -175,6 +175,25 @@ Commands:
|
|
|
175
175
|
STRICT_MODE ("true"/"false"). Empty/malformed stdin →
|
|
176
176
|
exits 0 with "skip" on stdout (no-op; workflow degrades
|
|
177
177
|
gracefully to the existing comment-only behavior).
|
|
178
|
+
post-inline-threads Post per-finding inline review threads (D.2.X). Reads a
|
|
179
|
+
--stdin structured-output JSON payload from stdin, fetches the
|
|
180
|
+
PR diff via \`gh api\`, posts one batched review with one
|
|
181
|
+
comment per anchorable finding. Each comment body carries
|
|
182
|
+
a hidden \`<!-- finding-id: <hash> --> \`marker so Wave 5b
|
|
183
|
+
auto-resolve can re-match findings on subsequent pushes
|
|
184
|
+
without persistent state. Required env vars: GH_TOKEN,
|
|
185
|
+
REPO ("owner/name"), PR_NUMBER, HEAD_SHA. Output is a
|
|
186
|
+
JSON status report \`{posted, skipped, preexisting, error?}\`.
|
|
187
|
+
resolve-threads Auto-resolve inline review threads when a fix-push
|
|
188
|
+
addresses the original finding (D.2.6). Fetches all
|
|
189
|
+
bot-authored unresolved threads, asks the Anthropic
|
|
190
|
+
Messages API per-thread whether the new commit addressed
|
|
191
|
+
the concern, then resolves (with a marker reply)
|
|
192
|
+
ADDRESSED threads + leaves UNCERTAIN/NOT_ADDRESSED open.
|
|
193
|
+
Fail-closed: verifier errors route to UNCERTAIN.
|
|
194
|
+
Required env vars: GH_TOKEN, ANTHROPIC_API_KEY,
|
|
195
|
+
REPO ("owner/name"), PR_NUMBER. Output: JSON
|
|
196
|
+
\`{actions, verifierCallCount, shouldRequestChanges}\`.
|
|
178
197
|
|
|
179
198
|
Options:
|
|
180
199
|
--offline Skip skills.sh; pin only the bundled baseline specimens.
|
|
@@ -237,6 +256,8 @@ async function main() {
|
|
|
237
256
|
case 'render': return runRender(args);
|
|
238
257
|
case 'update-skill-usage': return runUpdateSkillUsage(args);
|
|
239
258
|
case 'select-review-event': return runSelectReviewEvent(args);
|
|
259
|
+
case 'post-inline-threads': return runPostInlineThreads(args);
|
|
260
|
+
case 'resolve-threads': return runResolveThreads(args);
|
|
240
261
|
default:
|
|
241
262
|
process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
|
|
242
263
|
process.exit(2);
|
|
@@ -492,6 +513,435 @@ async function runSelectReviewEvent(args) {
|
|
|
492
513
|
});
|
|
493
514
|
process.stdout.write(event + '\n');
|
|
494
515
|
}
|
|
516
|
+
// Wave 5a / 0.7.0-rc.7: post per-finding inline review threads (D.2.X)
|
|
517
|
+
// to the PR. Replaces the legacy "one summary comment listing every
|
|
518
|
+
// finding" UX with first-class GitHub review threads users can reply to
|
|
519
|
+
// and (in Wave 5b) the bot can auto-resolve on fix-push.
|
|
520
|
+
//
|
|
521
|
+
// Pipeline:
|
|
522
|
+
// 1. Read the structured review JSON from stdin (same shape the
|
|
523
|
+
// `render` and `update-skill-usage` verbs consume).
|
|
524
|
+
// 2. Fetch the PR's per-file diff via `gh api repos/.../pulls/N/files`.
|
|
525
|
+
// 3. Call `planInlineThreads(findings, diffFiles)` from
|
|
526
|
+
// `clud-bug/core/inline-threads` to partition into anchored
|
|
527
|
+
// `comments[]` + skipped + preexisting buckets.
|
|
528
|
+
// 4. If any comments survive: POST one batched review via
|
|
529
|
+
// `gh api -X POST repos/.../pulls/N/reviews` with `event: COMMENT`.
|
|
530
|
+
// Each comment body carries a hidden `<!-- finding-id: <hash> -->`
|
|
531
|
+
// marker so Wave 5b auto-resolve can re-derive the same ids on
|
|
532
|
+
// subsequent pushes without a persistent store.
|
|
533
|
+
// 5. Emit a JSON summary on stdout (`{posted, skipped, preexisting}`).
|
|
534
|
+
//
|
|
535
|
+
// Required env vars (the workflow template provides all of them):
|
|
536
|
+
// GH_TOKEN, REPO ("owner/name"), PR_NUMBER, HEAD_SHA
|
|
537
|
+
//
|
|
538
|
+
// Failure posture: any `gh api` failure is logged to stderr + emitted in
|
|
539
|
+
// the stdout JSON as `error`, but the verb exits 0 so the surrounding
|
|
540
|
+
// workflow step's `continue-on-error: true` is the single source of
|
|
541
|
+
// failure semantics. Mirrors the `render` / `update-skill-usage` posture.
|
|
542
|
+
async function runPostInlineThreads(args) {
|
|
543
|
+
const { planInlineThreads } = await import('../core/inline-threads.js');
|
|
544
|
+
if (!args.stdin) {
|
|
545
|
+
process.stderr.write('clud-bug post-inline-threads: --stdin is required.\n');
|
|
546
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'no-stdin' }) + '\n');
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
let raw = '';
|
|
550
|
+
for await (const chunk of process.stdin)
|
|
551
|
+
raw += chunk;
|
|
552
|
+
raw = raw.trim();
|
|
553
|
+
if (!raw) {
|
|
554
|
+
process.stderr.write('clud-bug post-inline-threads: stdin empty — no-op.\n');
|
|
555
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'empty-stdin' }) + '\n');
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
let payload;
|
|
559
|
+
try {
|
|
560
|
+
payload = JSON.parse(raw);
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
process.stderr.write(`clud-bug post-inline-threads: JSON parse failed: ${e.message} — no-op.\n`);
|
|
564
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'parse-failed' }) + '\n');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (!payload || typeof payload !== 'object') {
|
|
568
|
+
process.stderr.write('clud-bug post-inline-threads: payload must be a JSON object — no-op.\n');
|
|
569
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'not-object' }) + '\n');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// Pull findings from the structured_output shape — same field names as
|
|
573
|
+
// the SPEC §1.8.1 review schema the model emits.
|
|
574
|
+
const findings = [];
|
|
575
|
+
for (const f of Array.isArray(payload.critical_findings) ? payload.critical_findings : []) {
|
|
576
|
+
if (f && typeof f === 'object') {
|
|
577
|
+
findings.push({ ...f, severity: 'critical' });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
for (const f of Array.isArray(payload.minor_findings) ? payload.minor_findings : []) {
|
|
581
|
+
if (f && typeof f === 'object') {
|
|
582
|
+
findings.push({ ...f, severity: 'minor' });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// `preexisting_findings` are intentionally not threaded (informational
|
|
586
|
+
// about prior code; not a reason to block this PR). `planInlineThreads`
|
|
587
|
+
// partitions them into the `preexisting` bucket if any are included, but
|
|
588
|
+
// the workflow path doesn't ship them — keep the payload focused.
|
|
589
|
+
if (findings.length === 0) {
|
|
590
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], reason: 'no-findings' }) + '\n');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// Required env vars.
|
|
594
|
+
const repo = String(process.env.REPO ?? '').trim();
|
|
595
|
+
const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
|
|
596
|
+
const headSha = String(process.env.HEAD_SHA ?? '').trim();
|
|
597
|
+
const prNumber = Number(prNumberRaw);
|
|
598
|
+
if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !headSha) {
|
|
599
|
+
process.stderr.write(`clud-bug post-inline-threads: REPO + PR_NUMBER + HEAD_SHA env vars required (got REPO=${repo}, PR_NUMBER=${prNumberRaw}, HEAD_SHA=${headSha ? '<set>' : '<unset>'}).\n`);
|
|
600
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'missing-env' }) + '\n');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// Fetch the PR's diff. `gh api repos/.../pulls/N/files --paginate`
|
|
604
|
+
// returns a JSON array of {filename, patch, status, ...}. The single
|
|
605
|
+
// -slurp on multi-page output stitches all pages into one big array.
|
|
606
|
+
const filesResult = spawnSync('gh', [
|
|
607
|
+
'api',
|
|
608
|
+
`repos/${repo}/pulls/${prNumber}/files`,
|
|
609
|
+
'--paginate',
|
|
610
|
+
'--slurp',
|
|
611
|
+
], { encoding: 'utf8' });
|
|
612
|
+
if (filesResult.status !== 0) {
|
|
613
|
+
process.stderr.write(`clud-bug post-inline-threads: \`gh api .../files\` failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`);
|
|
614
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-fetch-failed' }) + '\n');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
let diffFiles = [];
|
|
618
|
+
try {
|
|
619
|
+
// --slurp wraps each page in an outer array; flatten one level.
|
|
620
|
+
const pages = JSON.parse(filesResult.stdout);
|
|
621
|
+
if (Array.isArray(pages)) {
|
|
622
|
+
for (const page of pages) {
|
|
623
|
+
if (Array.isArray(page))
|
|
624
|
+
diffFiles.push(...page);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (e) {
|
|
629
|
+
process.stderr.write(`clud-bug post-inline-threads: diff JSON parse failed: ${e.message}\n`);
|
|
630
|
+
process.stdout.write(JSON.stringify({ posted: 0, skipped: [], preexisting: [], error: 'diff-parse-failed' }) + '\n');
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const plan = planInlineThreads(findings, diffFiles);
|
|
634
|
+
if (plan.comments.length === 0) {
|
|
635
|
+
// Everything fell through to summary-comment fallback (no anchor
|
|
636
|
+
// matches). Not a failure — emit the breakdown so the workflow log
|
|
637
|
+
// explains why no inline threads were posted.
|
|
638
|
+
process.stdout.write(JSON.stringify({
|
|
639
|
+
posted: 0,
|
|
640
|
+
skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
|
|
641
|
+
preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
|
|
642
|
+
reason: 'no-anchorable-findings',
|
|
643
|
+
}) + '\n');
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Post the review. gh accepts the body as a JSON file via --input — we
|
|
647
|
+
// pipe through stdin with `--input -`. Build the body as JSON-on-one-line
|
|
648
|
+
// so stdin is a single write that `gh` reads start-to-finish.
|
|
649
|
+
const reviewBody = JSON.stringify({
|
|
650
|
+
commit_id: headSha,
|
|
651
|
+
event: 'COMMENT',
|
|
652
|
+
body: `Clud-Bug posted ${plan.comments.length} inline finding${plan.comments.length === 1 ? '' : 's'}.`,
|
|
653
|
+
comments: plan.comments,
|
|
654
|
+
});
|
|
655
|
+
const postResult = spawnSync('gh', [
|
|
656
|
+
'api',
|
|
657
|
+
'--method', 'POST',
|
|
658
|
+
`repos/${repo}/pulls/${prNumber}/reviews`,
|
|
659
|
+
'--input', '-',
|
|
660
|
+
], { encoding: 'utf8', input: reviewBody });
|
|
661
|
+
if (postResult.status !== 0) {
|
|
662
|
+
process.stderr.write(`clud-bug post-inline-threads: \`gh api -X POST .../reviews\` failed (exit ${postResult.status}): ${(postResult.stderr || '').slice(0, 500)}\n`);
|
|
663
|
+
process.stdout.write(JSON.stringify({
|
|
664
|
+
posted: 0,
|
|
665
|
+
skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
|
|
666
|
+
preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
|
|
667
|
+
error: 'review-post-failed',
|
|
668
|
+
}) + '\n');
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
process.stdout.write(JSON.stringify({
|
|
672
|
+
posted: plan.comments.length,
|
|
673
|
+
skipped: plan.skipped.map((f) => ({ skill: f.skill, file: f.file, line: f.line, reason: 'not-anchorable' })),
|
|
674
|
+
preexisting: plan.preexisting.map((f) => ({ skill: f.skill })),
|
|
675
|
+
}) + '\n');
|
|
676
|
+
}
|
|
677
|
+
// Wave 5b / 0.7.0-rc.8: D.2.6 auto-resolve on fix-push. Fetches all
|
|
678
|
+
// bot-authored unresolved inline threads, asks the Anthropic Messages
|
|
679
|
+
// API per-thread whether the new commit addressed the original
|
|
680
|
+
// finding, and resolves ADDRESSED threads (with a marker reply) while
|
|
681
|
+
// leaving UNCERTAIN/NOT_ADDRESSED open. Fail-closed throughout —
|
|
682
|
+
// verifier errors route to UNCERTAIN; the surrounding workflow
|
|
683
|
+
// step's `continue-on-error: true` is the single failure gate.
|
|
684
|
+
//
|
|
685
|
+
// Required env: GH_TOKEN, ANTHROPIC_API_KEY, REPO ("owner/name"), PR_NUMBER.
|
|
686
|
+
//
|
|
687
|
+
// No `--stdin` — fetches everything from `gh api graphql` itself.
|
|
688
|
+
// Workflow gates this verb on `github.event.action == 'synchronize'`
|
|
689
|
+
// so it only runs on fix-pushes (not initial PR opens, when there
|
|
690
|
+
// are no prior threads to resolve).
|
|
691
|
+
async function runResolveThreads(_args) {
|
|
692
|
+
const { parseThreadBody, extractAnchorContext, REVIEW_THREADS_QUERY, RESOLVE_THREAD_MUTATION, ADD_REPLY_MUTATION, } = await import('../core/inline-threads.js');
|
|
693
|
+
const { readAutoResolveConfigFromCludBug, runAutoResolve, } = await import('../core/auto-resolve.js');
|
|
694
|
+
const { VERIFIER_SYSTEM, buildVerifierPrompt, parseVerifierResponse, } = await import('../core/resolve-verifier.js');
|
|
695
|
+
// ---- Env validation ----------------------------------------------------
|
|
696
|
+
const repo = String(process.env.REPO ?? '').trim();
|
|
697
|
+
const prNumberRaw = String(process.env.PR_NUMBER ?? '').trim();
|
|
698
|
+
const prNumber = Number(prNumberRaw);
|
|
699
|
+
const ghToken = String(process.env.GH_TOKEN ?? '').trim();
|
|
700
|
+
const anthropicKey = String(process.env.ANTHROPIC_API_KEY ?? '').trim();
|
|
701
|
+
if (!repo || !repo.includes('/') || !Number.isInteger(prNumber) || prNumber <= 0 || !ghToken || !anthropicKey) {
|
|
702
|
+
process.stderr.write(`clud-bug resolve-threads: REPO + PR_NUMBER + GH_TOKEN + ANTHROPIC_API_KEY env vars required.\n`);
|
|
703
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'missing-env' }) + '\n');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const [owner, repoName] = repo.split('/', 2);
|
|
707
|
+
// ---- Optional .clud-bug.json config (autoResolve block) ----------------
|
|
708
|
+
// The workflow's working dir is the PR checkout — if .clud-bug.json is
|
|
709
|
+
// present we read autoResolve from it. Otherwise defaults (mode='verified').
|
|
710
|
+
let autoResolveConfig;
|
|
711
|
+
try {
|
|
712
|
+
const cfgPath = join(process.cwd(), '.claude/skills/.clud-bug.json');
|
|
713
|
+
const cfgRaw = await readFile(cfgPath, 'utf8');
|
|
714
|
+
const cfg = JSON.parse(cfgRaw);
|
|
715
|
+
autoResolveConfig = readAutoResolveConfigFromCludBug(cfg, (msg) => {
|
|
716
|
+
process.stderr.write(`clud-bug resolve-threads: config warning: ${msg}\n`);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
// Missing OR unparseable .clud-bug.json — use defaults but log the
|
|
721
|
+
// specific reason so an operator with malformed config can diagnose
|
|
722
|
+
// why their `autoResolve.mode = 'off'` is being silently ignored.
|
|
723
|
+
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
|
724
|
+
if (code !== 'ENOENT') {
|
|
725
|
+
process.stderr.write(`clud-bug resolve-threads: .clud-bug.json read/parse error (${err && typeof err === 'object' && 'message' in err ? err.message : String(err)}); using defaults.\n`);
|
|
726
|
+
}
|
|
727
|
+
autoResolveConfig = readAutoResolveConfigFromCludBug(null);
|
|
728
|
+
}
|
|
729
|
+
if (autoResolveConfig.mode === 'off') {
|
|
730
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'mode-off' }) + '\n');
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
// ---- Fetch threads via GraphQL -----------------------------------------
|
|
734
|
+
// `gh api graphql` uses `-f` for String! variables and `-F` for typed
|
|
735
|
+
// (number / bool) variables. Owner + repo are String! per the query;
|
|
736
|
+
// pr is Int!. Reviewer-flagged: passing owner/repo as `-F` would coerce
|
|
737
|
+
// numeric-looking values incorrectly.
|
|
738
|
+
const threadsResult = spawnSync('gh', [
|
|
739
|
+
'api', 'graphql',
|
|
740
|
+
'-f', `query=${REVIEW_THREADS_QUERY}`,
|
|
741
|
+
'-f', `owner=${owner}`,
|
|
742
|
+
'-f', `repo=${repoName}`,
|
|
743
|
+
'-F', `pr=${prNumber}`,
|
|
744
|
+
], { encoding: 'utf8' });
|
|
745
|
+
if (threadsResult.status !== 0) {
|
|
746
|
+
process.stderr.write(`clud-bug resolve-threads: gh api graphql (threads) failed (exit ${threadsResult.status}): ${(threadsResult.stderr || '').slice(0, 500)}\n`);
|
|
747
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-fetch-failed' }) + '\n');
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
let threadNodes;
|
|
751
|
+
try {
|
|
752
|
+
const parsed = JSON.parse(threadsResult.stdout);
|
|
753
|
+
threadNodes = parsed?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
|
754
|
+
if (!Array.isArray(threadNodes))
|
|
755
|
+
threadNodes = [];
|
|
756
|
+
}
|
|
757
|
+
catch (e) {
|
|
758
|
+
process.stderr.write(`clud-bug resolve-threads: threads JSON parse failed: ${e.message}\n`);
|
|
759
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'threads-parse-failed' }) + '\n');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
// ---- Filter to bot-authored unresolved threads with our marker --------
|
|
763
|
+
// The workflow's GITHUB_TOKEN posts as `github-actions[bot]`. Match both
|
|
764
|
+
// the bracketed bot form and the bare `github-actions` form (GraphQL
|
|
765
|
+
// can return either depending on the node-type field requested).
|
|
766
|
+
const BOT_AUTHORS = new Set(['github-actions', 'github-actions[bot]']);
|
|
767
|
+
const candidates = [];
|
|
768
|
+
for (const t of threadNodes) {
|
|
769
|
+
if (!t || t.isResolved)
|
|
770
|
+
continue;
|
|
771
|
+
const c = t.comments?.nodes?.[0];
|
|
772
|
+
if (!c)
|
|
773
|
+
continue;
|
|
774
|
+
const authorLogin = c.author?.login ?? '';
|
|
775
|
+
if (!BOT_AUTHORS.has(authorLogin))
|
|
776
|
+
continue;
|
|
777
|
+
const parsed = parseThreadBody(c.body ?? '');
|
|
778
|
+
if (!parsed)
|
|
779
|
+
continue;
|
|
780
|
+
if (!c.path || (c.line === null && c.originalLine === null))
|
|
781
|
+
continue;
|
|
782
|
+
candidates.push({
|
|
783
|
+
threadId: t.id,
|
|
784
|
+
file: c.path,
|
|
785
|
+
line: c.line ?? c.originalLine,
|
|
786
|
+
parsed,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (candidates.length === 0) {
|
|
790
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, reason: 'no-bot-threads' }) + '\n');
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
// ---- Fetch diff for anchor context -------------------------------------
|
|
794
|
+
const filesResult = spawnSync('gh', [
|
|
795
|
+
'api',
|
|
796
|
+
`repos/${repo}/pulls/${prNumber}/files`,
|
|
797
|
+
'--paginate',
|
|
798
|
+
'--slurp',
|
|
799
|
+
], { encoding: 'utf8' });
|
|
800
|
+
if (filesResult.status !== 0) {
|
|
801
|
+
process.stderr.write(`clud-bug resolve-threads: diff fetch failed (exit ${filesResult.status}): ${(filesResult.stderr || '').slice(0, 500)}\n`);
|
|
802
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-fetch-failed' }) + '\n');
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
let diffFiles = [];
|
|
806
|
+
try {
|
|
807
|
+
const pages = JSON.parse(filesResult.stdout);
|
|
808
|
+
if (Array.isArray(pages)) {
|
|
809
|
+
for (const page of pages) {
|
|
810
|
+
if (Array.isArray(page))
|
|
811
|
+
diffFiles.push(...page);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
catch (e) {
|
|
816
|
+
process.stderr.write(`clud-bug resolve-threads: diff JSON parse failed: ${e.message}\n`);
|
|
817
|
+
process.stdout.write(JSON.stringify({ actions: [], verifierCallCount: 0, shouldRequestChanges: false, error: 'diff-parse-failed' }) + '\n');
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
// ---- Build PriorThread[] for runAutoResolve ----------------------------
|
|
821
|
+
const priorThreads = candidates.map((c) => {
|
|
822
|
+
const ctx = extractAnchorContext({ file: c.file, line: c.line }, diffFiles);
|
|
823
|
+
const findingBody = c.parsed.reasoning
|
|
824
|
+
? `${c.parsed.summary}\n\n${c.parsed.reasoning}`
|
|
825
|
+
: c.parsed.summary;
|
|
826
|
+
return {
|
|
827
|
+
threadId: c.threadId,
|
|
828
|
+
finding: {
|
|
829
|
+
severity: c.parsed.severity,
|
|
830
|
+
body: findingBody,
|
|
831
|
+
skill: c.parsed.skill,
|
|
832
|
+
file: c.file,
|
|
833
|
+
line: c.line,
|
|
834
|
+
},
|
|
835
|
+
codeBefore: ctx.codeBefore,
|
|
836
|
+
codeAfter: ctx.codeAfter,
|
|
837
|
+
...(ctx.diffAtAnchor !== undefined ? { diffAtAnchor: ctx.diffAtAnchor } : {}),
|
|
838
|
+
};
|
|
839
|
+
});
|
|
840
|
+
// ---- Define verifier (Anthropic Messages API via raw fetch) -----------
|
|
841
|
+
// Model is hardcoded here — the verifier needs a model that handles
|
|
842
|
+
// structured JSON output reliably. Sonnet 4.6 is the default. Override
|
|
843
|
+
// via CLUD_BUG_VERIFIER_MODEL env var if needed.
|
|
844
|
+
const verifierModel = String(process.env.CLUD_BUG_VERIFIER_MODEL ?? '').trim()
|
|
845
|
+
|| 'claude-sonnet-4-6';
|
|
846
|
+
async function verifier(input) {
|
|
847
|
+
const userPrompt = buildVerifierPrompt(input);
|
|
848
|
+
try {
|
|
849
|
+
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
850
|
+
method: 'POST',
|
|
851
|
+
headers: {
|
|
852
|
+
'content-type': 'application/json',
|
|
853
|
+
'anthropic-version': '2023-06-01',
|
|
854
|
+
'x-api-key': anthropicKey,
|
|
855
|
+
},
|
|
856
|
+
body: JSON.stringify({
|
|
857
|
+
model: verifierModel,
|
|
858
|
+
max_tokens: 300,
|
|
859
|
+
system: VERIFIER_SYSTEM,
|
|
860
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
861
|
+
}),
|
|
862
|
+
});
|
|
863
|
+
if (!resp.ok) {
|
|
864
|
+
const errText = await resp.text().catch(() => '');
|
|
865
|
+
return {
|
|
866
|
+
verdict: 'UNCERTAIN',
|
|
867
|
+
source: 'api-error',
|
|
868
|
+
rationale: `Anthropic API ${resp.status}: ${errText.slice(0, 200)}`,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
const body = await resp.json();
|
|
872
|
+
// Messages API: content is an array of blocks; pick the first text block.
|
|
873
|
+
const text = (body?.content ?? []).find((b) => b?.type === 'text')?.text ?? '';
|
|
874
|
+
return parseVerifierResponse(text);
|
|
875
|
+
}
|
|
876
|
+
catch (err) {
|
|
877
|
+
return {
|
|
878
|
+
verdict: 'UNCERTAIN',
|
|
879
|
+
source: 'api-error',
|
|
880
|
+
rationale: `verifier fetch error: ${err?.message ?? String(err)}`,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
// ---- Run pure orchestration -------------------------------------------
|
|
885
|
+
const result = await runAutoResolve({
|
|
886
|
+
priorThreads,
|
|
887
|
+
config: autoResolveConfig,
|
|
888
|
+
verifier,
|
|
889
|
+
});
|
|
890
|
+
// ---- Execute the actions via GraphQL mutations ------------------------
|
|
891
|
+
const actionsReport = [];
|
|
892
|
+
for (let i = 0; i < result.actions.length; i++) {
|
|
893
|
+
const action = result.actions[i];
|
|
894
|
+
const thread = priorThreads[i];
|
|
895
|
+
if (!action || !thread)
|
|
896
|
+
continue;
|
|
897
|
+
const report = {
|
|
898
|
+
threadId: thread.threadId,
|
|
899
|
+
file: thread.finding.file,
|
|
900
|
+
line: thread.finding.line,
|
|
901
|
+
verdict: action.verdict?.verdict,
|
|
902
|
+
kind: action.kind,
|
|
903
|
+
};
|
|
904
|
+
if (action.kind === 'skipped') {
|
|
905
|
+
actionsReport.push({ ...report, executed: 'skipped' });
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
// Post the marker reply (all non-skipped paths get a reply).
|
|
909
|
+
const replyResult = spawnSync('gh', [
|
|
910
|
+
'api', 'graphql',
|
|
911
|
+
'-f', `query=${ADD_REPLY_MUTATION}`,
|
|
912
|
+
'-f', `threadId=${thread.threadId}`,
|
|
913
|
+
'-f', `body=${action.markerBody}`,
|
|
914
|
+
], { encoding: 'utf8' });
|
|
915
|
+
if (replyResult.status !== 0) {
|
|
916
|
+
process.stderr.write(`clud-bug resolve-threads: ADD_REPLY failed for thread ${thread.threadId}: ${(replyResult.stderr || '').slice(0, 200)}\n`);
|
|
917
|
+
actionsReport.push({ ...report, executed: 'reply-failed' });
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
// Resolve only ADDRESSED threads.
|
|
921
|
+
if (action.kind === 'resolve') {
|
|
922
|
+
const resolveResult = spawnSync('gh', [
|
|
923
|
+
'api', 'graphql',
|
|
924
|
+
'-f', `query=${RESOLVE_THREAD_MUTATION}`,
|
|
925
|
+
'-f', `threadId=${thread.threadId}`,
|
|
926
|
+
], { encoding: 'utf8' });
|
|
927
|
+
if (resolveResult.status !== 0) {
|
|
928
|
+
process.stderr.write(`clud-bug resolve-threads: RESOLVE failed for thread ${thread.threadId}: ${(resolveResult.stderr || '').slice(0, 200)}\n`);
|
|
929
|
+
actionsReport.push({ ...report, executed: 'resolve-failed' });
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
actionsReport.push({ ...report, executed: 'resolved' });
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
// keep_open or keep_open_request_changes — reply already posted.
|
|
936
|
+
actionsReport.push({ ...report, executed: 'kept-open' });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
process.stdout.write(JSON.stringify({
|
|
940
|
+
actions: actionsReport,
|
|
941
|
+
verifierCallCount: result.verifierCallCount,
|
|
942
|
+
shouldRequestChanges: result.shouldRequestChanges,
|
|
943
|
+
}) + '\n');
|
|
944
|
+
}
|
|
495
945
|
// 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
|
|
496
946
|
// who follow the README invoke `clud-bug eval` — this routes to the
|
|
497
947
|
// same `node --test` runner CI uses, so dev and CI verdicts match.
|