clud-bug 0.6.21 → 0.6.23
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/bin/clud-bug.js +50 -0
- package/lib/prompts.js +47 -29
- package/lib/render-review.js +204 -0
- package/lib/render.js +17 -0
- package/lib/review-schema.js +159 -0
- package/lib/update.js +1 -0
- package/package.json +1 -1
- package/templates/workflow-py.yml.tmpl +63 -4
- package/templates/workflow-ts.yml.tmpl +63 -4
- package/templates/workflow.yml.tmpl +99 -4
package/bin/clud-bug.js
CHANGED
|
@@ -31,6 +31,8 @@ function parseArgs(argv) {
|
|
|
31
31
|
setProtection: true, quiet: false,
|
|
32
32
|
// 0.0.M.1 (v0.6.13): `clud-bug usage` flags.
|
|
33
33
|
repo: null, pr: null, limit: null, json: false,
|
|
34
|
+
// 0.0.O (v0.6.22): `clud-bug render` reads its payload from stdin.
|
|
35
|
+
stdin: false,
|
|
34
36
|
};
|
|
35
37
|
for (let i = 0; i < argv.length; i++) {
|
|
36
38
|
const a = argv[i];
|
|
@@ -49,6 +51,7 @@ function parseArgs(argv) {
|
|
|
49
51
|
else if (a === '--pr') args.pr = Number(argv[++i]);
|
|
50
52
|
else if (a === '--limit') args.limit = Number(argv[++i]);
|
|
51
53
|
else if (a === '--json') args.json = true;
|
|
54
|
+
else if (a === '--stdin') args.stdin = true;
|
|
52
55
|
else args._.push(a);
|
|
53
56
|
}
|
|
54
57
|
return args;
|
|
@@ -79,6 +82,11 @@ Commands:
|
|
|
79
82
|
eval Run the golden-set regression gate against the rendered review
|
|
80
83
|
prompt (must-contain / must-not-contain / byte-budget). Same as
|
|
81
84
|
\`node --test test/prompts.eval.test.js\` but works from any cwd.
|
|
85
|
+
render --stdin Render a structured-output JSON payload (the action's
|
|
86
|
+
\`outputs.structured_output\`, piped via stdin) to the
|
|
87
|
+
GitHub-markdown summary comment shape. Invoked by the
|
|
88
|
+
workflow post-step; output is what \`gh pr comment\`
|
|
89
|
+
receives. Empty stdin or non-object payload exits 2.
|
|
82
90
|
|
|
83
91
|
Options:
|
|
84
92
|
--offline Skip skills.sh; pin only the bundled baseline specimens.
|
|
@@ -130,12 +138,53 @@ async function main() {
|
|
|
130
138
|
case 'edit-workflow': return runEditWorkflow(args);
|
|
131
139
|
case 'usage': return runUsage(args);
|
|
132
140
|
case 'eval': return runEval();
|
|
141
|
+
case 'render': return runRender(args);
|
|
133
142
|
default:
|
|
134
143
|
process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
|
|
135
144
|
process.exit(2);
|
|
136
145
|
}
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
// 0.0.O (v0.6.22): render a structured-output JSON payload to the
|
|
149
|
+
// GitHub-markdown summary comment shape. Called by the post-step
|
|
150
|
+
// in the workflow templates: it reads the action's
|
|
151
|
+
// `outputs.structured_output` (one bundled JSON string), pipes it
|
|
152
|
+
// to stdin here, and we emit the rendered markdown on stdout for
|
|
153
|
+
// the shell to pass to `gh pr comment --body`.
|
|
154
|
+
//
|
|
155
|
+
// Usage: `clud-bug render --stdin` (only input source supported).
|
|
156
|
+
// Exit code: 0 on success, 2 on JSON parse error or non-object payload.
|
|
157
|
+
async function runRender(args) {
|
|
158
|
+
const { renderReview } = await import('../lib/render-review.js');
|
|
159
|
+
if (!args.stdin) {
|
|
160
|
+
process.stderr.write('clud-bug render: --stdin is required (the only supported input source).\n');
|
|
161
|
+
process.exit(2);
|
|
162
|
+
}
|
|
163
|
+
let raw = '';
|
|
164
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
165
|
+
raw = raw.trim();
|
|
166
|
+
if (!raw) {
|
|
167
|
+
// Empty structured_output → post-step is supposed to skip the
|
|
168
|
+
// render. Surface the situation rather than silently producing an
|
|
169
|
+
// empty comment.
|
|
170
|
+
process.stderr.write('clud-bug render: stdin was empty — nothing to render.\n');
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
let payload;
|
|
174
|
+
try {
|
|
175
|
+
payload = JSON.parse(raw);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
process.stderr.write(`clud-bug render: JSON parse failed: ${e.message}\n`);
|
|
178
|
+
process.exit(2);
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
process.stdout.write(renderReview(payload));
|
|
182
|
+
} catch (e) {
|
|
183
|
+
process.stderr.write(`clud-bug render: ${e.message}\n`);
|
|
184
|
+
process.exit(2);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
139
188
|
// 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
|
|
140
189
|
// who follow the README invoke `clud-bug eval` — this routes to the
|
|
141
190
|
// same `node --test` runner CI uses, so dev and CI verdicts match.
|
|
@@ -220,6 +269,7 @@ async function runInit(args) {
|
|
|
220
269
|
log(' drafting field kit...');
|
|
221
270
|
const tmplName = pickTemplate(signals.languages);
|
|
222
271
|
const tmplPath = join(TEMPLATES, tmplName);
|
|
272
|
+
// REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
|
|
223
273
|
const workflow = await renderFile(tmplPath, {
|
|
224
274
|
REVIEW_PROMPT: reviewPrompt({
|
|
225
275
|
projectDescription: buildDescriptionLine(signals),
|
package/lib/prompts.js
CHANGED
|
@@ -68,20 +68,24 @@ cached system prefix is free at 10%; per-PR fetches are not.
|
|
|
68
68
|
state lives in your PRIOR SUMMARY COMMENT as an HTML marker:
|
|
69
69
|
\`<!-- last-reviewed-sha: <sha> -->\`.
|
|
70
70
|
|
|
71
|
-
Identifying the PRIOR SUMMARY
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
Identifying the PRIOR SUMMARY (v0.6.22+ identity note):
|
|
72
|
+
From v0.6.22 (0.0.O) onward the summary is posted by a workflow
|
|
73
|
+
post-step under the \`github-actions[bot]\` identity, not
|
|
74
|
+
claude[bot]. Inline review threads are still claude[bot] (the
|
|
75
|
+
MCP tool routes through claude-code-action). Beware: the
|
|
76
|
+
in-progress \`Claude Code is working…\` comment claude-code-action
|
|
77
|
+
posts BEFORE this prompt runs is claude[bot]-authored but is NOT
|
|
78
|
+
your prior summary (no marker). Anchor on the H2 line
|
|
79
|
+
\`## 🐛 Clud Bug review\` — same anchor strict-mode uses.
|
|
76
80
|
|
|
77
81
|
Detection in three steps:
|
|
78
82
|
|
|
79
|
-
1. Fetch
|
|
83
|
+
1. Fetch github-actions[bot] comments newest-first:
|
|
80
84
|
\`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=100&sort=created&direction=desc"\`
|
|
81
|
-
Walk them in order;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
Walk them in order; filter to author \`github-actions[bot]\`,
|
|
86
|
+
and find the FIRST whose body starts with
|
|
87
|
+
\`## 🐛 Clud Bug review\`. THAT is your prior summary. In
|
|
88
|
+
its body, look for \`last-reviewed-sha: <sha>\`. (Pre-v0.6.22 summaries are claude[bot]-authored — if you find a recent claude[bot] comment with the H2 anchor and no newer github-actions[bot] match, treat it as the prior summary.)
|
|
85
89
|
|
|
86
90
|
2. If a SHA was found, verify it's still in HEAD's ancestry:
|
|
87
91
|
\`git merge-base --is-ancestor <prior_sha> $HEAD_SHA\`
|
|
@@ -105,7 +109,7 @@ cached system prefix is free at 10%; per-PR fetches are not.
|
|
|
105
109
|
per file (default 4,000 bytes). Baseline skills fit easily;
|
|
106
110
|
bloated user-added skills get truncated.
|
|
107
111
|
|
|
108
|
-
- PR comments: \`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=20" --jq '.[] | select(.user.login != "claude[bot]")' | head -c "$MAX_COMMENT_BYTES"\`
|
|
112
|
+
- PR comments: \`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=20" --jq '.[] | select(.user.login != "claude[bot]" and .user.login != "github-actions[bot]")' | head -c "$MAX_COMMENT_BYTES"\`
|
|
109
113
|
(default 20,000 bytes, 20 most-recent). Skips your own prior
|
|
110
114
|
comments — the FIX-PUSH FLOW handles those via reviewThreads
|
|
111
115
|
GraphQL instead.
|
|
@@ -185,13 +189,18 @@ subsequent fix-push re-fetches only the delta since this SHA. Omit
|
|
|
185
189
|
it and the next review falls back to full \`gh pr diff\`.
|
|
186
190
|
|
|
187
191
|
Strict-mode header (opt-in): if .claude/skills/.clud-bug.json has
|
|
188
|
-
\`{ "strictMode": true }\`, the
|
|
189
|
-
|
|
190
|
-
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
\`{ "strictMode": true }\`, set the \`status_header\` schema field
|
|
193
|
+
to signal verdict:
|
|
194
|
+
- any critical issue flagged → \`status_header: "critical findings"\`
|
|
195
|
+
(renderer emits \`## 🐛 Clud Bug review — critical findings\`)
|
|
196
|
+
- otherwise → \`status_header: "clean"\` (renderer emits
|
|
197
|
+
\`## 🐛 Clud Bug review — clean\`)
|
|
198
|
+
The strict-mode gate post-step greps for "critical findings" and
|
|
199
|
+
fails the check.
|
|
200
|
+
|
|
201
|
+
If strictMode is unset or absent, set \`status_header: "bare"\` —
|
|
202
|
+
the renderer emits the bare \`## 🐛 Clud Bug review\` (no suffix),
|
|
203
|
+
matching the v0.6.21- visible behaviour for non-strict-mode repos.
|
|
195
204
|
|
|
196
205
|
Tone: conversational, concise field-naturalist voice (you are Clud
|
|
197
206
|
Bug examining specimens of code) — never at the cost of clarity,
|
|
@@ -214,17 +223,24 @@ Your review lives in TWO surfaces, in this order:
|
|
|
214
223
|
Cross-cutting findings (no specific line) stay summary-only —
|
|
215
224
|
but default to inline whenever you can name file:line.
|
|
216
225
|
|
|
217
|
-
2. SUMMARY PR COMMENT —
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
strict-mode
|
|
226
|
+
2. SUMMARY PR COMMENT — emitted as STRUCTURED JSON via the
|
|
227
|
+
workflow's \`--json-schema\` flag (0.0.O / v0.6.22+). Do NOT
|
|
228
|
+
post the summary yourself via \`gh pr comment\` — a post-step
|
|
229
|
+
reads your structured output and renders the comment with the
|
|
230
|
+
exact format the strict-mode gate expects. Populate every
|
|
231
|
+
schema field (\`status_header\`, \`summary_counts\`,
|
|
232
|
+
\`per_skill_scan\`, \`critical_findings\`, \`minor_findings\`,
|
|
233
|
+
\`preexisting_findings\`, \`skills_referenced\`,
|
|
234
|
+
\`last_reviewed_sha\`); \`dedicated_sections\` and
|
|
235
|
+
\`diagnostics\` are optional but emit them when applicable.
|
|
236
|
+
See workflow env for the schema; the format docs below describe
|
|
237
|
+
what each field becomes after rendering.
|
|
222
238
|
|
|
223
239
|
The comment body MUST start with:
|
|
224
240
|
|
|
225
241
|
## 🐛 Clud Bug review
|
|
226
242
|
|
|
227
|
-
(
|
|
243
|
+
(The post-step renders this H2 anchor — the strict-mode gate greps it.)
|
|
228
244
|
|
|
229
245
|
On the next non-empty line, emit:
|
|
230
246
|
|
|
@@ -297,14 +313,16 @@ Shared-mode skill findings stay in the combined "Critical findings"
|
|
|
297
313
|
(e.g. a logging-PII issue belongs in both critical-issues-only and
|
|
298
314
|
pii-and-compliance at once).
|
|
299
315
|
|
|
300
|
-
|
|
301
|
-
|
|
316
|
+
Emit the summary as structured JSON output (the workflow's
|
|
317
|
+
--json-schema captures it; a post-step renders + posts via gh pr
|
|
318
|
+
comment). Do NOT post the summary yourself.
|
|
302
319
|
|
|
303
|
-
Inline findings post via mcp__github_inline_comment__create_inline_comment
|
|
320
|
+
Inline findings still post via mcp__github_inline_comment__create_inline_comment
|
|
304
321
|
(with \`confirmed: true\`). Pass ordering: (a) post inline findings,
|
|
305
322
|
(b) resolve prior threads now fixed (FIX-PUSH FLOW below — feeds
|
|
306
|
-
"
|
|
307
|
-
|
|
323
|
+
"resolved_from_prior" counter in the JSON), (c) emit structured
|
|
324
|
+
summary output. Step (b) MUST complete before (c) so the counter
|
|
325
|
+
is accurate; (a)/(b) order between themselves doesn't matter.
|
|
308
326
|
|
|
309
327
|
FIX-PUSH FLOW (when prior claude[bot] threads exist):
|
|
310
328
|
List prior claude[bot] inline threads, resolve the ones whose issue
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Render a clud-bug review's structured-output JSON to the GitHub-markdown
|
|
2
|
+
// summary comment shape the workflow has been posting since v0.6.5.
|
|
3
|
+
//
|
|
4
|
+
// 0.0.O (v0.6.22): introduced as the receiver for `--json-schema` output.
|
|
5
|
+
// The LLM emits structured JSON (one bundled string via the action's
|
|
6
|
+
// `outputs.structured_output`); a workflow post-step pipes that JSON to
|
|
7
|
+
// `clud-bug render --stdin` (CLI subcommand), which calls renderReview()
|
|
8
|
+
// here, then posts the result via `gh pr comment`. Failure mode: if
|
|
9
|
+
// `structured_output` is empty (max retries hit), the post-step is
|
|
10
|
+
// skipped and the LLM's prior free-form behaviour stands (it had already
|
|
11
|
+
// been instructed to post via `gh pr comment` directly as a fallback).
|
|
12
|
+
//
|
|
13
|
+
// Why an outside renderer at all: with --json-schema the LLM can no
|
|
14
|
+
// longer paraphrase the comment format (good for consistency, bad if the
|
|
15
|
+
// rendered shape is wrong). Centralising the markdown shape here means a
|
|
16
|
+
// future format tweak edits one function rather than the prompt.
|
|
17
|
+
|
|
18
|
+
const SEVERITY_EMOJI = { critical: '🔴', minor: '🟡', preexisting: '🟣' };
|
|
19
|
+
const SEVERITY_LABEL = {
|
|
20
|
+
critical: 'important',
|
|
21
|
+
minor: 'nit',
|
|
22
|
+
preexisting: 'pre-existing',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Render the full summary comment markdown. `data` is the parsed JSON
|
|
26
|
+
// matching the schema (see schema.js). Returns a string suitable for
|
|
27
|
+
// `gh pr comment --body`.
|
|
28
|
+
export function renderReview(data) {
|
|
29
|
+
if (!data || typeof data !== 'object') {
|
|
30
|
+
throw new TypeError('renderReview: data must be an object');
|
|
31
|
+
}
|
|
32
|
+
const out = [];
|
|
33
|
+
out.push(renderHeader(data));
|
|
34
|
+
out.push('');
|
|
35
|
+
out.push(renderStatusLine(data.summary_counts));
|
|
36
|
+
out.push('');
|
|
37
|
+
out.push(renderStatsHeader(data.summary_counts));
|
|
38
|
+
out.push('');
|
|
39
|
+
out.push(...renderPerSkillScan(data.per_skill_scan));
|
|
40
|
+
out.push('');
|
|
41
|
+
for (const section of data.dedicated_sections || []) {
|
|
42
|
+
out.push(...renderDedicatedSection(section));
|
|
43
|
+
out.push('');
|
|
44
|
+
}
|
|
45
|
+
if (nonEmpty(data.critical_findings)) {
|
|
46
|
+
out.push('### Critical findings');
|
|
47
|
+
out.push('');
|
|
48
|
+
out.push(...renderFindings(data.critical_findings, 'critical'));
|
|
49
|
+
out.push('');
|
|
50
|
+
}
|
|
51
|
+
if (nonEmpty(data.minor_findings)) {
|
|
52
|
+
out.push('### Minor findings');
|
|
53
|
+
out.push('');
|
|
54
|
+
out.push(...renderFindings(data.minor_findings, 'minor'));
|
|
55
|
+
out.push('');
|
|
56
|
+
}
|
|
57
|
+
if (nonEmpty(data.preexisting_findings)) {
|
|
58
|
+
out.push('### Pre-existing findings');
|
|
59
|
+
out.push('');
|
|
60
|
+
out.push(...renderFindings(data.preexisting_findings, 'preexisting'));
|
|
61
|
+
out.push('');
|
|
62
|
+
}
|
|
63
|
+
if (nonEmpty(data.diagnostics)) {
|
|
64
|
+
out.push('### Diagnostics');
|
|
65
|
+
out.push('');
|
|
66
|
+
for (const line of data.diagnostics) out.push(`- ${line}`);
|
|
67
|
+
out.push('');
|
|
68
|
+
}
|
|
69
|
+
out.push(renderSkillsReferenced(data.skills_referenced));
|
|
70
|
+
out.push('');
|
|
71
|
+
if (data.last_reviewed_sha) {
|
|
72
|
+
out.push(`<!-- last-reviewed-sha: ${data.last_reviewed_sha} -->`);
|
|
73
|
+
}
|
|
74
|
+
// Trim trailing blank lines but always keep a single trailing newline so
|
|
75
|
+
// the comment ends with a final newline (markdown rendering is unchanged
|
|
76
|
+
// either way, but it matches the prior LLM-driven shape).
|
|
77
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderHeader(data) {
|
|
81
|
+
const verdict = data.status_header;
|
|
82
|
+
const base = '## 🐛 Clud Bug review';
|
|
83
|
+
if (verdict === 'critical findings') return `${base} — critical findings`;
|
|
84
|
+
if (verdict === 'clean') return `${base} — clean`;
|
|
85
|
+
// 'bare' (non-strict-mode default) OR an unexpected verdict — render
|
|
86
|
+
// the unsuffixed H2. Strict-mode gate's anchor stays intact either way.
|
|
87
|
+
return base;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderStatusLine(counts) {
|
|
91
|
+
const c = sanitizeCounts(counts);
|
|
92
|
+
return `**This round:** ${c.critical} critical · ${c.minor} minor · ${c.resolved_from_prior} resolved from prior · ${c.still_open} still open`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Severity-emoji stats header. Counts pre-existing in 🟣 even though
|
|
96
|
+
// it's not in summary_counts (the prompt counts pre-existing separately
|
|
97
|
+
// in preexisting_findings.length).
|
|
98
|
+
function renderStatsHeader(counts) {
|
|
99
|
+
const c = sanitizeCounts(counts);
|
|
100
|
+
return `Found: ${c.critical} 🔴 / ${c.minor} 🟡 / ${c.preexisting} 🟣`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderPerSkillScan(scan) {
|
|
104
|
+
const out = ['### Per-skill scan'];
|
|
105
|
+
if (!Array.isArray(scan) || scan.length === 0) {
|
|
106
|
+
out.push('- (no skills loaded — review proceeded against the baseline.)');
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
for (const entry of scan) {
|
|
110
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
111
|
+
const skill = String(entry.skill || '').trim();
|
|
112
|
+
const outcome = String(entry.outcome || '').trim();
|
|
113
|
+
if (!skill) continue;
|
|
114
|
+
out.push(`- [${skill}]: ${outcome || 'scanned (no outcome reported).'}`);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderDedicatedSection(section) {
|
|
120
|
+
if (!section || typeof section !== 'object') return [];
|
|
121
|
+
const name = String(section.section_name || '').trim();
|
|
122
|
+
const skill = String(section.skill || '').trim();
|
|
123
|
+
const header = skill && name
|
|
124
|
+
? `### ${name} [${skill}]`
|
|
125
|
+
: `### ${name || skill || 'Dedicated section'}`;
|
|
126
|
+
const out = [header, ''];
|
|
127
|
+
if (Array.isArray(section.findings) && section.findings.length > 0) {
|
|
128
|
+
// Dedicated-section findings use the same emoji-prefix block.
|
|
129
|
+
// Default severity for dedicated sections is "critical" — they're
|
|
130
|
+
// domain-specific findings the skill considers important.
|
|
131
|
+
out.push(...renderFindings(section.findings, 'critical'));
|
|
132
|
+
} else {
|
|
133
|
+
out.push('No findings.');
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderFindings(findings, severity) {
|
|
139
|
+
const emoji = SEVERITY_EMOJI[severity] || '🔴';
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const f of findings) {
|
|
142
|
+
if (!f || typeof f !== 'object') continue;
|
|
143
|
+
const skill = String(f.skill || '').trim();
|
|
144
|
+
const summary = String(f.summary || '').trim();
|
|
145
|
+
if (!summary) continue;
|
|
146
|
+
const skillPrefix = skill ? `[${skill}]: ` : '';
|
|
147
|
+
const anchor = locationAnchor(f);
|
|
148
|
+
const claim = anchor
|
|
149
|
+
? `${emoji} ${skillPrefix}${stripTrailingPunctuation(summary)} (${anchor}).`
|
|
150
|
+
: `${emoji} ${skillPrefix}${summary}`;
|
|
151
|
+
out.push(claim);
|
|
152
|
+
if (f.reasoning) {
|
|
153
|
+
out.push('<details><summary>Reasoning</summary>');
|
|
154
|
+
out.push('');
|
|
155
|
+
out.push(String(f.reasoning).trim());
|
|
156
|
+
out.push('');
|
|
157
|
+
out.push('</details>');
|
|
158
|
+
}
|
|
159
|
+
out.push('');
|
|
160
|
+
}
|
|
161
|
+
// Remove the trailing empty line — renderReview adds its own separators.
|
|
162
|
+
if (out[out.length - 1] === '') out.pop();
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderSkillsReferenced(skills) {
|
|
167
|
+
if (!Array.isArray(skills) || skills.length === 0) {
|
|
168
|
+
return 'Skills referenced: [none] — no installed skill applied to this diff.';
|
|
169
|
+
}
|
|
170
|
+
return `Skills referenced: [${skills.join(', ')}]`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- helpers ---
|
|
174
|
+
|
|
175
|
+
function nonEmpty(arr) {
|
|
176
|
+
return Array.isArray(arr) && arr.length > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sanitizeCounts(counts) {
|
|
180
|
+
const c = counts && typeof counts === 'object' ? counts : {};
|
|
181
|
+
return {
|
|
182
|
+
critical: numOrZero(c.critical),
|
|
183
|
+
minor: numOrZero(c.minor),
|
|
184
|
+
preexisting: numOrZero(c.preexisting),
|
|
185
|
+
resolved_from_prior: numOrZero(c.resolved_from_prior),
|
|
186
|
+
still_open: numOrZero(c.still_open),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function numOrZero(v) {
|
|
191
|
+
const n = Number(v);
|
|
192
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function locationAnchor(f) {
|
|
196
|
+
const file = String(f.file || '').trim();
|
|
197
|
+
if (!file) return null;
|
|
198
|
+
const line = Number(f.line);
|
|
199
|
+
return Number.isFinite(line) && line > 0 ? `${file}:${line}` : file;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stripTrailingPunctuation(s) {
|
|
203
|
+
return s.replace(/[.!?]+$/, '');
|
|
204
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { serializedReviewSchema } from './review-schema.js';
|
|
2
6
|
|
|
3
7
|
const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
|
|
4
8
|
|
|
9
|
+
// CLUD_BUG_VERSION (0.0.O / v0.6.22) — read from this package's
|
|
10
|
+
// package.json at module-load time. The rendered workflow uses
|
|
11
|
+
// `npx --yes clud-bug@<CLUD_BUG_VERSION>` in the post-step that renders
|
|
12
|
+
// structured output to markdown. Pinning to the version that ran
|
|
13
|
+
// `clud-bug init` guarantees the renderer's output shape matches the
|
|
14
|
+
// prompt's expectations.
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const PKG_VERSION = JSON.parse(
|
|
17
|
+
readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
|
|
18
|
+
).version;
|
|
19
|
+
|
|
5
20
|
// Default values for substitution tokens that every template uses.
|
|
6
21
|
// Callers can override per-render by passing the same key in `vars`.
|
|
7
22
|
//
|
|
@@ -13,6 +28,8 @@ const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
|
|
|
13
28
|
// version in their own forked workflow.
|
|
14
29
|
export const DEFAULTS = {
|
|
15
30
|
CCA_VERSION: 'v1.0.133',
|
|
31
|
+
CLUD_BUG_VERSION: PKG_VERSION,
|
|
32
|
+
REVIEW_SCHEMA: serializedReviewSchema(),
|
|
16
33
|
};
|
|
17
34
|
|
|
18
35
|
// Multi-line value substitution preserves YAML/Markdown indentation by
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// JSON Schema for clud-bug review structured output (0.0.O / v0.6.22).
|
|
2
|
+
//
|
|
3
|
+
// Passed to claude-code-action via `claude_args: --json-schema '<JSON>'`.
|
|
4
|
+
// The Agent SDK validates the LLM's emitted JSON against this schema and
|
|
5
|
+
// re-prompts on mismatch (internal retry; not a clud-bug-level loop).
|
|
6
|
+
//
|
|
7
|
+
// Schema design choices:
|
|
8
|
+
// - Flat top-level — composite-action outputs are a single string, so we
|
|
9
|
+
// fromJSON() once and pluck fields. No deep nesting that would force
|
|
10
|
+
// extra path traversal in the post-step shell.
|
|
11
|
+
// - Word/char caps in `description:` — the SDK doc names these as the
|
|
12
|
+
// primary lever for keeping output cheap. They're advisory (the SDK
|
|
13
|
+
// does not enforce numeric caps; the LLM treats them as instruction).
|
|
14
|
+
// 0.0.X already lives in the prompt, so the schema description here is
|
|
15
|
+
// a complementary belt-and-suspenders signal.
|
|
16
|
+
// - Required minimum — only the fields a renderer absolutely needs to
|
|
17
|
+
// produce a valid summary comment. Counts are always required (the
|
|
18
|
+
// stats header + status block depend on them); finding arrays are
|
|
19
|
+
// required but may be empty (a clean review has 0 findings, not
|
|
20
|
+
// missing arrays).
|
|
21
|
+
// - `additionalProperties: false` on every object — schema-strict mode.
|
|
22
|
+
// Anthropic's structured-outputs doc explicitly recommends this to
|
|
23
|
+
// keep the model from inventing fields.
|
|
24
|
+
//
|
|
25
|
+
// Bumped via deliberate edit; not derived from a TypeScript type. The
|
|
26
|
+
// rendering side (lib/render-review.js) treats unknown fields permissively
|
|
27
|
+
// — schema and renderer can drift up to one minor version safely.
|
|
28
|
+
|
|
29
|
+
const FINDING_ITEM = {
|
|
30
|
+
type: 'object',
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
properties: {
|
|
33
|
+
skill: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'The skill name in brackets, e.g. "critical-issues-only". Must match a loaded skill or "(none)".',
|
|
36
|
+
},
|
|
37
|
+
file: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Path of the affected file relative to repo root. Optional when the finding is cross-cutting.',
|
|
40
|
+
},
|
|
41
|
+
line: {
|
|
42
|
+
type: 'integer',
|
|
43
|
+
minimum: 1,
|
|
44
|
+
description: 'Line number in the affected file. Required when `file` is set.',
|
|
45
|
+
},
|
|
46
|
+
summary: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'One sentence stating the claim. Max ~20 words; no trailing period (the renderer adds one).',
|
|
49
|
+
},
|
|
50
|
+
reasoning: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Evidence anchor + suggested fix. Max ~80 words. Rendered inside <details> block; can be omitted for self-evident findings.',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ['skill', 'summary'],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const PER_SKILL_SCAN_ITEM = {
|
|
59
|
+
type: 'object',
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
properties: {
|
|
62
|
+
skill: { type: 'string' },
|
|
63
|
+
outcome: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'One sentence describing what the skill found. Max ~15 words. Examples: "scanned all paths. 2 critical findings below.", "0 findings.", "not applicable to this diff."',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ['skill', 'outcome'],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const REVIEW_SCHEMA = {
|
|
72
|
+
type: 'object',
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
properties: {
|
|
75
|
+
status_header: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
enum: ['critical findings', 'clean', 'bare'],
|
|
78
|
+
description: 'Strict-mode opt-in repos: `critical findings` when ANY 🔴 finding exists, otherwise `clean`. Non-strict-mode repos (the default): emit `bare` — the renderer produces a `## 🐛 Clud Bug review` H2 with no suffix, matching the v0.6.21- behaviour. Check .claude/skills/.clud-bug.json for `strictMode: true` to pick critical/clean vs bare.',
|
|
79
|
+
},
|
|
80
|
+
summary_counts: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
properties: {
|
|
84
|
+
critical: { type: 'integer', minimum: 0 },
|
|
85
|
+
minor: { type: 'integer', minimum: 0 },
|
|
86
|
+
preexisting: { type: 'integer', minimum: 0 },
|
|
87
|
+
resolved_from_prior: { type: 'integer', minimum: 0 },
|
|
88
|
+
still_open: { type: 'integer', minimum: 0 },
|
|
89
|
+
},
|
|
90
|
+
required: ['critical', 'minor', 'preexisting', 'resolved_from_prior', 'still_open'],
|
|
91
|
+
},
|
|
92
|
+
per_skill_scan: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
description: 'One entry per LOADED skill — even silent ones. Empty when no skills are installed.',
|
|
95
|
+
items: PER_SKILL_SCAN_ITEM,
|
|
96
|
+
},
|
|
97
|
+
critical_findings: {
|
|
98
|
+
type: 'array',
|
|
99
|
+
description: 'NEW 🔴 findings (bugs, security, perf, missing test coverage). May be empty.',
|
|
100
|
+
items: FINDING_ITEM,
|
|
101
|
+
},
|
|
102
|
+
minor_findings: {
|
|
103
|
+
type: 'array',
|
|
104
|
+
description: 'NEW 🟡 findings (nits, style, observations). May be empty.',
|
|
105
|
+
items: FINDING_ITEM,
|
|
106
|
+
},
|
|
107
|
+
preexisting_findings: {
|
|
108
|
+
type: 'array',
|
|
109
|
+
description: 'NEW 🟣 findings (issues that pre-date this PR). May be empty.',
|
|
110
|
+
items: FINDING_ITEM,
|
|
111
|
+
},
|
|
112
|
+
dedicated_sections: {
|
|
113
|
+
type: 'array',
|
|
114
|
+
description: 'Per-dedicated-mode-skill section blocks. Each item carries the section header text and its findings.',
|
|
115
|
+
items: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
properties: {
|
|
119
|
+
section_name: { type: 'string', description: 'Human-readable section title, e.g. "Brand voice".' },
|
|
120
|
+
skill: { type: 'string', description: 'The dedicated skill name.' },
|
|
121
|
+
findings: { type: 'array', items: FINDING_ITEM },
|
|
122
|
+
},
|
|
123
|
+
required: ['section_name', 'skill', 'findings'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
diagnostics: {
|
|
127
|
+
type: 'array',
|
|
128
|
+
description: '0.0.T tee-hint diagnostics. One line per `head -c` cap that fired during fetch. Empty when no truncation occurred.',
|
|
129
|
+
items: { type: 'string' },
|
|
130
|
+
},
|
|
131
|
+
skills_referenced: {
|
|
132
|
+
type: 'array',
|
|
133
|
+
description: 'Names of every skill cited in any finding. Empty list (or ["(none)"]) when no skill applied.',
|
|
134
|
+
items: { type: 'string' },
|
|
135
|
+
},
|
|
136
|
+
last_reviewed_sha: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
description: 'The HEAD SHA at review time, used by the incremental-diff handshake. Set to the literal value of the workflow env var $HEAD_SHA.',
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
required: [
|
|
142
|
+
'status_header',
|
|
143
|
+
'summary_counts',
|
|
144
|
+
'per_skill_scan',
|
|
145
|
+
'critical_findings',
|
|
146
|
+
'minor_findings',
|
|
147
|
+
'preexisting_findings',
|
|
148
|
+
'skills_referenced',
|
|
149
|
+
'last_reviewed_sha',
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Serialize the schema for inclusion in workflow templates as the
|
|
154
|
+
// `--json-schema '<JSON>'` argument. Single-line (the workflow YAML uses
|
|
155
|
+
// the pipe block; single-quoted JSON inside that needs to stay flat to
|
|
156
|
+
// avoid YAML parser surprises with embedded newlines).
|
|
157
|
+
export function serializedReviewSchema() {
|
|
158
|
+
return JSON.stringify(REVIEW_SCHEMA);
|
|
159
|
+
}
|
package/lib/update.js
CHANGED
|
@@ -45,6 +45,7 @@ export async function runUpdate({
|
|
|
45
45
|
// 1. Re-render review workflow with the latest template.
|
|
46
46
|
const signals = await detect(cwd);
|
|
47
47
|
const tmplName = pickTemplate(signals.languages);
|
|
48
|
+
// REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
|
|
48
49
|
const newReview = await renderFile(join(templatesDir, tmplName), {
|
|
49
50
|
REVIEW_PROMPT: reviewPrompt({
|
|
50
51
|
projectDescription: buildDescriptionLine(signals),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clud-bug",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.23",
|
|
4
4
|
"description": "Skill-driven Claude PR review. Ship a brand-voice skill, get brand reviews. Each finding cites the skill that motivated it. CLI installs the workflow + a baseline kit; add more from skills.sh.",
|
|
5
5
|
"homepage": "https://cludbug.dev",
|
|
6
6
|
"bugs": "https://github.com/thrillmade/clud-bug/issues",
|
|
@@ -15,6 +15,7 @@ jobs:
|
|
|
15
15
|
outputs:
|
|
16
16
|
is_workflow_only: ${{ steps.classify.outputs.is_workflow_only }}
|
|
17
17
|
model: ${{ steps.classify.outputs.model }}
|
|
18
|
+
max_turns: ${{ steps.classify.outputs.max_turns }}
|
|
18
19
|
steps:
|
|
19
20
|
- name: Classify PR diff
|
|
20
21
|
id: classify
|
|
@@ -29,6 +30,8 @@ jobs:
|
|
|
29
30
|
if [ -z "$CHANGED" ]; then
|
|
30
31
|
echo "is_workflow_only=false" >> "$GITHUB_OUTPUT"
|
|
31
32
|
echo "model=$MODEL" >> "$GITHUB_OUTPUT"
|
|
33
|
+
# v0.6.23 / §5: max_turns must always be emitted — see workflow.yml.tmpl for design notes.
|
|
34
|
+
echo "max_turns=15" >> "$GITHUB_OUTPUT"
|
|
32
35
|
exit 0
|
|
33
36
|
fi
|
|
34
37
|
IS_WORKFLOW_ONLY=true
|
|
@@ -75,6 +78,23 @@ jobs:
|
|
|
75
78
|
fi
|
|
76
79
|
echo "model=$MODEL" >> "$GITHUB_OUTPUT"
|
|
77
80
|
|
|
81
|
+
# Adaptive max-turns (v0.6.23 / §5) — see workflow.yml.tmpl for design notes.
|
|
82
|
+
MAX_TURNS=15
|
|
83
|
+
if [ "$IS_TRIVIAL" = "true" ]; then
|
|
84
|
+
MAX_TURNS=10
|
|
85
|
+
else
|
|
86
|
+
FILE_COUNT=$(echo "$CHANGED" | wc -l | tr -d ' ')
|
|
87
|
+
THREAD_COUNT=$(gh api graphql -f query='{repository(owner:"'"$(echo "$REPO" | cut -d/ -f1)"'",name:"'"$(echo "$REPO" | cut -d/ -f2)"'"){pullRequest(number:'"$PR_NUMBER"'){reviewThreads(first:50){nodes{isResolved comments(first:1){nodes{author{login}}}}}}}}' --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false and (.comments.nodes[0].author.login == "claude" or .comments.nodes[0].author.login == "claude[bot]"))] | length' 2>/dev/null || echo 0)
|
|
88
|
+
THREAD_COUNT=${THREAD_COUNT:-0}
|
|
89
|
+
if [ "$FILE_COUNT" -ge 30 ] || [ "$THREAD_COUNT" -ge 6 ]; then
|
|
90
|
+
MAX_TURNS=40
|
|
91
|
+
elif [ "$FILE_COUNT" -ge 10 ] || [ "$THREAD_COUNT" -ge 3 ]; then
|
|
92
|
+
MAX_TURNS=25
|
|
93
|
+
fi
|
|
94
|
+
echo "::notice title=Clud Bug 🐛::Turn budget: $MAX_TURNS ($FILE_COUNT files, $THREAD_COUNT prior threads)."
|
|
95
|
+
fi
|
|
96
|
+
echo "max_turns=$MAX_TURNS" >> "$GITHUB_OUTPUT"
|
|
97
|
+
|
|
78
98
|
clud-bug-review:
|
|
79
99
|
needs: paths-check
|
|
80
100
|
if: needs.paths-check.outputs.is_workflow_only != 'true'
|
|
@@ -85,6 +105,8 @@ jobs:
|
|
|
85
105
|
id-token: write
|
|
86
106
|
# checks: write — composite emits per-skill check-runs (BB.3).
|
|
87
107
|
checks: write
|
|
108
|
+
# actions: read (v0.6.23 / §5) — github_ci MCP server. See workflow.yml.tmpl for design notes.
|
|
109
|
+
actions: read
|
|
88
110
|
|
|
89
111
|
steps:
|
|
90
112
|
- uses: actions/checkout@v6
|
|
@@ -108,7 +130,7 @@ jobs:
|
|
|
108
130
|
if $IS_FORK || $IS_BOT; then
|
|
109
131
|
REASON=$($IS_BOT && echo "bot author ($PR_AUTHOR)" || echo "fork ($HEAD_REPO)")
|
|
110
132
|
EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
|
|
111
|
-
--jq '[.[] | select(.user.login == "
|
|
133
|
+
--jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
|
|
112
134
|
if [ "${EXISTING:-0}" = "0" ]; then
|
|
113
135
|
BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
|
|
114
136
|
gh pr comment "$PR_NUMBER" --body "$BODY" || true
|
|
@@ -143,19 +165,56 @@ jobs:
|
|
|
143
165
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
144
166
|
track_progress: true
|
|
145
167
|
show_full_output: true
|
|
168
|
+
# v0.6.22 / 0.0.O: --json-schema captures the summary as
|
|
169
|
+
# structured output; post-step renders + posts. See workflow.yml.tmpl for design notes.
|
|
146
170
|
claude_args: |
|
|
147
171
|
--model ${{ needs.paths-check.outputs.model }}
|
|
148
|
-
--max-turns
|
|
149
|
-
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr
|
|
172
|
+
--max-turns ${{ needs.paths-check.outputs.max_turns }}
|
|
173
|
+
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
|
|
174
|
+
--json-schema '{{REVIEW_SCHEMA}}'
|
|
150
175
|
prompt: |
|
|
151
176
|
Review this pull request following the discipline in your
|
|
152
177
|
system prompt — every rule about skill routing, comment
|
|
153
178
|
format, the strict-mode header, the two-surface review
|
|
154
179
|
shape, and the FIX-PUSH FLOW applies.
|
|
180
|
+
id: clud-bug-review
|
|
181
|
+
|
|
182
|
+
# v0.6.22 / 0.0.O: render structured output → post via gh pr comment.
|
|
183
|
+
- name: Render + post structured review
|
|
184
|
+
if: success() && steps.clud-bug-review.outputs.structured_output != ''
|
|
185
|
+
env:
|
|
186
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
187
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
188
|
+
STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
|
|
189
|
+
run: |
|
|
190
|
+
set -euo pipefail
|
|
191
|
+
BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
|
|
192
|
+
gh pr comment "$PR_NUMBER" --body "$BODY"
|
|
193
|
+
|
|
194
|
+
# Fallback when structured_output is empty (max-retries hit).
|
|
195
|
+
- name: Fallback summary (structured_output empty)
|
|
196
|
+
if: success() && steps.clud-bug-review.outputs.structured_output == ''
|
|
197
|
+
env:
|
|
198
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
199
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
200
|
+
run: |
|
|
201
|
+
set -euo pipefail
|
|
202
|
+
gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
|
|
203
|
+
|
|
204
|
+
**This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
|
|
205
|
+
|
|
206
|
+
Found: 0 🔴 / 0 🟡 / 0 🟣
|
|
207
|
+
|
|
208
|
+
⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
|
|
209
|
+
|
|
210
|
+
Skills referenced: [none]"
|
|
155
211
|
|
|
156
212
|
# Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
|
|
157
213
|
- name: Strict mode — fail check on critical findings
|
|
158
214
|
if: success()
|
|
159
|
-
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.
|
|
215
|
+
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.23
|
|
160
216
|
with:
|
|
161
217
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
218
|
+
# v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
|
|
219
|
+
# See workflow.yml.tmpl for design notes.
|
|
220
|
+
bot-login: 'github-actions[bot]'
|
|
@@ -15,6 +15,7 @@ jobs:
|
|
|
15
15
|
outputs:
|
|
16
16
|
is_workflow_only: ${{ steps.classify.outputs.is_workflow_only }}
|
|
17
17
|
model: ${{ steps.classify.outputs.model }}
|
|
18
|
+
max_turns: ${{ steps.classify.outputs.max_turns }}
|
|
18
19
|
steps:
|
|
19
20
|
- name: Classify PR diff
|
|
20
21
|
id: classify
|
|
@@ -29,6 +30,8 @@ jobs:
|
|
|
29
30
|
if [ -z "$CHANGED" ]; then
|
|
30
31
|
echo "is_workflow_only=false" >> "$GITHUB_OUTPUT"
|
|
31
32
|
echo "model=$MODEL" >> "$GITHUB_OUTPUT"
|
|
33
|
+
# v0.6.23 / §5: max_turns must always be emitted — see workflow.yml.tmpl for design notes.
|
|
34
|
+
echo "max_turns=15" >> "$GITHUB_OUTPUT"
|
|
32
35
|
exit 0
|
|
33
36
|
fi
|
|
34
37
|
IS_WORKFLOW_ONLY=true
|
|
@@ -75,6 +78,23 @@ jobs:
|
|
|
75
78
|
fi
|
|
76
79
|
echo "model=$MODEL" >> "$GITHUB_OUTPUT"
|
|
77
80
|
|
|
81
|
+
# Adaptive max-turns (v0.6.23 / §5) — see workflow.yml.tmpl for design notes.
|
|
82
|
+
MAX_TURNS=15
|
|
83
|
+
if [ "$IS_TRIVIAL" = "true" ]; then
|
|
84
|
+
MAX_TURNS=10
|
|
85
|
+
else
|
|
86
|
+
FILE_COUNT=$(echo "$CHANGED" | wc -l | tr -d ' ')
|
|
87
|
+
THREAD_COUNT=$(gh api graphql -f query='{repository(owner:"'"$(echo "$REPO" | cut -d/ -f1)"'",name:"'"$(echo "$REPO" | cut -d/ -f2)"'"){pullRequest(number:'"$PR_NUMBER"'){reviewThreads(first:50){nodes{isResolved comments(first:1){nodes{author{login}}}}}}}}' --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false and (.comments.nodes[0].author.login == "claude" or .comments.nodes[0].author.login == "claude[bot]"))] | length' 2>/dev/null || echo 0)
|
|
88
|
+
THREAD_COUNT=${THREAD_COUNT:-0}
|
|
89
|
+
if [ "$FILE_COUNT" -ge 30 ] || [ "$THREAD_COUNT" -ge 6 ]; then
|
|
90
|
+
MAX_TURNS=40
|
|
91
|
+
elif [ "$FILE_COUNT" -ge 10 ] || [ "$THREAD_COUNT" -ge 3 ]; then
|
|
92
|
+
MAX_TURNS=25
|
|
93
|
+
fi
|
|
94
|
+
echo "::notice title=Clud Bug 🐛::Turn budget: $MAX_TURNS ($FILE_COUNT files, $THREAD_COUNT prior threads)."
|
|
95
|
+
fi
|
|
96
|
+
echo "max_turns=$MAX_TURNS" >> "$GITHUB_OUTPUT"
|
|
97
|
+
|
|
78
98
|
clud-bug-review:
|
|
79
99
|
needs: paths-check
|
|
80
100
|
if: needs.paths-check.outputs.is_workflow_only != 'true'
|
|
@@ -85,6 +105,8 @@ jobs:
|
|
|
85
105
|
id-token: write
|
|
86
106
|
# checks: write — composite emits per-skill check-runs (BB.3).
|
|
87
107
|
checks: write
|
|
108
|
+
# actions: read (v0.6.23 / §5) — github_ci MCP server. See workflow.yml.tmpl for design notes.
|
|
109
|
+
actions: read
|
|
88
110
|
|
|
89
111
|
steps:
|
|
90
112
|
- uses: actions/checkout@v6
|
|
@@ -108,7 +130,7 @@ jobs:
|
|
|
108
130
|
if $IS_FORK || $IS_BOT; then
|
|
109
131
|
REASON=$($IS_BOT && echo "bot author ($PR_AUTHOR)" || echo "fork ($HEAD_REPO)")
|
|
110
132
|
EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
|
|
111
|
-
--jq '[.[] | select(.user.login == "
|
|
133
|
+
--jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
|
|
112
134
|
if [ "${EXISTING:-0}" = "0" ]; then
|
|
113
135
|
BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
|
|
114
136
|
gh pr comment "$PR_NUMBER" --body "$BODY" || true
|
|
@@ -143,19 +165,56 @@ jobs:
|
|
|
143
165
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
144
166
|
track_progress: true
|
|
145
167
|
show_full_output: true
|
|
168
|
+
# v0.6.22 / 0.0.O: --json-schema captures the summary as
|
|
169
|
+
# structured output; post-step renders + posts. See workflow.yml.tmpl for design notes.
|
|
146
170
|
claude_args: |
|
|
147
171
|
--model ${{ needs.paths-check.outputs.model }}
|
|
148
|
-
--max-turns
|
|
149
|
-
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr
|
|
172
|
+
--max-turns ${{ needs.paths-check.outputs.max_turns }}
|
|
173
|
+
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
|
|
174
|
+
--json-schema '{{REVIEW_SCHEMA}}'
|
|
150
175
|
prompt: |
|
|
151
176
|
Review this pull request following the discipline in your
|
|
152
177
|
system prompt — every rule about skill routing, comment
|
|
153
178
|
format, the strict-mode header, the two-surface review
|
|
154
179
|
shape, and the FIX-PUSH FLOW applies.
|
|
180
|
+
id: clud-bug-review
|
|
181
|
+
|
|
182
|
+
# v0.6.22 / 0.0.O: render structured output → post via gh pr comment.
|
|
183
|
+
- name: Render + post structured review
|
|
184
|
+
if: success() && steps.clud-bug-review.outputs.structured_output != ''
|
|
185
|
+
env:
|
|
186
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
187
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
188
|
+
STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
|
|
189
|
+
run: |
|
|
190
|
+
set -euo pipefail
|
|
191
|
+
BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
|
|
192
|
+
gh pr comment "$PR_NUMBER" --body "$BODY"
|
|
193
|
+
|
|
194
|
+
# Fallback when structured_output is empty (max-retries hit).
|
|
195
|
+
- name: Fallback summary (structured_output empty)
|
|
196
|
+
if: success() && steps.clud-bug-review.outputs.structured_output == ''
|
|
197
|
+
env:
|
|
198
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
199
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
200
|
+
run: |
|
|
201
|
+
set -euo pipefail
|
|
202
|
+
gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
|
|
203
|
+
|
|
204
|
+
**This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
|
|
205
|
+
|
|
206
|
+
Found: 0 🔴 / 0 🟡 / 0 🟣
|
|
207
|
+
|
|
208
|
+
⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
|
|
209
|
+
|
|
210
|
+
Skills referenced: [none]"
|
|
155
211
|
|
|
156
212
|
# Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
|
|
157
213
|
- name: Strict mode — fail check on critical findings
|
|
158
214
|
if: success()
|
|
159
|
-
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.
|
|
215
|
+
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.23
|
|
160
216
|
with:
|
|
161
217
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
218
|
+
# v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
|
|
219
|
+
# See workflow.yml.tmpl for design notes.
|
|
220
|
+
bot-login: 'github-actions[bot]'
|
|
@@ -29,6 +29,7 @@ jobs:
|
|
|
29
29
|
outputs:
|
|
30
30
|
is_workflow_only: ${{ steps.classify.outputs.is_workflow_only }}
|
|
31
31
|
model: ${{ steps.classify.outputs.model }}
|
|
32
|
+
max_turns: ${{ steps.classify.outputs.max_turns }}
|
|
32
33
|
steps:
|
|
33
34
|
- name: Classify PR diff
|
|
34
35
|
id: classify
|
|
@@ -43,6 +44,13 @@ jobs:
|
|
|
43
44
|
if [ -z "$CHANGED" ]; then
|
|
44
45
|
echo "is_workflow_only=false" >> "$GITHUB_OUTPUT"
|
|
45
46
|
echo "model=$MODEL" >> "$GITHUB_OUTPUT"
|
|
47
|
+
# v0.6.23 / §5: max_turns must always be emitted because
|
|
48
|
+
# clud-bug-review runs (is_workflow_only=false). Without
|
|
49
|
+
# this, --max-turns ${{ ... }} expands to '--max-turns '
|
|
50
|
+
# (empty), failing the CLI invocation. Empty-CHANGED
|
|
51
|
+
# fires on gh pr diff auth/network failures + the
|
|
52
|
+
# (theoretical) no-changed-files PR.
|
|
53
|
+
echo "max_turns=15" >> "$GITHUB_OUTPUT"
|
|
46
54
|
exit 0
|
|
47
55
|
fi
|
|
48
56
|
|
|
@@ -102,6 +110,38 @@ jobs:
|
|
|
102
110
|
fi
|
|
103
111
|
echo "model=$MODEL" >> "$GITHUB_OUTPUT"
|
|
104
112
|
|
|
113
|
+
# --- (c) adaptive max-turns (v0.6.23 / §5) ---
|
|
114
|
+
# Scope-based turn budget so large PRs (many files OR many prior
|
|
115
|
+
# unresolved threads to walk in FIX-PUSH FLOW) don't exhaust the
|
|
116
|
+
# default 15-turn budget. Concrete failure that motivated this:
|
|
117
|
+
# tokenomics PR #18 (23 docs files + 6 prior claude[bot] threads)
|
|
118
|
+
# exhausted the cap under v0.6.12 AND under v0.6.22's
|
|
119
|
+
# structured-output flow.
|
|
120
|
+
#
|
|
121
|
+
# Buckets:
|
|
122
|
+
# Trivial (Haiku) → 10
|
|
123
|
+
# Standard (<10 files AND <3 prior threads) → 15 (current default)
|
|
124
|
+
# Larger (≥10 files OR ≥3 prior threads) → 25
|
|
125
|
+
# Very large (≥30 files OR ≥6 prior threads) → 40
|
|
126
|
+
MAX_TURNS=15
|
|
127
|
+
if [ "$IS_TRIVIAL" = "true" ]; then
|
|
128
|
+
MAX_TURNS=10
|
|
129
|
+
else
|
|
130
|
+
FILE_COUNT=$(echo "$CHANGED" | wc -l | tr -d ' ')
|
|
131
|
+
# Count unresolved claude-bot threads. Best-effort: rate-limit
|
|
132
|
+
# or auth failures default to 0 (no escalation, fall back to
|
|
133
|
+
# file-count tier).
|
|
134
|
+
THREAD_COUNT=$(gh api graphql -f query='{repository(owner:"'"$(echo "$REPO" | cut -d/ -f1)"'",name:"'"$(echo "$REPO" | cut -d/ -f2)"'"){pullRequest(number:'"$PR_NUMBER"'){reviewThreads(first:50){nodes{isResolved comments(first:1){nodes{author{login}}}}}}}}' --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false and (.comments.nodes[0].author.login == "claude" or .comments.nodes[0].author.login == "claude[bot]"))] | length' 2>/dev/null || echo 0)
|
|
135
|
+
THREAD_COUNT=${THREAD_COUNT:-0}
|
|
136
|
+
if [ "$FILE_COUNT" -ge 30 ] || [ "$THREAD_COUNT" -ge 6 ]; then
|
|
137
|
+
MAX_TURNS=40
|
|
138
|
+
elif [ "$FILE_COUNT" -ge 10 ] || [ "$THREAD_COUNT" -ge 3 ]; then
|
|
139
|
+
MAX_TURNS=25
|
|
140
|
+
fi
|
|
141
|
+
echo "::notice title=Clud Bug 🐛::Turn budget: $MAX_TURNS ($FILE_COUNT files, $THREAD_COUNT prior unresolved claude threads)."
|
|
142
|
+
fi
|
|
143
|
+
echo "max_turns=$MAX_TURNS" >> "$GITHUB_OUTPUT"
|
|
144
|
+
|
|
105
145
|
clud-bug-review:
|
|
106
146
|
needs: paths-check
|
|
107
147
|
if: needs.paths-check.outputs.is_workflow_only != 'true'
|
|
@@ -114,6 +154,11 @@ jobs:
|
|
|
114
154
|
# the GitHub Checks API for any skill in .clud-bug.json's strictSkills
|
|
115
155
|
# list (BB.3, v0.5.10+). No-op when strictSkills is unset.
|
|
116
156
|
checks: write
|
|
157
|
+
# actions: read (v0.6.23 / §5) — claude-code-action's bundled
|
|
158
|
+
# github_ci MCP server needs this to introspect recent CI runs.
|
|
159
|
+
# Per-job GITHUB_TOKEN permissions aren't inherited, so this
|
|
160
|
+
# MUST be on the clud-bug-review job, not paths-check.
|
|
161
|
+
actions: read
|
|
117
162
|
|
|
118
163
|
steps:
|
|
119
164
|
- uses: actions/checkout@v6
|
|
@@ -158,7 +203,7 @@ jobs:
|
|
|
158
203
|
# pull_request: synchronize on a long-running Dependabot PR would
|
|
159
204
|
# otherwise stack one identical comment per rebase.
|
|
160
205
|
EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
|
|
161
|
-
--jq '[.[] | select(.user.login == "
|
|
206
|
+
--jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
|
|
162
207
|
if [ "${EXISTING:-0}" = "0" ]; then
|
|
163
208
|
BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
|
|
164
209
|
gh pr comment "$PR_NUMBER" --body "$BODY" || true
|
|
@@ -222,15 +267,58 @@ jobs:
|
|
|
222
267
|
# the PR as trivial (Haiku) or default (Sonnet). Override
|
|
223
268
|
# per-repo by editing the rendered workflow if you want a
|
|
224
269
|
# specific model for a specific repo.
|
|
270
|
+
# v0.6.22 / 0.0.O: --json-schema captures the summary as
|
|
271
|
+
# structured output (action exposes outputs.structured_output).
|
|
272
|
+
# The model emits findings as schema fields; a post-step
|
|
273
|
+
# renders to markdown and posts. The schema is rendered into
|
|
274
|
+
# this template at `clud-bug init` time via {{REVIEW_SCHEMA}}.
|
|
225
275
|
claude_args: |
|
|
226
276
|
--model ${{ needs.paths-check.outputs.model }}
|
|
227
|
-
--max-turns
|
|
228
|
-
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr
|
|
277
|
+
--max-turns ${{ needs.paths-check.outputs.max_turns }}
|
|
278
|
+
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
|
|
279
|
+
--json-schema '{{REVIEW_SCHEMA}}'
|
|
229
280
|
prompt: |
|
|
230
281
|
Review this pull request following the discipline in your
|
|
231
282
|
system prompt — every rule about skill routing, comment
|
|
232
283
|
format, the strict-mode header, the two-surface review
|
|
233
284
|
shape, and the FIX-PUSH FLOW applies.
|
|
285
|
+
id: clud-bug-review
|
|
286
|
+
|
|
287
|
+
# v0.6.22 / 0.0.O: render the structured output to the summary
|
|
288
|
+
# comment shape, post via gh pr comment. Guarded so we only run
|
|
289
|
+
# when the action returned a non-empty structured payload
|
|
290
|
+
# (max-retries hit → empty → fall through to the next step).
|
|
291
|
+
- name: Render + post structured review
|
|
292
|
+
if: success() && steps.clud-bug-review.outputs.structured_output != ''
|
|
293
|
+
env:
|
|
294
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
295
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
296
|
+
STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
|
|
297
|
+
run: |
|
|
298
|
+
set -euo pipefail
|
|
299
|
+
BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
|
|
300
|
+
gh pr comment "$PR_NUMBER" --body "$BODY"
|
|
301
|
+
|
|
302
|
+
# Fallback comment when the model couldn't produce schema-valid
|
|
303
|
+
# output after max retries (structured_output is empty). Keeps a
|
|
304
|
+
# bare H2 header so the strict-mode gate sees a comment and falls
|
|
305
|
+
# open (advisory) rather than panicking on a missing summary.
|
|
306
|
+
- name: Fallback summary (structured_output empty)
|
|
307
|
+
if: success() && steps.clud-bug-review.outputs.structured_output == ''
|
|
308
|
+
env:
|
|
309
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
310
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
311
|
+
run: |
|
|
312
|
+
set -euo pipefail
|
|
313
|
+
gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
|
|
314
|
+
|
|
315
|
+
**This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
|
|
316
|
+
|
|
317
|
+
Found: 0 🔴 / 0 🟡 / 0 🟣
|
|
318
|
+
|
|
319
|
+
⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
|
|
320
|
+
|
|
321
|
+
Skills referenced: [none]"
|
|
234
322
|
|
|
235
323
|
# Strict-mode gate. Fails the check when the BASE ref's manifest
|
|
236
324
|
# has { "strictMode": true } AND the latest clud-bug review's first
|
|
@@ -247,6 +335,13 @@ jobs:
|
|
|
247
335
|
# Letting the action's own failure fail the check is louder and right.
|
|
248
336
|
- name: Strict mode — fail check on critical findings
|
|
249
337
|
if: success()
|
|
250
|
-
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.
|
|
338
|
+
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.23
|
|
251
339
|
with:
|
|
252
340
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
341
|
+
# v0.6.22 / 0.0.O: the summary is now posted by the workflow
|
|
342
|
+
# post-step under the github-actions[bot] identity (was
|
|
343
|
+
# claude[bot] when claude-code-action did the posting).
|
|
344
|
+
# Without this override the gate's default (claude[bot])
|
|
345
|
+
# never matches the new summary author → strict mode falls
|
|
346
|
+
# open advisory for every install that opted in.
|
|
347
|
+
bot-login: 'github-actions[bot]'
|