Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 5.5 cb3c7fae87 fix: only treat CI-fix requests from ignored bots as stranded
Another write-access collaborator's request does reach Claude, so the
pending label must still block it (greptile P2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:27:39 +00:00
MervinPraisonandClaude Opus 5.5 0402645f09 fix(ci): require write access to invoke @claude; stop App-token CI-fix requests stranding PRs
Security: claude-response accepted @claude from any non-bot GitHub user and
the vendored claude-code-action performs no actor permission check.
- Job if: comment/review triggers require author_association OWNER, MEMBER or
  COLLABORATOR (github-actions[bot] and the triage-bot label path unchanged).
- New first step, before any App/OAuth token is created, re-checks the
  requester's permission via the collaborators API and fails unless write or
  admin.

CI-fix loop: pipeline-status label sync (run by the merge gate under the App
token) posted 'CI failed on HEAD' as praisonai-triage-agent[bot]. claude.yml
ignores that account, so Claude never ran, and the claude-ci-fix-pending label
it added made every later real request skip ('ci fix pending'). 0 of 6 bot
requests since 2026-10-07 ever ran (#5800 stuck since 2026-10-13 21:16).
- Label sync no longer posts CI-fix requests; ci-failure-claude.yml (maintainer
  token) remains the only poster.
- The pending label no longer blocks when the newest request came from an
  account claude.yml ignores, so stranded PRs recover on the next scan.

Selftests: ci-failure-claude 18, pipeline-status 15, merge-gate 124.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 08:18:48 +01:00
4 changed files with 108 additions and 4 deletions

No files matched your search

@@ -82,6 +82,46 @@ const skipPending = ciFix.shouldSkipCiFix({
});
assert('skips when ci fix pending label', skipPending.skip && skipPending.reason.includes('pending'));
// A request the App bot posted never reached Claude (claude.yml ignores it);
// its pending label must not block the real request.
const botRequest = {
user: { login: 'praisonai-triage-agent[bot]' },
body: `@claude CI failed on HEAD \`${headSha.slice(0, 8)}\`. Please fix.`,
created_at: '2026-10-13T21:16:49Z',
};
const maintainerRequest = {
user: { login: 'MervinPraison' },
body: '@claude CI failed on HEAD `0d561a42`. Please fix.',
created_at: '2026-10-13T09:16:04Z',
};
const strandedLabel = ciFix.shouldSkipCiFix({
comments: [maintainerRequest, botRequest],
headSha,
labels: [ciFix.CI_FIX_LABEL],
hasFinal: true,
claudeInProgress: false,
failedChecks: [{ name: 'test-core' }],
});
assert('label left by an ignored bot request does not block', !strandedLabel.skip);
const realPending = ciFix.shouldSkipCiFix({
comments: [botRequest, { ...maintainerRequest, created_at: '2026-10-13T22:00:00Z' }],
headSha,
labels: [ciFix.CI_FIX_LABEL],
hasFinal: true,
claudeInProgress: false,
failedChecks: [{ name: 'test-core' }],
});
assert('label still blocks after a real maintainer request', realPending.skip && realPending.reason.includes('pending'));
const otherWriterPending = ciFix.shouldSkipCiFix({
comments: [botRequest, { ...maintainerRequest, user: { login: 'other-writer' }, created_at: '2026-10-13T22:00:00Z' }],
headSha,
labels: [ciFix.CI_FIX_LABEL],
hasFinal: true,
claudeInProgress: false,
failedChecks: [{ name: 'test-core' }],
});
assert('label still blocks after another writer request', otherWriterPending.skip && otherWriterPending.reason.includes('pending'));
const failedRuns = mergeGate.listFailedChecksOnSha([
{ status: 'completed', conclusion: 'success', name: 'smoke' },
{ status: 'completed', conclusion: 'failure', name: 'test-core (cli)' },
+23 -1
View File
@@ -45,9 +45,30 @@ function hasRecentCiFixComment(comments, headSha) {
});
}
/**
* The newest CI-fix request on the PR, from any author. A request posted by an
* bot claude.yml ignores (e.g. the App bot) never reached Claude, so the
* pending label it left behind must not block a real request.
*/
function latestCiFixComment(comments) {
let latest = null;
for (const c of comments || []) {
if (!isCiFixComment(c)) continue;
if (!latest || new Date(c.created_at) > new Date(latest.created_at)) latest = c;
}
return latest;
}
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
if (labels.includes(CI_FIX_LABEL)) {
const latest = latestCiFixComment(comments);
const login = latest?.user?.login || '';
// claude.yml drops comments from bots other than github-actions[bot];
// human writers do reach Claude, so their request still blocks.
const strandedByIgnoredBot = login.endsWith('[bot]') && !AUTO_ACTORS.includes(login);
if (!strandedByIgnoredBot) return { skip: true, reason: 'ci fix pending' };
}
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
if (hasCiFixCommentForSha(comments, headSha)) {
@@ -279,6 +300,7 @@ async function processWorkflowRunFailure(github, owner, repo, workflowRun, core)
module.exports = {
CI_FIX_LABEL,
latestCiFixComment,
COOLDOWN_MS,
ciFixShaMarker,
isCiFixComment,
+5 -3
View File
@@ -199,12 +199,14 @@ async function syncPipelineLabels(github, owner, repo, prNumber, core) {
core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`);
// CI-fix requests are posted only by ci-failure-claude.yml, which uses the
// maintainer token. Label sync runs under the App token, whose comments
// claude.yml ignores: a request posted from here never reached Claude, yet
// its pending label blocked every real request for that PR.
const ciNotGreen = (evalResult.reasons || []).some((r) =>
r.toLowerCase().includes('ci not green')
);
if (ciNotGreen || blockers.includes('pipeline/blocked:ci')) {
await ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core);
} else {
if (!ciNotGreen && !blockers.includes('pipeline/blocked:ci')) {
await ciFix.maybeClearCiFixLabel(
github, owner, repo, prNumber, ctx.labels, ctx.headSha, core
);
+40
View File
@@ -154,6 +154,14 @@ jobs:
github.actor == 'github-actions[bot]' ||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'claude' && github.actor == 'praisonai-triage-agent[bot]')
) &&
(
github.event_name == 'issues' ||
github.actor == 'github-actions[bot]' ||
(github.event_name == 'pull_request_review' &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)) ||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
) &&
github.actor != 'dependabot[bot]' &&
github.actor != 'cursor[bot]' &&
github.actor != 'renovate[bot]'
@@ -168,6 +176,38 @@ jobs:
actions: read
id-token: write
steps:
# Must stay the first step: nothing below may run (no App token, no OAuth
# token, no agent) for a requester without write access. The job-level
# `if:` already filters on author_association; this re-checks the real
# permission level, which also excludes read-only collaborators.
- name: Authorize requester (write access required)
uses: actions/github-script@v7
with:
script: |
const trustedAutomation = new Set(['github-actions[bot]', 'praisonai-triage-agent[bot]']);
const p = context.payload;
const login = p.comment?.user?.login || p.review?.user?.login || p.sender?.login || context.actor;
if (trustedAutomation.has(login) && login === context.actor) {
core.info(`Trusted repository automation: ${login}`);
return;
}
let permission = 'none';
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: login,
});
permission = data.permission;
} catch (err) {
core.info(`Permission lookup for ${login} failed: ${err.status || err.message}`);
}
if (!['admin', 'write'].includes(permission)) {
core.setFailed(`Claude can only be invoked by users with write access; ${login} has '${permission}'.`);
return;
}
core.info(`${login} has ${permission} access`);
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1