Skip to content

Commit 248a564

Browse files
committed
fix(ci): authenticate origin before the Claude action's branch fetch, and count its github-actions findings
1 parent 80f3934 commit 248a564

5 files changed

Lines changed: 205 additions & 18 deletions

File tree

‎.agents/skills/iterate-pr/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ Returns JSON with feedback categorized as:
6969

7070
Review bot feedback (from Sentry, Warden, Copilot, Cursor, Bugbot, CodeQL, etc.) appears in `high`/`medium`/`low` with `review_bot: true` — it is NOT placed in the `bot` bucket.
7171

72+
That includes Claude posting as `github-actions`, which happens when a workflow hands claude-code-action `github_token`. That login is shared with informational bots, so it is decided by content: inline review threads from it, and top-level comments carrying a claude-code-action signature (`Claude finished @…`, a placeholder, a `[View job](…/actions/runs/N)` link), are review feedback. Other `github-actions` comments stay in `bot`.
73+
7274
**A review bot's comment existing is not a completion signal, and neither is its `created_at` or a green check run.** The Claude review bot posts a comment the instant it is triggered and edits that same comment in place as it works — a run has been observed to report `success` while the body still read "Review in progress" with unchecked boxes. `fetch_pr_feedback.cjs` detects this by checking whether the body still **opens** with the in-progress marker (a review that happens to discuss this behaviour mid-body is not mistaken for an unfinished one) and reports the count as `summary.review_in_progress` instead of bucketing the placeholder as ordinary feedback. **Before treating `needs_attention: 0` as "reviewed, nothing found," check `summary.review_in_progress` — a nonzero count means the review hasn't finished, not that it found nothing.** When one or more are present, `action_required` says so and outranks every other bucket, since a caller must not stop just because the buckets it can already see look clean.
7375

7476
**Self-review feedback** (from the PR author) appears in `high`/`medium`/`low` with `self_review: true`. Because you can't "Request changes" on your own PR, a self-review lands as `COMMENTED` review summaries and ordinary review threads rather than changes-requested items — these are surfaced (not dropped). A self-review **thread** (an inline comment) is bucketed by content like any other, defaulting to `medium` absent a `h:/m:/l:` prefix. A self-review **summary** is bucketed structurally, not by content (see below), landing in `review_summary` absent a marker. Treat `self_review` items the same as any other human feedback in step 3.

‎.agents/skills/iterate-pr/scripts/fetch_pr_feedback.cjs‎

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
* with a `review_bot: true` flag — they are NOT placed in the `bot` bucket.
3434
* - Info bots (Codecov, Dependabot, Renovate, etc.) post status reports and are
3535
* placed in the `bot` bucket for silent skipping.
36+
* - `github-actions` is decided by content, since claude-code-action posts as
37+
* it when given `github_token`: its inline threads, and its top-level
38+
* comments carrying a claude-code-action signature, are review feedback.
3639
*
3740
* Self-review feedback:
3841
* - You can't formally "Request changes" on your own PR, so a PR author's own
@@ -150,6 +153,45 @@ const isReviewBot = (username) =>
150153
const isInfoBot = (username) =>
151154
INFO_BOT_PATTERNS.some((pattern) => pattern.test(username ?? ""));
152155

156+
/**
157+
* `github-actions` is a SHARED identity, so its login alone cannot decide a
158+
* bucket. claude-code-action posts as it whenever a workflow passes
159+
* `github_token: ${{ github.token }}` (this repo's review and focus workflows
160+
* do), and the login is spelled `github-actions[bot]` over REST and
161+
* `github-actions` over GraphQL. Filing those as info-bot noise is how
162+
* taskless/marketing#73 reported `needs_attention: 0` over four open findings.
163+
*/
164+
const SHARED_ACTIONS_BOT = /^github-actions(\[bot\])?$/i;
165+
166+
/**
167+
* Openings and links that only claude-code-action's comments carry. The
168+
* in-progress placeholders count too: a placeholder posted as `github-actions`
169+
* must still hold the wait loop open.
170+
*/
171+
const ACTION_SIGNATURES = [
172+
/^[*_]*\s*claude finished @/i,
173+
/\[View job(?: run)?\]\(https:\/\/[^)\s]+\/actions\/runs\/\d+/i,
174+
];
175+
176+
/**
177+
* Whether a comment is a review bot's, deciding `github-actions` by content.
178+
*
179+
* An INLINE thread from `github-actions` is review feedback by default:
180+
* informational bots post top-level status comments, not line-anchored review
181+
* threads. A top-level `github-actions` comment counts only when it carries a
182+
* claude-code-action signature; every other one stays informational.
183+
*/
184+
const isReviewComment = (author, body, { inline = false } = {}) => {
185+
if (isReviewBot(author)) return true;
186+
if (!SHARED_ACTIONS_BOT.test(author ?? "")) return false;
187+
if (inline) return true;
188+
const opening = body.trimStart();
189+
return (
190+
isReviewInProgress(opening) ||
191+
ACTION_SIGNATURES.some((signature) => signature.test(opening))
192+
);
193+
};
194+
153195
/**
154196
* The placeholders a review bot posts before it has anything to say.
155197
*
@@ -328,8 +370,17 @@ const matchesUnnegated = (patterns, body) =>
328370
const categorizeComment = (comment, body) => {
329371
const author = comment?.author?.login || comment?.user?.login || "";
330372

331-
if (isInfoBot(author) && !isReviewBot(author)) return "bot";
373+
if (isInfoBot(author) && !isReviewComment(author, body)) return "bot";
332374

375+
return categorizeContent(comment, body);
376+
};
377+
378+
/**
379+
* The content half of `categorizeComment`, for a caller that has already
380+
* decided the author is not an info bot (`bucketByAuthor`), so a review
381+
* comment posted as `github-actions` is not sent back to `bot` by its login.
382+
*/
383+
const categorizeContent = (_comment, body) => {
333384
const logaf = detectLogaf(body);
334385
if (logaf) return logaf;
335386

@@ -382,28 +433,30 @@ const categorizeReviewSummary = (_comment, body) =>
382433
* an info bot's noise (`bot`), or ordinary human feedback, from ANY of the
383434
* three sources.
384435
*
385-
* `categorize` defaults to the inline-comment classifier; the review-summary
386-
* call site passes `categorizeReviewSummary` instead, so the bot/info-bot
387-
* split and the in-progress check stay one rule in one place while what
388-
* happens to the *content* differs by source.
436+
* `categorize` defaults to the inline-comment content classifier; the
437+
* review-summary call site passes `categorizeReviewSummary` instead, so the
438+
* bot/info-bot split and the in-progress check stay one rule in one place while
439+
* what happens to the *content* differs by source. `inline` marks a review
440+
* thread, which decides a `github-actions` author (see `isReviewComment`).
389441
*/
390442
const bucketByAuthor = (
391443
feedback,
392444
item,
393445
comment,
394446
body,
395447
author,
396-
categorize = categorizeComment
448+
{ categorize = categorizeContent, inline = false } = {}
397449
) => {
450+
const reviewer = isReviewComment(author, body, { inline });
398451
// The author gate is load-bearing, not belt and braces. A human writing
399452
// "Review in progress on my end, back by EOD" would otherwise be filed as an
400453
// unfinished review, vanish from `needs_attention`, and hang the wait loop
401454
// forever: a person's comment never gets edited into a finished form the way
402455
// the bot's placeholder does, so the count never drops. Only a review bot has
403456
// the lifecycle this bucket describes.
404-
if (isReviewBot(author) && isReviewInProgress(body)) {
457+
if (reviewer && isReviewInProgress(body)) {
405458
feedback.review_in_progress.push(item);
406-
} else if (isReviewBot(author)) {
459+
} else if (reviewer) {
407460
item.review_bot = true;
408461
feedback[categorize(comment, body)].push(item);
409462
} else if (isInfoBot(author)) {
@@ -628,14 +681,9 @@ const buildFeedback = (client, { owner, repo, prInfo }) => {
628681
) {
629682
feedback.high.push(item);
630683
} else {
631-
bucketByAuthor(
632-
feedback,
633-
item,
634-
review,
635-
body,
636-
author,
637-
categorizeReviewSummary
638-
);
684+
bucketByAuthor(feedback, item, review, body, author, {
685+
categorize: categorizeReviewSummary,
686+
});
639687
}
640688
}
641689

@@ -671,7 +719,7 @@ const buildFeedback = (client, { owner, repo, prInfo }) => {
671719
if (isResolved) {
672720
feedback.resolved.push(item);
673721
} else {
674-
bucketByAuthor(feedback, item, first, body, author);
722+
bucketByAuthor(feedback, item, first, body, author, { inline: true });
675723
}
676724
}
677725

@@ -822,6 +870,7 @@ module.exports = {
822870
extractFeedbackItem,
823871
isInfoBot,
824872
isReviewBot,
873+
isReviewComment,
825874
isReviewInProgress,
826875
main,
827876
};

‎.agents/skills/iterate-pr/scripts/fetch_pr_feedback.test.cjs‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
extractFeedbackItem,
2222
isInfoBot,
2323
isReviewBot,
24+
isReviewComment,
2425
isReviewInProgress,
2526
main,
2627
} = require("./fetch_pr_feedback.cjs");
@@ -1072,3 +1073,106 @@ test("an explicit marker in a summary still reaches its priority bucket", () =>
10721073
assert.equal(output.summary.high, 1);
10731074
assert.equal(output.summary.review_summaries, 0);
10741075
});
1076+
1077+
// claude-code-action posts as `github-actions` when a workflow hands it
1078+
// `github_token`: `github-actions[bot]` over REST, `github-actions` over
1079+
// GraphQL. Measured on taskless/marketing#73, where four unresolved findings
1080+
// were filed as info-bot noise and the script reported needs_attention: 0.
1081+
test("an inline thread from github-actions is review feedback, in both spellings", () => {
1082+
for (const login of ["github-actions", "github-actions[bot]"]) {
1083+
const output = build(
1084+
fakeClient({
1085+
threads: [
1086+
thread({
1087+
comments: {
1088+
nodes: [
1089+
{ body: "This will break on empty input", author: { login } },
1090+
],
1091+
},
1092+
}),
1093+
],
1094+
}),
1095+
{}
1096+
);
1097+
assert.equal(output.summary.bot_comments, 0, login);
1098+
assert.equal(output.summary.high, 1, login);
1099+
assert.equal(output.summary.review_bot_feedback, 1, login);
1100+
assert.equal(output.feedback.high[0].review_bot, true, login);
1101+
}
1102+
});
1103+
1104+
test("a finished claude-code-action summary from github-actions is review feedback", () => {
1105+
for (const login of ["github-actions", "github-actions[bot]"]) {
1106+
const output = build(
1107+
fakeClient({
1108+
comments: [
1109+
{
1110+
id: 1,
1111+
body: `${LIVE_FINISHED}\n\nThis will break on empty input.`,
1112+
user: { login },
1113+
},
1114+
],
1115+
}),
1116+
{}
1117+
);
1118+
assert.equal(output.summary.bot_comments, 0, login);
1119+
assert.equal(output.summary.high, 1, login);
1120+
}
1121+
});
1122+
1123+
// The wait loop must not exit while a review posted as github-actions is still
1124+
// running: that is the silent-success shape `review_in_progress` exists for.
1125+
test("a placeholder posted as github-actions is an unfinished review", () => {
1126+
for (const body of [LIVE_CREATED, LIVE_IN_PROGRESS]) {
1127+
const output = build(
1128+
fakeClient({
1129+
comments: [{ id: 1, body, user: { login: "github-actions[bot]" } }],
1130+
}),
1131+
{}
1132+
);
1133+
assert.equal(output.summary.review_in_progress, 1);
1134+
assert.equal(output.summary.bot_comments, 0);
1135+
assert.match(output.action_required, /still in progress/);
1136+
}
1137+
});
1138+
1139+
// The login is shared, so every OTHER top-level github-actions comment stays
1140+
// informational: a deploy preview or a coverage table is not a finding.
1141+
test("an ordinary top-level github-actions comment stays in bot", () => {
1142+
const output = build(
1143+
fakeClient({
1144+
comments: [
1145+
{
1146+
id: 1,
1147+
body: "Preview deployed. This will break nothing, see the logs.",
1148+
user: { login: "github-actions[bot]" },
1149+
},
1150+
],
1151+
}),
1152+
{}
1153+
);
1154+
assert.equal(output.summary.bot_comments, 1);
1155+
assert.equal(output.summary.needs_attention, 0);
1156+
});
1157+
1158+
test("isReviewComment decides github-actions by signature, and only github-actions", () => {
1159+
assert.ok(isReviewComment("github-actions", LIVE_FINISHED));
1160+
assert.ok(
1161+
isReviewComment(
1162+
"github-actions[bot]",
1163+
"Done.\n\n[View job](https://github.com/o/r/actions/runs/123)"
1164+
)
1165+
);
1166+
assert.ok(!isReviewComment("github-actions[bot]", "Coverage dropped 2%"));
1167+
assert.ok(isReviewComment("github-actions", "anything", { inline: true }));
1168+
// Another info bot carrying the same signature is still not a reviewer.
1169+
assert.ok(!isReviewComment("codecov[bot]", LIVE_FINISHED));
1170+
assert.ok(!isReviewComment("codecov[bot]", "anything", { inline: true }));
1171+
assert.equal(
1172+
categorizeComment(
1173+
{ user: { login: "github-actions[bot]" } },
1174+
`${LIVE_FINISHED}\n\nThis will break at runtime`
1175+
),
1176+
"high"
1177+
);
1178+
});

‎.github/workflows/claude-code-focus-on-demand.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ jobs:
129129
130130
echo "threads: $(jq '.data.repository.pullRequest.reviewThreads.nodes | length' "${GITHUB_WORKSPACE}/.prior-review.json")"
131131
132+
# The action's tag mode fetches the PR branch before it authenticates
133+
# origin, which fails on a private repo. See the review workflow's step
134+
# of the same name (issue #512) for why, and why not
135+
# `persist-credentials: true`.
136+
- name: Authenticate origin for the action's PR-branch fetch
137+
env:
138+
GH_TOKEN: ${{ github.token }}
139+
run: git remote set-url origin "https://x-access-token:${GH_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
140+
132141
- name: Run Claude Code Focus
133142
id: focus
134143
uses: anthropics/claude-code-action@0a8d3c9443bbff909ab973b6a17a340b913f229f # v1.0.221

‎.github/workflows/claude-code-review-on-demand.yml‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ name: Claude Code Review (on demand)
6464
# - that is NOT sufficient on its own. The action rewrites the origin remote
6565
# URL to embed ITS token (`https://x-access-token:<token>@github.com/...`,
6666
# logged as "Updated remote URL with authentication token") after checkout,
67-
# whatever the checkout did. So `--disallowedTools` denies `Read(.git/**)`;
67+
# whatever the checkout did, and a step just before the action writes
68+
# the same URL early so private repos work (issue #512). So
69+
# `--disallowedTools` denies `Read(.git/**)`;
6870
# - the token also sits in the Claude process's environment, which is how
6971
# `gh pr comment` authenticates, so `Read(//proc/**)` is denied as well.
7072
# Do not remove any of these without removing `Read`: they are safe together
@@ -231,6 +233,27 @@ jobs:
231233
# that had nothing to classify.
232234
echo "threads: $(jq '.data.repository.pullRequest.reviewThreads.nodes | length' "${GITHUB_WORKSPACE}/.prior-review.json")"
233235
236+
# The action fetches the PR branch BEFORE it authenticates origin.
237+
# `track_progress: true` selects tag mode, whose `setupBranch` runs
238+
# `git fetch origin <headRefName>` (or `pull/N/head` for a fork), and only
239+
# then does `configureGitAuth` write the token into the origin URL. With
240+
# `persist-credentials: false` that fetch is anonymous: fine on this
241+
# public repo, `could not read Username` on a private one, which is how a
242+
# vendored copy in taskless/marketing died (issue #512, checked against
243+
# v1.0.221). This writes the value the action writes moments later, from
244+
# the same token as `github_token`, so it exposes nothing new and
245+
# `Read(.git/**)` already covers it.
246+
#
247+
# Do NOT fix this with `persist-credentials: true`. Checkout v7 keeps its
248+
# credential in a `RUNNER_TEMP` file loaded through `includeIf.gitdir:*`;
249+
# the action's `replaceCheckoutCredentials` only strips `include.path`
250+
# entries, so that credential would survive, outside `.git/**`, where the
251+
# `Read` deny rule does not reach.
252+
- name: Authenticate origin for the action's PR-branch fetch
253+
env:
254+
GH_TOKEN: ${{ github.token }}
255+
run: git remote set-url origin "https://x-access-token:${GH_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git"
256+
234257
# The 👀 on the triggering comment is not from this workflow or the
235258
# action. The Claude GitHub App adds it, as `claude[bot]`, to ANY comment
236259
# mentioning `@claude`, before this workflow has started (measured on PR

0 commit comments

Comments
 (0)