From 65bf2f49a0005e68867d7ee12d57fc164ab9fd56 Mon Sep 17 00:00:00 2001 From: MervinPraison Date: Wed, 8 Jul 2026 11:39:00 +0100 Subject: [PATCH 1/3] Add PraisonAIUI-style automation pipeline for Plugins repo. Merge gate, pipeline status, CI failure Claude, nightly release gate, PyPI release workflow, and repo-specific gate-config for plugin lifecycle scope. --- .github/scripts/bot-pr-review-chain.js | 153 +++ .github/scripts/ci-failure-claude-selftest.js | 35 + .github/scripts/ci-failure-claude.js | 294 ++++++ .github/scripts/gate-config.js | 32 + .github/scripts/merge-gate-selftest.js | 237 +++++ .github/scripts/merge-gate.js | 899 ++++++++++++++++++ .github/scripts/pipeline-status-selftest.js | 82 ++ .github/scripts/pipeline-status.js | 264 +++++ .github/scripts/pr-review-chain.js | 300 ++++++ .github/scripts/release-gate-selftest.js | 32 + .github/scripts/release-gate.js | 205 ++++ .github/workflows/auto-pr-comment.yml | 456 ++++++++- .github/workflows/ci-failure-claude.yml | 102 ++ .github/workflows/ci.yml | 34 + .github/workflows/claude-merge-gate.yml | 531 +++++++++++ .github/workflows/nightly-release-gate.yml | 129 +++ .github/workflows/pipeline-status-sync.yml | 42 + .github/workflows/pypi-release.yml | 135 +++ .../workflows/sync-secrets-from-praisonai.yml | 21 + 19 files changed, 3937 insertions(+), 46 deletions(-) create mode 100644 .github/scripts/bot-pr-review-chain.js create mode 100644 .github/scripts/ci-failure-claude-selftest.js create mode 100644 .github/scripts/ci-failure-claude.js create mode 100644 .github/scripts/gate-config.js create mode 100644 .github/scripts/merge-gate-selftest.js create mode 100644 .github/scripts/merge-gate.js create mode 100644 .github/scripts/pipeline-status-selftest.js create mode 100644 .github/scripts/pipeline-status.js create mode 100644 .github/scripts/pr-review-chain.js create mode 100644 .github/scripts/release-gate-selftest.js create mode 100644 .github/scripts/release-gate.js create mode 100644 .github/workflows/ci-failure-claude.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/claude-merge-gate.yml create mode 100644 .github/workflows/nightly-release-gate.yml create mode 100644 .github/workflows/pipeline-status-sync.yml create mode 100644 .github/workflows/pypi-release.yml create mode 100644 .github/workflows/sync-secrets-from-praisonai.yml diff --git a/.github/scripts/bot-pr-review-chain.js b/.github/scripts/bot-pr-review-chain.js new file mode 100644 index 0000000..6ce3157 --- /dev/null +++ b/.github/scripts/bot-pr-review-chain.js @@ -0,0 +1,153 @@ +/** + * Idempotent CodeRabbit/Qodo kick for bot-opened PRs. + * @see .github/workflows/auto-pr-comment.yml, claude.yml, bot-pr-recovery.yml + */ + +const KICK_AUTHORS = new Set(['MervinPraison', 'github-actions[bot]']); +const BOT_PR_AUTHORS = new Set(['praisonai-triage-agent[bot]', 'github-actions[bot]']); + +async function listAllComments(github, owner, repo, issueNumber) { + if (typeof github.paginate === 'function') { + return github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + } + const { data } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return data; +} + +function kickAuthored(comment, marker) { + return ( + KICK_AUTHORS.has(comment.user?.login) && + (comment.body || '').includes(marker) + ); +} + +function coderabbitKickPosted(comments) { + return comments.some((c) => kickAuthored(c, '@coderabbitai review')); +} + +function qodoKickPosted(comments) { + return comments.some((c) => kickAuthored(c, '/review')); +} + +function chainKickPosted(comments) { + return coderabbitKickPosted(comments) && qodoKickPosted(comments); +} + +function isBotOpenedPr(pr) { + if (BOT_PR_AUTHORS.has(pr.user?.login)) return true; + return pr.user?.type === 'Bot'; +} + +async function kickReviewChain(github, owner, repo, prNumber, core, preFetchedComments = null) { + const comments = preFetchedComments || await listAllComments(github, owner, repo, prNumber); + const needCoderabbit = !coderabbitKickPosted(comments); + const needQodo = !qodoKickPosted(comments); + + if (!needCoderabbit && !needQodo) { + core?.info?.(`Review chain already kicked on PR #${prNumber}`); + return { kicked: false, reason: 'already_kicked' }; + } + + if (needCoderabbit) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: '@coderabbitai review', + }); + } + if (needQodo) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: '/review', + }); + } + core?.info?.(`Kicked review chain for PR #${prNumber} (coderabbit=${needCoderabbit}, qodo=${needQodo})`); + return { kicked: true, coderabbit: needCoderabbit, qodo: needQodo }; +} + +async function findOpenPrForIssue(github, owner, repo, issueNumber) { + const prefix = `claude/issue-${issueNumber}-`; + const { data: prs } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + sort: 'created', + direction: 'desc', + per_page: 30, + }); + return ( + prs.find( + (p) => (p.head?.ref || '').startsWith(prefix) && isBotOpenedPr(p) + ) || null + ); +} + +async function kickReviewChainForIssue(github, owner, repo, issueNumber, core) { + const pr = await findOpenPrForIssue(github, owner, repo, issueNumber); + if (!pr) { + core?.info?.(`No open PR for issue #${issueNumber}, skipping review kick`); + return { kicked: false, reason: 'no_pr' }; + } + const result = await kickReviewChain(github, owner, repo, pr.number, core); + return { ...result, prNumber: pr.number }; +} + +async function recoverStalledBotPrs(github, owner, repo, options, core) { + const { prNumber = null, minAgeMs = 10 * 60 * 1000, maxRecover = 10 } = options || {}; + let prs; + if (prNumber) { + const { data } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + prs = [data]; + } else if (typeof github.paginate === 'function') { + prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + } else { + const { data } = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 }); + prs = data; + } + + const cutoff = Date.now() - minAgeMs; + let recovered = 0; + for (const pr of prs) { + if (recovered >= maxRecover) break; + if (!isBotOpenedPr(pr)) continue; + if (!prNumber && new Date(pr.created_at).getTime() > cutoff) continue; + const comments = await listAllComments(github, owner, repo, pr.number); + if (chainKickPosted(comments)) continue; + await kickReviewChain(github, owner, repo, pr.number, core, comments); + recovered += 1; + } + core?.info?.(`Recovery complete (${recovered} PR(s) kicked)`); + return recovered; +} + +module.exports = { + KICK_AUTHORS, + BOT_PR_AUTHORS, + listAllComments, + chainKickPosted, + coderabbitKickPosted, + qodoKickPosted, + isBotOpenedPr, + kickReviewChain, + findOpenPrForIssue, + kickReviewChainForIssue, + recoverStalledBotPrs, +}; diff --git a/.github/scripts/ci-failure-claude-selftest.js b/.github/scripts/ci-failure-claude-selftest.js new file mode 100644 index 0000000..36f7f21 --- /dev/null +++ b/.github/scripts/ci-failure-claude-selftest.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * Run: node .github/scripts/ci-failure-claude-selftest.js + */ +const ciFix = require('./ci-failure-claude.js'); +const mergeGate = require('./merge-gate.js'); +const config = require('./gate-config.js'); + +let failed = 0; +function assert(name, cond) { + if (!cond) { + console.error('FAIL:', name); + failed += 1; + } else { + console.log('ok:', name); + } +} + +const LOG = ` +python UNKNOWN STEP FAILED (0.0100s) tests/unit/test_example.py::test_foo - AssertionError: bar +python UNKNOWN STEP ##[error]Process completed with exit code 1. +`; + +const parsed = ciFix.parsePytestFailures(LOG); +assert('parses pytest failure', parsed.length === 1); +assert('uses CI workflow list', config.ciFailureWorkflowRuns.includes('CI')); + +const comment = ciFix.buildCiFixComment({ + headSha: 'abc1234567890abcdef1234567890abcdef12', + failedChecks: [{ name: 'python', workflow: 'CI', html_url: 'https://example.com/job/1' }], + failureSummaries: [{ jobName: 'python', failures: parsed }], +}); +assert('comment mentions product guardrails', comment.includes('Product guardrails')); + +process.exit(failed ? 1 : 0); diff --git a/.github/scripts/ci-failure-claude.js b/.github/scripts/ci-failure-claude.js new file mode 100644 index 0000000..16f18a0 --- /dev/null +++ b/.github/scripts/ci-failure-claude.js @@ -0,0 +1,294 @@ +/** + * Post @claude comments with CI failure details for internal PRs. + * @see .github/workflows/ci-failure-claude.yml + */ + +const mergeGate = require('./merge-gate.js'); +const config = require('./gate-config.js'); + +const CI_FIX_LABEL = 'claude-ci-fix-pending'; +const COOLDOWN_MS = 12 * 60 * 60 * 1000; +const AUTO_ACTORS = mergeGate.AUTO_ACTORS; +const MAX_FAILURES = 15; +const MAX_JOBS_TO_FETCH = 5; + +function shortSha(headSha) { + return (headSha || '').slice(0, 8); +} + +function ciFixShaMarker(headSha) { + return `ci failed on head \`${shortSha(headSha)}`.toLowerCase(); +} + +function isCiFixComment(comment) { + const body = (comment.body || '').toLowerCase(); + return body.includes('@claude') && body.includes('ci failed on head'); +} + +function hasCiFixCommentForSha(comments, headSha) { + const marker = shortSha(headSha).toLowerCase(); + return comments.some((c) => { + if (!AUTO_ACTORS.includes(c.user.login)) return false; + if (!isCiFixComment(c)) return false; + return (c.body || '').toLowerCase().includes(marker); + }); +} + +function hasRecentCiFixComment(comments, headSha) { + const cutoff = Date.now() - COOLDOWN_MS; + const marker = shortSha(headSha).toLowerCase(); + return comments.some((c) => { + if (!AUTO_ACTORS.includes(c.user.login)) return false; + if (!isCiFixComment(c)) return false; + const body = (c.body || '').toLowerCase(); + if (body.includes(marker)) return true; + return new Date(c.created_at).getTime() > cutoff; + }); +} + +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 (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' }; + if (claudeInProgress) return { skip: true, reason: 'claude in progress' }; + if (hasCiFixCommentForSha(comments, headSha)) { + return { skip: true, reason: 'already commented for this sha' }; + } + if (hasRecentCiFixComment(comments, headSha)) { + return { skip: true, reason: 'recent ci fix comment cooldown' }; + } + return { skip: false }; +} + +function parsePytestFailures(logText) { + if (!logText) return []; + const lines = logText.split('\n'); + const failures = []; + const seen = new Set(); + + for (let i = lines.length - 1; i >= 0 && failures.length < MAX_FAILURES; i -= 1) { + const line = lines[i].trim(); + if (!line) continue; + + const pytestMatch = line.match( + /FAILED\s*(?:\([^)]+\))?\s+(tests\/[^\s]+(?:::[^\s-]+)*)\s*-\s*(.+)/i + ); + if (pytestMatch) { + const testId = pytestMatch[1]; + if (!seen.has(testId)) { + seen.add(testId); + failures.push({ testId, error: pytestMatch[2].trim() }); + } + continue; + } + + const errorMatch = line.match(/##\[error\](.+)/); + if (errorMatch && /tests\//.test(errorMatch[1])) { + const msg = errorMatch[1].trim(); + if (!seen.has(msg)) { + seen.add(msg); + failures.push({ testId: msg, error: msg }); + } + } + } + + return failures; +} + +async function fetchJobFailureSummary(github, owner, repo, jobId, jobName) { + try { + const response = await github.rest.actions.downloadJobLogsForWorkflowRun({ + owner, + repo, + job_id: jobId, + }); + let logText = typeof response.data === 'string' ? response.data : String(response.data || ''); + if (logText.length > 100000) { + logText = logText.slice(-100000); + } + return { jobName, jobId, failures: parsePytestFailures(logText) }; + } catch (err) { + return { jobName, jobId, failures: [], error: err.message }; + } +} + +function mapFailedChecks(runs) { + return runs.map((run) => ({ + name: run.name, + id: run.id, + html_url: run.html_url || run.details_url, + workflow: run.app?.slug || run.check_suite?.app?.slug || run.name, + })); +} + +function buildCiFixComment({ headSha, failedChecks, failureSummaries }) { + const parts = [ + `@claude CI failed on HEAD \`${shortSha(headSha)}\`. Please fix the failures below and push to this branch.`, + '', + '## Failed checks', + ]; + + for (const check of failedChecks) { + parts.push(`- **${check.workflow || check.name}** / \`${check.name}\` — ${check.html_url}`); + } + + parts.push('', '## Failures (extracted)'); + let idx = 0; + for (const summary of failureSummaries) { + for (const failure of summary.failures) { + idx += 1; + if (idx > MAX_FAILURES) break; + parts.push(`${idx}. \`${failure.testId}\` — \`${failure.error}\``); + parts.push(` - Job: \`${summary.jobName}\``); + } + if (idx >= MAX_FAILURES) break; + } + + if (idx === 0) { + parts.push('_(Could not extract pytest details — see job logs above.)_'); + } + + const exampleTest = failureSummaries.find((s) => s.failures.length)?.failures[0]?.testId; + parts.push( + '', + '## Critical review first', + 'Before changing code or tests, decide **which side is wrong**:', + '1. **Legitimate feature change** — the PR intent is correct but implementation or tests need updating. Preserve product guarantees; update tests only when behaviour intentionally changed and document why.', + '2. **Regression / bug in this PR** — the failure exposes a real breakage introduced here. Fix the implementation; **do not weaken, skip, or delete tests** just to go green.', + '3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.', + '', + '**Product guardrails (AGENTS.md):**', + `- ${config.finalClaudeScope}`, + '- Tests must continue to guard backward compatibility — passing CI by lowering test standards is not acceptable.', + '- If the feature does not genuinely add product value, recommend reverting or narrowing scope instead of patching around failures.', + '', + '## What to do', + '1. State your verdict: **legitimate fix**, **regression fix**, or **needs human review** — and why (1–3 sentences).', + '2. Fix root cause with **minimal changes**; never bloat the Agent class with extra params.', + exampleTest + ? `3. Run failing tests locally, e.g. \`pytest ${exampleTest} -q\`, plus any related tests touched by the PR.` + : '3. Run failing tests locally with targeted pytest, plus any related tests touched by the PR.', + '4. Push to this branch and comment: files changed, review verdict, and why tests still protect product behaviour.', + ); + + return parts.join('\n'); +} + +async function maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core) { + if (!labels.includes(CI_FIX_LABEL)) return false; + const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core); + if (!ciGreen) return false; + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: CI_FIX_LABEL, + }); + core?.info?.(`Removed ${CI_FIX_LABEL} from PR #${prNumber}`); + } catch (err) { + if (err.status !== 404) throw err; + } + return true; +} + +async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts = {}) { + const baseRepo = `${owner}/${repo}`; + const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber); + const pr = ctx.pr; + + if (pr.state !== 'open') return { skipped: true, reason: 'not open' }; + if (pr.draft) return { skipped: true, reason: 'draft' }; + + const headRepo = pr.head.repo?.full_name; + if (headRepo && headRepo !== baseRepo && !pr.maintainer_can_modify) { + return { skipped: true, reason: 'fork without maintainer edits' }; + } + + const headSha = pr.head.sha; + const labels = ctx.labels; + + if (await maybeClearCiFixLabel(github, owner, repo, prNumber, labels, headSha, core)) { + return { skipped: true, reason: 'ci green, label cleared' }; + } + + const ciGreen = await mergeGate.allChecksGreenOnSha(github, owner, repo, headSha, core); + if (ciGreen) return { skipped: true, reason: 'ci green' }; + + const runs = await mergeGate.listChecksOnSha(github, owner, repo, headSha); + const failedRuns = mergeGate.listFailedChecksOnSha(runs); + if (failedRuns.length === 0) { + return { skipped: true, reason: 'no failed checks yet (may be pending)' }; + } + + const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments); + const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant( + github, owner, repo, prNumber + ); + const skip = shouldSkipCiFix({ + comments: ctx.comments, + headSha, + labels, + hasFinal, + claudeInProgress, + failedChecks: failedRuns, + }); + if (skip.skip) return { skipped: true, reason: skip.reason }; + + const failedChecks = mapFailedChecks(failedRuns); + const failureSummaries = []; + for (const check of failedChecks.slice(0, MAX_JOBS_TO_FETCH)) { + failureSummaries.push( + await fetchJobFailureSummary(github, owner, repo, check.id, check.name) + ); + } + + const body = buildCiFixComment({ headSha, failedChecks, failureSummaries }); + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: [CI_FIX_LABEL], + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + + core?.info?.(`Posted CI fix @claude on PR #${prNumber} (${shortSha(headSha)})`); + return { triggered: true, headSha: shortSha(headSha) }; +} + +async function processWorkflowRunFailure(github, owner, repo, workflowRun, core) { + if (workflowRun.conclusion !== 'failure') { + return { skipped: true, reason: `conclusion=${workflowRun.conclusion}` }; + } + const allowed = config.ciFailureWorkflowRuns; + if (!allowed.includes(workflowRun.name)) { + return { skipped: true, reason: `workflow ${workflowRun.name}` }; + } + const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun( + github, owner, repo, workflowRun + ); + if (!prNumber) return { skipped: true, reason: 'no linked PR' }; + return maybeTriggerCiFixClaude(github, owner, repo, prNumber, core); +} + +module.exports = { + CI_FIX_LABEL, + COOLDOWN_MS, + ciFixShaMarker, + isCiFixComment, + hasCiFixCommentForSha, + hasRecentCiFixComment, + shouldSkipCiFix, + parsePytestFailures, + fetchJobFailureSummary, + buildCiFixComment, + maybeClearCiFixLabel, + maybeTriggerCiFixClaude, + processWorkflowRunFailure, +}; diff --git a/.github/scripts/gate-config.js b/.github/scripts/gate-config.js new file mode 100644 index 0000000..18af81b --- /dev/null +++ b/.github/scripts/gate-config.js @@ -0,0 +1,32 @@ +/** + * PraisonAI-Plugins gate configuration. + */ + +module.exports = { + repoFullName: 'MervinPraison/PraisonAI-Plugins', + productPathPrefixes: ['src/praisonai_plugins/', 'tests/'], + sensitivePathPatterns: [ + /^\.github\/workflows\//, + /^pyproject\.toml$/, + ], + requiredCheckPatterns: [/^ci$/i, /python/i, /test/i, /lint/i, /ruff/i], + ciWorkflowFile: 'ci.yml', + ciWorkflowName: 'CI', + mergeGateWorkflowRuns: ['CI', 'Claude Assistant'], + ciFailureWorkflowRuns: ['CI'], + pypiPackageName: 'praisonai-plugins', + packagePaths: ['src/praisonai_plugins', 'pyproject.toml'], + finalClaudeScope: + 'SCOPE: Focus ONLY on PraisonAI-Plugins (src/praisonai_plugins, tests). ' + + 'Lifecycle plugins use praisonai.plugins; sandbox backends use praisonai.sandbox. ' + + 'Do NOT add agent-callable tools here — those belong in PraisonAI-Tools.', + finalClaudeProductValue: + '4. Product value: plugins wrap execution lifecycle (hooks, guardrails, policies); ' + + 'reject scope creep into praisonaiagents core or agent tools.', + agentPyChecks: false, + reviewBotLogins: [ + 'coderabbitai[bot]', + 'qodo-code-review[bot]', + 'greptile-apps[bot]', + ], +}; diff --git a/.github/scripts/merge-gate-selftest.js b/.github/scripts/merge-gate-selftest.js new file mode 100644 index 0000000..14ef768 --- /dev/null +++ b/.github/scripts/merge-gate-selftest.js @@ -0,0 +1,237 @@ +#!/usr/bin/env node +/** + * Local self-test for merge-gate.js heuristics (no GitHub API). + * Run: node .github/scripts/merge-gate-selftest.js + */ +const mg = require('./merge-gate.js'); +const config = require('./gate-config.js'); +const productSample = `${config.productPathPrefixes[0]}a/b.py`; +const repoShort = config.repoFullName.split('/')[1]; + +let failed = 0; +function assert(name, cond) { + if (!cond) { + console.error('FAIL:', name); + failed++; + } else { + console.log('ok:', name); + } +} + +// Stale FINAL: push after FINAL, no @claude since head +const finals = [ + { user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T08:00:00Z' }, +]; +assert('stale when head after final', mg.isStaleFinalAfterPush(finals, '2026-06-12T09:00:00Z')); + +const withRecovery = [ + ...finals, + { user: { login: 'github-actions[bot]' }, body: '@claude FINAL architecture reviewer', created_at: '2026-06-12T09:30:00Z' }, +]; +assert('not stale when @claude after head', !mg.isStaleFinalAfterPush(withRecovery, '2026-06-12T09:00:00Z')); + +const withClaudeReply = [ + ...finals, + { + user: { login: 'praisonai-triage-agent[bot]' }, + body: "**Claude finished @MervinPraison's task** —— [View job](https://github.com/)", + created_at: '2026-06-12T08:30:00Z', + }, +]; +assert('not stale when Claude replied after FINAL', !mg.isStaleFinalAfterPush(withClaudeReply, '2026-06-12T09:00:00Z')); +assert('claude final reply detected', mg.isClaudeFinalReplyComment(withClaudeReply[1])); + +assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger')); + +// Stale-FINAL recovery guards (PR #2560 push loop) +const nowMs = Date.now(); +const iso = (ms) => new Date(ms).toISOString(); +const firstFinal = { + user: { login: 'MervinPraison' }, + body: '@claude You are the FINAL architecture reviewer.', + created_at: iso(nowMs - 20 * 60 * 1000), +}; +const pushSoonAfter = mg.shouldSkipStaleFinalRecovery( + [firstFinal], + iso(nowMs - 16 * 60 * 1000) +); +assert('debounce push soon after FINAL', pushSoonAfter.skip && pushSoonAfter.reason.includes('soon after')); + +const secondFinal = { + user: { login: 'MervinPraison' }, + body: '@claude You are the FINAL architecture reviewer.', + created_at: iso(nowMs - 20 * 60 * 1000), +}; +const capped = mg.shouldSkipStaleFinalRecovery( + [firstFinal, secondFinal], + iso(nowMs - 2 * 60 * 1000) +); +assert('hourly cap blocks third FINAL in window', capped.skip && capped.reason.includes('capped')); + +const botPush = mg.shouldSkipStaleFinalRecovery( + [firstFinal], + iso(nowMs - 16 * 60 * 1000), + 'praisonai-triage-agent[bot]' +); +assert('skip when automation pushed head', botPush.skip && botPush.reason.includes('automation')); + +assert('claude automation login', mg.isClaudeAutomationLogin('praisonai-triage-agent[bot]')); + +// Bot CHANGES_REQUESTED then APPROVE +const reviews = [ + { user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T08:00:00Z' }, + { user: { login: 'coderabbit[bot]', type: 'Bot' }, state: 'APPROVED', submitted_at: '2026-06-12T09:00:00Z' }, +]; +assert('bot approve clears CR', !mg.hasAnyChangesRequested(reviews)); +assert('human CR blocks', mg.hasAnyChangesRequested([ + { user: { login: 'MervinPraison', type: 'User' }, state: 'CHANGES_REQUESTED', submitted_at: '2026-06-12T09:00:00Z' }, +])); + +// Verdict after HEAD +const verdictComments = [ + { body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T08:00:00Z' }, +]; +assert('verdict before head rejected', mg.findMergeGateVerdict(verdictComments, null, '2026-06-12T09:00:00Z') === null); +assert('verdict after head accepted', mg.findMergeGateVerdict( + [{ body: 'MERGE_GATE_VERDICT: APPROVE', created_at: '2026-06-12T10:00:00Z' }], + null, + '2026-06-12T09:00:00Z' +) === 'APPROVE'); + +const fallbackApprove = [ + { + body: 'MERGE_GATE_VERDICT: APPROVE\n\nAutomated fallback — Claude assess did not post a verdict comment.', + created_at: '2026-06-12T10:00:00Z', + }, +]; +assert( + 'automated fallback APPROVE ignored when Opus required', + mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null +); +assert( + 'automated fallback APPROVE still visible without Opus-only filter', + mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z') === 'APPROVE' +); + +const fallbackBlockNewFormat = [ + { + body: 'MERGE_GATE_VERDICT: BLOCK\n\nAutomated fallback — Opus merge gate assessment did not complete.', + created_at: '2026-06-12T10:00:00Z', + }, +]; +assert( + 'new-format fallback BLOCK ignored when Opus required', + mg.findMergeGateVerdict(fallbackBlockNewFormat, null, '2026-06-12T09:00:00Z', { excludeAutomatedFallback: true }) === null +); +assert( + 'new-format fallback BLOCK detected by marker helper', + mg.isAutomatedFallbackVerdict(fallbackBlockNewFormat[0].body) +); +assert( + 'findMergeGateVerdict null-safe with explicit null options', + mg.findMergeGateVerdict(fallbackApprove, null, '2026-06-12T09:00:00Z', null) === 'APPROVE' +); + +const noise = [{ user: { login: 'MervinPraison' }, body: '**Merge gate scan** — wait for `@claude`', created_at: new Date().toISOString() }]; +assert('diagnostic comment not a trigger', !mg.hasRecentClaudeTrigger(noise, 35)); + +// Cooldown skip requires an actual verdict on HEAD, not just a FINAL trigger comment +const recentFinalOnHead = [ + { + user: { login: 'MervinPraison' }, + body: '@claude You are the FINAL architecture reviewer.', + created_at: '2026-06-27T10:00:00Z', + }, +]; +assert( + 'final trigger alone does not count as verdict on head', + mg.findMergeGateVerdict(recentFinalOnHead, null, '2026-06-27T09:55:00Z') === null +); +const recentVerdictOnHead = [ + ...recentFinalOnHead, + { + user: { login: 'github-actions[bot]' }, + body: 'MERGE_GATE_VERDICT: APPROVE', + created_at: '2026-06-27T10:05:00Z', + }, +]; +assert( + 'verdict on head skips cooldown gate', + mg.findMergeGateVerdict(recentVerdictOnHead, null, '2026-06-27T09:55:00Z') === 'APPROVE' +); + +// Sensitive + secrets +assert('workflow path sensitive', mg.sensitivePathReasons([{ filename: '.github/workflows/foo.yml' }]).length === 1); +assert('ci-only label exempts workflows', mg.sensitivePathReasons( + [{ filename: '.github/workflows/foo.yml' }], + [mg.WORKFLOW_ONLY_LABEL] +).length === 0); +assert('ci-only label not exempt mixed changes', mg.sensitivePathReasons( + [{ filename: '.github/workflows/foo.yml' }, { filename: productSample }], + [mg.WORKFLOW_ONLY_LABEL] +).length === 1); +assert('secret in patch', mg.secretScanReasons([{ filename: 'x.py', patch: '+key = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"' }]).length === 1); + +// Claude run scoping — other PR branches must not block +assert('other branch claude does not block', !mg.hasBlockingClaudeRunForPr( + [{ event: 'issue_comment', head_branch: 'other-branch' }], + 'my-branch' +)); +assert('same branch claude blocks', mg.hasBlockingClaudeRunForPr( + [{ event: 'issue_comment', head_branch: 'my-branch' }], + 'my-branch' +)); +assert('issues event never blocks', !mg.hasBlockingClaudeRunForPr( + [{ event: 'issues', head_branch: 'my-branch' }], + 'my-branch' +)); + +// Conflict rebase clears after bot completion + FINAL on HEAD (within 12h cooldown) +const conflictNowMs = Date.now(); +const conflictIso = (offsetMs) => new Date(conflictNowMs + offsetMs).toISOString(); +const conflictTrigger = { + user: { login: 'MervinPraison' }, + body: '@claude this PR has merge conflicts with main. Please rebase', + created_at: conflictIso(-30 * 60 * 1000), +}; +const rebaseDone = { + user: { login: 'praisonai-triage-agent[bot]' }, + body: 'Rebase complete — PR #2308 onto latest main', + created_at: conflictIso(-29 * 60 * 1000), +}; +const finalAfterRebase = { + user: { login: 'MervinPraison' }, + body: '@claude You are the FINAL architecture reviewer.', + created_at: conflictIso(-20 * 60 * 1000), +}; +const headAfterRebase = conflictIso(-21 * 60 * 1000); +const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase]; +assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase)); +assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase)); +assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment( + [conflictTrigger, rebaseDone], + headAfterRebase +)); + +// Tests heuristic +assert('product code without tests', mg.missingTestsReason([{ filename: productSample, additions: 3 }]) !== null); +assert('product code with tests ok', mg.missingTestsReason([ + { filename: productSample, additions: 3 }, + { filename: 'tests/test_x.py', additions: 10 }, +]) === null); + +// PR size +assert('large PR blocked', mg.prSizeReasons([{ additions: 900 }]).length > 0); + +assert('internal PR link accepted', mg.isInternalPullRequestLink( + { base: { repo: { full_name: config.repoFullName } } }, + 'MervinPraison', + repoShort +)); +assert('fork sync PR link rejected', !mg.isInternalPullRequestLink( + { number: 21, base: { repo: { full_name: `Milkmange/${repoShort}` } } }, + 'MervinPraison', + repoShort +)); + +process.exit(failed ? 1 : 0); diff --git a/.github/scripts/merge-gate.js b/.github/scripts/merge-gate.js new file mode 100644 index 0000000..198aff3 --- /dev/null +++ b/.github/scripts/merge-gate.js @@ -0,0 +1,899 @@ +/** + * Shared merge-gate helpers for claude-merge-gate.yml + * @see .github/workflows/claude-merge-gate.yml + */ + +const config = require('./gate-config.js'); + +const CLAUDE_TRIGGER_LOGINS = ['MervinPraison', 'github-actions[bot]']; +const AUTO_ACTORS = CLAUDE_TRIGGER_LOGINS; +const CONFLICT_COOLDOWN_MS = 12 * 60 * 60 * 1000; +const CLAUDE_ACTIVE_MS = 35 * 60 * 1000; +const STALE_FINAL_RECOVERY_WINDOW_MS = 60 * 60 * 1000; +const STALE_FINAL_MAX_PER_WINDOW = 2; +const PUSH_AFTER_FINAL_DEBOUNCE_MS = 15 * 60 * 1000; +const ALLOWED_MERGE_STATES = new Set(['CLEAN', 'UNSTABLE']); +const AGENT_PY_MAX_AUTO_LINES = 100; +const PR_MAX_AUTO_ADDITIONS = 800; +const PR_MAX_AUTO_FILES = 30; +const MANUAL_ONLY_LABELS = new Set(['security', 'breaking-change', 'needs-manual-review', 'release']); +const WORKFLOW_ONLY_LABEL = 'merge-gate-ci-only'; +const CI_ONLY_PATH_PREFIXES = ['.github/workflows/', '.github/actions/', '.github/scripts/merge-gate']; +const SDK_PATH_PREFIXES = config.productPathPrefixes; +const SENSITIVE_PATH_PATTERNS = config.sensitivePathPatterns; +const REQUIRED_SDK_CHECK_PATTERNS = config.requiredCheckPatterns; +const SECRET_PATTERNS = [ + /sk-[a-zA-Z0-9]{20,}/, + /AKIA[0-9A-Z]{16}/, + /ghp_[a-zA-Z0-9]{36,}/, + /github_pat_/, + /Bearer eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/, + /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/, +]; +const BLOCK_LABELS = new Set([ + 'claude-conflict-pending', + 'claude-merge-gate-active', + 'no-auto-merge', + 'auto-merged-by-gate', +]); +const MERGE_READY_LABEL = 'pipeline/merge-ready'; +/** Superseded concurrency runs; must not block merge when real tests passed. */ +const OPTIONAL_CANCELLED_CHECKS = new Set(['detect-and-trigger']); +const BOT_REVIEWER_PATTERNS = [ + 'coderabbit', + 'qodo', + 'gemini', + 'copilot', + 'greptile', +]; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function isFinalClaudeTriggerComment(c) { + const body = (c.body || '').toLowerCase(); + if (!AUTO_ACTORS.includes(c.user.login)) return false; + if (!body.includes('@claude')) return false; + if (body.includes('merge conflict')) return false; + return body.includes('final architecture reviewer') || body.includes('lead engineer'); +} + +function isClaudeTriggerNoise(c) { + const body = c.body || ''; + if (body.includes('Merge gate scan')) return true; + if (body.includes('MERGE_GATE_VERDICT')) return true; + if (body.includes('Merged by **Claude PR merge gate**')) return true; + return false; +} + +function isClaudeFinalReplyComment(c) { + const login = (c.user?.login || '').toLowerCase(); + if (!login.includes('praisonai-triage')) return false; + return (c.body || '').includes('Claude finished'); +} + +function hasRecentClaudeTrigger(comments, minutes = 35) { + const cutoff = Date.now() - minutes * 60 * 1000; + return comments.some((c) => { + if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false; + if (isClaudeTriggerNoise(c)) return false; + if (!(c.body || '').includes('@claude')) return false; + return new Date(c.created_at).getTime() > cutoff; + }); +} + +function isConflictRebaseTriggerComment(c) { + if (!AUTO_ACTORS.includes(c.user.login)) return false; + const body = (c.body || '').toLowerCase(); + return body.includes('@claude') && body.includes('merge conflict'); +} + +function isConflictRebaseCompletionComment(c) { + const login = (c.user?.login || '').toLowerCase(); + if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false; + const body = (c.body || '').toLowerCase(); + return ( + body.includes('rebase complete') || + body.includes('rebase onto') || + (body.includes('conflict') && body.includes('resolved')) + ); +} + +function conflictRebaseQuiescent(comments, headPushedAt) { + const conflictTriggers = comments.filter(isConflictRebaseTriggerComment); + if (conflictTriggers.length === 0) return true; + + const latestConflict = conflictTriggers.reduce((a, b) => + new Date(a.created_at) > new Date(b.created_at) ? a : b + ); + const conflictTime = new Date(latestConflict.created_at).getTime(); + + const rebaseDone = comments.some( + (c) => + new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c) + ); + if (!rebaseDone) return false; + + return finalClaudeCompletedOnSha(comments, headPushedAt); +} + +function hasRecentConflictComment(comments, headPushedAt = null) { + const cutoff = Date.now() - CONFLICT_COOLDOWN_MS; + const hasRecentTrigger = comments.some((c) => { + if (!isConflictRebaseTriggerComment(c)) return false; + return new Date(c.created_at).getTime() > cutoff; + }); + if (!hasRecentTrigger) return false; + + if (headPushedAt && conflictRebaseQuiescent(comments, headPushedAt)) { + return false; + } + return true; +} + +function isBotReviewer(login, userType) { + const lower = (login || '').toLowerCase(); + if (userType === 'Bot') return true; + if (lower.endsWith('[bot]')) return true; + return ['coderabbit', 'qodo', 'gemini', 'copilot', 'greptile'].some((p) => lower.includes(p)); +} + +function latestReviewsByUser(reviews) { + const latestByUser = new Map(); + for (const r of reviews) { + const login = r.user?.login; + if (!login) continue; + const prev = latestByUser.get(login); + if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) { + latestByUser.set(login, r); + } + } + return latestByUser; +} + +function hasHumanChangesRequested(reviews) { + for (const [login, review] of latestReviewsByUser(reviews)) { + if (review.state !== 'CHANGES_REQUESTED') continue; + if (!isBotReviewer(login, review.user?.type)) return true; + } + return false; +} + +function hasAnyChangesRequested(reviews) { + for (const [, review] of latestReviewsByUser(reviews)) { + if (review.state === 'CHANGES_REQUESTED') return true; + } + return false; +} + +function hasFinalClaudeReviewTrigger(comments) { + return comments.some(isFinalClaudeTriggerComment); +} + +function isStaleFinalAfterPush(comments, headPushedAt) { + if (!headPushedAt) return false; + const headTime = new Date(headPushedAt).getTime(); + const finals = comments.filter(isFinalClaudeTriggerComment); + if (finals.length === 0) return true; + const latestFinal = finals.reduce((a, b) => + new Date(a.created_at) > new Date(b.created_at) ? a : b + ); + const finalTime = new Date(latestFinal.created_at).getTime(); + if (headTime <= finalTime + 60000) return false; + const claudeRepliedAfterFinal = comments.some((c) => { + if (!isClaudeFinalReplyComment(c)) return false; + return new Date(c.created_at).getTime() >= finalTime - 60000; + }); + if (claudeRepliedAfterFinal) return false; + const claudeSinceHead = comments.some((c) => { + if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false; + if (isClaudeTriggerNoise(c)) return false; + if (!(c.body || '').includes('@claude')) return false; + return new Date(c.created_at).getTime() >= headTime - 60000; + }); + return !claudeSinceHead; +} + +function needsStaleFinalRecovery(comments, headPushedAt) { + return ( + hasFinalClaudeReviewTrigger(comments) && + isStaleFinalAfterPush(comments, headPushedAt) + ); +} + +function shouldSkipFinalRecovery(comments, headPushedAt) { + const isStale = isStaleFinalAfterPush(comments, headPushedAt); + if (isStale) return false; + return hasRecentClaudeTrigger(comments, 35); +} + +function countFinalTriggersSince(comments, sinceMs) { + return comments.filter( + (c) => isFinalClaudeTriggerComment(c) && new Date(c.created_at).getTime() > sinceMs + ).length; +} + +function isClaudeAutomationLogin(login) { + const lower = (login || '').toLowerCase(); + return lower.includes('praisonai-triage') || lower === 'github-actions[bot]'; +} + +function isPushSoonAfterLatestFinal(comments, headPushedAt) { + if (!headPushedAt) return false; + const finals = comments.filter(isFinalClaudeTriggerComment); + if (finals.length === 0) return false; + const latestFinal = finals.reduce((a, b) => + new Date(a.created_at) > new Date(b.created_at) ? a : b + ); + const finalTime = new Date(latestFinal.created_at).getTime(); + const headTime = new Date(headPushedAt).getTime(); + if (headTime <= finalTime) return false; + return headTime - finalTime < PUSH_AFTER_FINAL_DEBOUNCE_MS; +} + +/** Returns { skip: true, reason } when stale-FINAL recovery should not post. */ +function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin = null) { + if (!isStaleFinalAfterPush(comments, headPushedAt)) { + return { skip: true, reason: 'not stale' }; + } + if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) { + return { skip: true, reason: 'head pushed by Claude automation' }; + } + if (isPushSoonAfterLatestFinal(comments, headPushedAt)) { + return { + skip: true, + reason: 'head pushed soon after FINAL (wait for CI / batched fixes)', + }; + } + const windowStart = Date.now() - STALE_FINAL_RECOVERY_WINDOW_MS; + const finalsInWindow = countFinalTriggersSince(comments, windowStart); + if (finalsInWindow >= STALE_FINAL_MAX_PER_WINDOW) { + return { + skip: true, + reason: `stale-FINAL capped (${finalsInWindow} FINAL triggers in last hour)`, + }; + } + return { skip: false, reason: '' }; +} + +function finalClaudeCompletedOnSha(comments, headPushedAt) { + if (!hasFinalClaudeReviewTrigger(comments)) return false; + if (isStaleFinalAfterPush(comments, headPushedAt)) return false; + return true; +} + +const FINAL_CLAUDE_REVIEW_BODY = + `@claude You are the FINAL architecture reviewer. If the branch is under ${config.repoFullName} (not a fork), you are able to make modifications to this branch and push directly. ${config.finalClaudeScope} Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n${config.finalClaudeProductValue}\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items`; + +async function getMergeState(github, owner, repo, prNumber) { + const query = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + mergeStateStatus + maintainerCanModify + isDraft + headRefOid + headRef { repository { nameWithOwner } } + } + } + } + `; + for (let attempt = 0; attempt < 3; attempt++) { + const result = await github.graphql(query, { owner, repo, number: prNumber }); + const prGql = result.repository.pullRequest; + const status = (prGql?.mergeStateStatus || '').toUpperCase(); + if (status && status !== 'UNKNOWN') { + return { + status, + isDraft: prGql.isDraft, + headRepo: prGql.headRef?.repository?.nameWithOwner, + headSha: prGql.headRefOid, + maintainerCanModify: prGql.maintainerCanModify === true, + }; + } + if (attempt < 2) await sleep(10000); + } + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + return { + status: (pr.mergeable_state || '').toUpperCase(), + isDraft: pr.draft, + headRepo: pr.head.repo?.full_name, + headSha: pr.head.sha, + maintainerCanModify: pr.maintainer_can_modify === true, + }; +} + +async function listChecksOnSha(github, owner, repo, sha) { + const { data } = await github.rest.checks.listForRef({ + owner, + repo, + ref: sha, + per_page: 100, + }); + return (data.check_runs || []).filter((r) => r.head_sha === sha); +} + +function listFailedChecksOnSha(runs) { + return (runs || []).filter((run) => { + if (run.status !== 'completed') return false; + return run.conclusion === 'failure'; + }); +} + +async function allChecksGreenOnSha(github, owner, repo, sha, core) { + const runs = await listChecksOnSha(github, owner, repo, sha); + if (runs.length === 0) { + core?.info?.(`No check runs on ${sha.slice(0, 7)} — allowing (e.g. docs-only PR)`); + return true; + } + for (const run of runs) { + if (run.status !== 'completed') { + core?.info?.(`Check pending: ${run.name} (${run.status})`); + return false; + } + const ok = + ['success', 'neutral', 'skipped'].includes(run.conclusion) || + (run.conclusion === 'cancelled' && OPTIONAL_CANCELLED_CHECKS.has(run.name)); + if (!ok) { + core?.info?.(`Check failed: ${run.name} (${run.conclusion})`); + return false; + } + } + return true; +} + +function claudeRunBlocksPr(run, headRef) { + if (!run || run.event === 'issues') return false; + if (!headRef) return true; + return run.head_branch === headRef; +} + +function hasBlockingClaudeRunForPr(runs, headRef) { + return (runs || []).some((r) => claudeRunBlocksPr(r, headRef)); +} + +async function hasInProgressClaudeAssistant(github, owner, repo, prNumber = null) { + try { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'claude.yml', + status: 'in_progress', + per_page: 20, + }); + const runs = (data.workflow_runs || []).filter((r) => r.event !== 'issues'); + if (runs.length === 0) return false; + if (prNumber == null) return runs.length > 0; + + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + const headRef = pr.head.ref; + return hasBlockingClaudeRunForPr(runs, headRef); + } catch { + return false; + } +} + +function isCiOnlyChange(files) { + if (!files.length) return false; + return files.every((f) => CI_ONLY_PATH_PREFIXES.some((p) => f.filename.startsWith(p))); +} + +function isInternalPullRequestLink(link, owner, repo) { + if (link?.base?.repo?.full_name === `${owner}/${repo}`) return true; + const baseUrl = link?.base?.repo?.url || ''; + return baseUrl.endsWith(`/repos/${owner}/${repo}`); +} + +async function resolvePrNumberFromLinkedPullRequests(github, owner, repo, linked) { + const repoFull = `${owner}/${repo}`; + const internal = (linked || []).filter((l) => isInternalPullRequestLink(l, owner, repo)); + for (const link of internal) { + if (!link.number) continue; + try { + const { data } = await github.rest.pulls.get({ + owner, + repo, + pull_number: link.number, + }); + if (data.state === 'open' && data.base?.repo?.full_name === repoFull) { + return data.number; + } + } catch (err) { + if (err.status !== 404) throw err; + } + } + return null; +} + +async function resolvePrNumberFromHeadBranch(github, owner, repo, branch) { + if (!branch || branch === 'main' || branch === 'master') return null; + const { data } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + head: `${owner}:${branch}`, + per_page: 1, + }); + return data[0]?.number || null; +} + +async function resolvePrNumberFromWorkflowRun(github, owner, repo, workflowRun) { + const fromLinked = await resolvePrNumberFromLinkedPullRequests( + github, owner, repo, workflowRun.pull_requests + ); + if (fromLinked) return fromLinked; + + const fromBranch = await resolvePrNumberFromHeadBranch( + github, owner, repo, workflowRun.head_branch + ); + if (fromBranch) return fromBranch; + + return null; +} + +async function listPullFiles(github, owner, repo, prNumber) { + if (typeof github.paginate === 'function') { + return github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + } + const { data } = await github.rest.pulls.listFiles({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + return data; +} + +function countNewAgentParams(patch) { + if (!patch) return 0; + return patch + .split('\n') + .filter((l) => l.startsWith('+') && !l.startsWith('+++')) + .filter((l) => { + const t = l.slice(1).trim(); + if (!t || t.startsWith('#') || t.startsWith('"""') || t.startsWith("'''")) return false; + if (/^(def|class|@|return\b|if\b|elif\b|else\b|for\b|while\b)/.test(t)) return false; + if (/^\w+\s*[:=]/.test(t) && !t.startsWith('self.')) return true; + return false; + }).length; +} + +function getAgentPyChangeFromFiles(files) { + const agentFile = files.find((f) => f.filename.endsWith('praisonaiagents/agent/agent.py')); + if (!agentFile) { + return { touched: false, additions: 0, newParams: 0 }; + } + return { + touched: true, + additions: agentFile.additions || 0, + newParams: countNewAgentParams(agentFile.patch || ''), + }; +} + +async function getAgentPyChange(github, owner, repo, prNumber) { + const files = await listPullFiles(github, owner, repo, prNumber); + return getAgentPyChangeFromFiles(files); +} + +function touchesSdk(files) { + return files.some((f) => SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p))); +} + +function hasManualOnlyLabel(labels) { + return labels.some((l) => MANUAL_ONLY_LABELS.has(l)); +} + +function sensitivePathReasons(files, labels = []) { + if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) { + return []; + } + const reasons = []; + for (const f of files) { + if (SENSITIVE_PATH_PATTERNS.some((p) => p.test(f.filename))) { + reasons.push(`sensitive path: ${f.filename}`); + break; + } + } + return reasons; +} + +function prSizeReasons(files) { + const reasons = []; + const totalAdditions = files.reduce((sum, f) => sum + (f.additions || 0), 0); + if (totalAdditions > PR_MAX_AUTO_ADDITIONS) { + reasons.push(`PR +${totalAdditions} lines (>${PR_MAX_AUTO_ADDITIONS}) requires manual review`); + } + if (files.length > PR_MAX_AUTO_FILES) { + reasons.push(`${files.length} files changed (>${PR_MAX_AUTO_FILES}) requires manual review`); + } + return reasons; +} + +function missingTestsReason(files) { + const productAdds = files.filter( + (f) => + SDK_PATH_PREFIXES.some((p) => f.filename.startsWith(p)) && + f.filename.endsWith('.py') && + !f.filename.endsWith('__init__.py') && + (f.additions || 0) > 0 + ); + if (productAdds.length === 0) return null; + const hasTestChange = files.some( + (f) => /\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename) || /_test\.py$/.test(f.filename) + ); + if (!hasTestChange) return 'Product code added without test file changes — requires manual review'; + return null; +} + +function secretScanReasons(files) { + for (const f of files) { + if (/\/tests?\//.test(f.filename) || /test_.*\.py$/.test(f.filename)) continue; + const patch = f.patch || ''; + if (!patch) continue; + for (const pattern of SECRET_PATTERNS) { + if (pattern.test(patch)) { + return [`possible secret in diff (${f.filename}) — requires manual review`]; + } + } + } + return []; +} + +async function sdkTestChecksReason(github, owner, repo, sha, files, core) { + if (!touchesSdk(files)) return null; + const { data } = await github.rest.checks.listForRef({ + owner, + repo, + ref: sha, + per_page: 100, + }); + const runs = (data.check_runs || []).filter((r) => r.head_sha === sha); + if (runs.length === 0) { + return 'SDK code changed but no CI checks on HEAD'; + } + const testRuns = runs.filter((r) => REQUIRED_SDK_CHECK_PATTERNS.some((p) => p.test(r.name || ''))); + if (testRuns.length === 0) { + return 'SDK code changed but no test check runs on HEAD'; + } + core?.info?.(`SDK test checks on HEAD: ${testRuns.map((r) => r.name).join(', ')}`); + return null; +} + +function manualReviewReasonForAgentPy(agentChange) { + if (!agentChange.touched) return null; + if (agentChange.additions > AGENT_PY_MAX_AUTO_LINES) { + return `agent.py +${agentChange.additions} lines (>${AGENT_PY_MAX_AUTO_LINES}) requires manual review`; + } + if (agentChange.newParams > 0) { + return `agent.py adds ${agentChange.newParams} new Agent param(s) — requires manual review`; + } + return null; +} + +async function listAllComments(github, owner, repo, issueNumber) { + if (typeof github.paginate === 'function') { + return github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + } + const { data } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return data; +} + +async function getHeadCommitDate(github, owner, repo, prNumber) { + try { + let commits; + if (typeof github.paginate === 'function') { + commits = await github.paginate(github.rest.pulls.listCommits, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + } else { + const { data } = await github.rest.pulls.listCommits({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + commits = data; + } + const last = commits[commits.length - 1]; + return last?.commit?.committer?.date || last?.commit?.author?.date || null; + } catch { + return null; + } +} + +async function loadPrContext(github, owner, repo, prNumber) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber }); + const comments = await listAllComments(github, owner, repo, prNumber); + const { data: reviews } = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + const mergeState = await getMergeState(github, owner, repo, prNumber); + const headSha = mergeState.headSha || pr.head.sha; + const headCommitDate = await getHeadCommitDate(github, owner, repo, prNumber); + const headPushedAt = headCommitDate || pr.updated_at; + + return { + pr, + issue, + comments, + reviews, + mergeState, + headSha, + headPushedAt, + labels: issue.labels.map((l) => l.name), + baseRepo: `${owner}/${repo}`, + }; +} + +async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, options = {}) { + const { + forMergeStep = false, + skipGlobalClaudeRunCheck = false, + skipRecentClaudeCooldown = false, + } = options; + const ctx = await loadPrContext(github, owner, repo, prNumber); + const reasons = []; + + if (ctx.pr.draft) reasons.push('draft'); + if (ctx.pr.state !== 'open') reasons.push('not open'); + if (ctx.labels.includes('auto-merged-by-gate')) reasons.push('already merged by gate'); + if (ctx.labels.includes('no-auto-merge')) reasons.push('no-auto-merge label'); + if (ctx.labels.includes('claude-conflict-pending')) reasons.push('claude-conflict-pending'); + if (!forMergeStep && ctx.labels.includes('claude-merge-gate-active')) { + reasons.push('claude-merge-gate-active'); + } + + const { status, headRepo, maintainerCanModify } = ctx.mergeState; + if (!ALLOWED_MERGE_STATES.has(status)) reasons.push(`mergeState=${status}`); + + if (headRepo && headRepo !== ctx.baseRepo) { + reasons.push('fork PR'); + } + + if (hasRecentConflictComment(ctx.comments, ctx.headPushedAt)) { + reasons.push('recent merge-conflict @claude'); + } + if (!skipRecentClaudeCooldown && hasRecentClaudeTrigger(ctx.comments, 35)) { + const verdictOnHead = findMergeGateVerdict(ctx.comments, null, ctx.headPushedAt) !== null; + if (!verdictOnHead) reasons.push('recent @claude within 35min'); + } + + if (!skipGlobalClaudeRunCheck && (await hasInProgressClaudeAssistant(github, owner, repo, prNumber))) { + reasons.push('claude.yml in progress'); + } + + const checksOk = await allChecksGreenOnSha(github, owner, repo, ctx.headSha, core); + if (!checksOk) reasons.push('CI not green on HEAD'); + + if (!finalClaudeCompletedOnSha(ctx.comments, ctx.headPushedAt)) { + if (!hasFinalClaudeReviewTrigger(ctx.comments)) { + reasons.push('no FINAL Claude review trigger'); + } else if (isStaleFinalAfterPush(ctx.comments, ctx.headPushedAt)) { + reasons.push('stale FINAL after new commits (needs @claude re-review)'); + } else { + reasons.push('FINAL Claude not complete on HEAD'); + } + } + + if (hasAnyChangesRequested(ctx.reviews)) reasons.push('CHANGES_REQUESTED review'); + + if (hasManualOnlyLabel(ctx.labels)) { + const manualLabel = ctx.labels.find((l) => MANUAL_ONLY_LABELS.has(l)); + reasons.push(`manual-only label: ${manualLabel}`); + } + + const pullFiles = await listPullFiles(github, owner, repo, prNumber); + + if (config.agentPyChecks) { + const agentChange = getAgentPyChangeFromFiles(pullFiles); + const agentManual = manualReviewReasonForAgentPy(agentChange); + if (agentManual) reasons.push(agentManual); + } + + reasons.push(...sensitivePathReasons(pullFiles, ctx.labels)); + reasons.push(...prSizeReasons(pullFiles)); + reasons.push(...secretScanReasons(pullFiles)); + + const testsReason = missingTestsReason(pullFiles); + if (testsReason) reasons.push(testsReason); + + const sdkCheckReason = await sdkTestChecksReason(github, owner, repo, ctx.headSha, pullFiles, core); + if (sdkCheckReason) reasons.push(sdkCheckReason); + + if (ctx.pr.mergeable === false) reasons.push('not mergeable'); + + const ready = reasons.length === 0; + return { + ready, + reasons, + headSha: ctx.headSha, + prNumber, + }; +} + +async function listPrNumbersForMergeGateScan(github, owner, repo, core) { + let mergeReadyIssues; + if (typeof github.paginate === 'function') { + mergeReadyIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: MERGE_READY_LABEL, + per_page: 100, + }); + } else { + ({ data: mergeReadyIssues } = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + labels: MERGE_READY_LABEL, + per_page: 100, + })); + } + const mergeReady = mergeReadyIssues + .filter((issue) => issue.pull_request) + .map((issue) => issue.number) + .sort((a, b) => a - b); + + if (mergeReady.length > 0) { + core?.info?.( + `Merge gate scan: ${mergeReady.length} ${MERGE_READY_LABEL} PR(s) (skipping unlabelled open PRs)` + ); + return mergeReady; + } + + core?.info?.(`No ${MERGE_READY_LABEL} PRs — scanning oldest open PRs`); + const prs = + typeof github.paginate === 'function' + ? await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + sort: 'created', + direction: 'asc', + }) + : (await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + sort: 'created', + direction: 'asc', + })).data; + return prs.map((pr) => pr.number); +} + +async function selectMergeGateCandidates(github, owner, repo, prNumbers, maxCandidates, core) { + const readyList = []; + const skipped = []; + for (const num of prNumbers) { + if (readyList.length >= maxCandidates) { + core?.info?.(`Found ${maxCandidates} candidate(s) — skipping remaining PR scans`); + break; + } + const result = await evaluatePipelineQuiescent(github, owner, repo, num, core); + if (result.ready) { + readyList.push({ pr_number: num, head_sha: result.headSha }); + } else { + skipped.push({ pr: num, reasons: result.reasons }); + } + } + readyList.sort((a, b) => a.pr_number - b.pr_number); + return { + candidates: readyList.slice(0, maxCandidates), + skipped, + }; +} + +const AUTOMATED_FALLBACK_MARKER = 'Automated fallback —'; + +function isAutomatedFallbackVerdict(body) { + return (body || '').includes(AUTOMATED_FALLBACK_MARKER); +} + +function findMergeGateVerdict(comments, minCreatedAt = null, headPushedAt = null, options = {}) { + const { excludeAutomatedFallback = false } = options || {}; + const minTime = minCreatedAt ? new Date(minCreatedAt).getTime() : 0; + const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0; + const gateComments = comments + .filter((c) => { + if (!(c.body || '').includes('MERGE_GATE_VERDICT:')) return false; + if (excludeAutomatedFallback && isAutomatedFallbackVerdict(c.body)) return false; + const created = new Date(c.created_at).getTime(); + if (minTime && created < minTime) return false; + if (headTime && created < headTime) return false; + return true; + }) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + if (gateComments.length === 0) return null; + const body = gateComments[0].body || ''; + if (body.includes('MERGE_GATE_VERDICT: APPROVE')) return 'APPROVE'; + if (body.includes('MERGE_GATE_VERDICT: BLOCK')) return 'BLOCK'; + return null; +} + +module.exports = { + CLAUDE_TRIGGER_LOGINS, + AUTO_ACTORS, + isFinalClaudeTriggerComment, + isClaudeTriggerNoise, + isClaudeFinalReplyComment, + hasRecentClaudeTrigger, + hasRecentConflictComment, + isConflictRebaseTriggerComment, + isConflictRebaseCompletionComment, + conflictRebaseQuiescent, + hasHumanChangesRequested, + hasAnyChangesRequested, + hasFinalClaudeReviewTrigger, + finalClaudeCompletedOnSha, + getMergeState, + OPTIONAL_CANCELLED_CHECKS, + listChecksOnSha, + listFailedChecksOnSha, + allChecksGreenOnSha, + hasInProgressClaudeAssistant, + claudeRunBlocksPr, + hasBlockingClaudeRunForPr, + isCiOnlyChange, + isInternalPullRequestLink, + resolvePrNumberFromLinkedPullRequests, + resolvePrNumberFromHeadBranch, + resolvePrNumberFromWorkflowRun, + WORKFLOW_ONLY_LABEL, + getAgentPyChange, + getAgentPyChangeFromFiles, + manualReviewReasonForAgentPy, + countNewAgentParams, + listPullFiles, + touchesSdk, + hasManualOnlyLabel, + sensitivePathReasons, + prSizeReasons, + missingTestsReason, + secretScanReasons, + sdkTestChecksReason, + listAllComments, + getHeadCommitDate, + isStaleFinalAfterPush, + needsStaleFinalRecovery, + shouldSkipFinalRecovery, + shouldSkipStaleFinalRecovery, + isClaudeAutomationLogin, + isPushSoonAfterLatestFinal, + countFinalTriggersSince, + STALE_FINAL_RECOVERY_WINDOW_MS, + STALE_FINAL_MAX_PER_WINDOW, + FINAL_CLAUDE_REVIEW_BODY, + loadPrContext, + evaluatePipelineQuiescent, + MERGE_READY_LABEL, + listPrNumbersForMergeGateScan, + selectMergeGateCandidates, + isAutomatedFallbackVerdict, + AUTOMATED_FALLBACK_MARKER, + findMergeGateVerdict, +}; diff --git a/.github/scripts/pipeline-status-selftest.js b/.github/scripts/pipeline-status-selftest.js new file mode 100644 index 0000000..2989b77 --- /dev/null +++ b/.github/scripts/pipeline-status-selftest.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Run: node .github/scripts/pipeline-status-selftest.js + */ +const ps = require('./pipeline-status.js'); +const mg = require('./merge-gate.js'); +const config = require('./gate-config.js'); + +let failed = 0; +function assert(name, cond) { + if (!cond) { + console.error('FAIL:', name); + failed++; + } else { + console.log('ok:', name); + } +} + +const kickComments = [ + { user: { login: 'MervinPraison' }, body: '@coderabbitai review' }, + { user: { login: 'MervinPraison' }, body: '/review' }, +]; + +const finalComment = { + user: { login: 'MervinPraison' }, + body: '@claude You are the FINAL architecture reviewer.', + created_at: '2026-06-26T10:00:00Z', +}; + +assert( + 'reviews pending when not kicked', + ps.deriveStage([], { ready: false, reasons: ['no FINAL Claude review trigger'] }) + === 'pipeline/reviews-pending' +); +assert( + 'final pending after kick', + ps.deriveStage(kickComments, { ready: false, reasons: ['no FINAL Claude review trigger'] }) + === 'pipeline/final-claude-pending' +); +assert( + 'awaiting merge gate after bot review comment', + ps.deriveStage( + [ + { user: { login: 'coderabbitai[bot]' }, body: 'review summary' }, + finalComment, + ], + { ready: false, reasons: ['recent @claude within 35min'] } + ) === 'pipeline/awaiting-merge-gate' +); + +assert('review bot logins configured', config.reviewBotLogins.includes('coderabbitai[bot]')); +assert( + 'merge ready', + ps.deriveStage([...kickComments, finalComment], { ready: true, reasons: [] }) + === 'pipeline/merge-ready' +); + +const blockers = ps.deriveBlockerLabels({ + ready: false, + reasons: ['CI not green on HEAD', 'SDK code added without test file changes — requires manual review'], +}); +assert('maps ci blocker', blockers.includes('pipeline/blocked:ci')); +assert('maps manual blocker', blockers.includes('pipeline/blocked:manual-review')); + +assert( + 'internal link matches upstream base', + mg.isInternalPullRequestLink( + { base: { repo: { full_name: 'MervinPraison/PraisonAI' } } }, + 'MervinPraison', + 'PraisonAI' + ) +); +assert( + 'fork sync link rejected', + !mg.isInternalPullRequestLink( + { number: 21, base: { repo: { full_name: 'Milkmange/PraisonAI' } } }, + 'MervinPraison', + 'PraisonAI' + ) +); + +process.exit(failed ? 1 : 0); diff --git a/.github/scripts/pipeline-status.js b/.github/scripts/pipeline-status.js new file mode 100644 index 0000000..15caee2 --- /dev/null +++ b/.github/scripts/pipeline-status.js @@ -0,0 +1,264 @@ +/** + * Sync pipeline stage/blocker labels on open PRs. + * @see .github/workflows/pipeline-status-sync.yml + */ + +const mergeGate = require('./merge-gate.js'); +const chain = require('./bot-pr-review-chain.js'); +const ciFix = require('./ci-failure-claude.js'); +const config = require('./gate-config.js'); + +const PIPELINE_PREFIX = 'pipeline/'; +const STAGE_LABELS = [ + 'pipeline/reviews-pending', + 'pipeline/final-claude-pending', + 'pipeline/awaiting-merge-gate', + 'pipeline/merge-ready', + 'pipeline/merged', +]; +const BLOCKER_LABELS = [ + 'pipeline/blocked:ci', + 'pipeline/blocked:conflict', + 'pipeline/blocked:manual-review', + 'pipeline/blocked:cooldown', + 'pipeline/blocked:stale-final', + 'pipeline/blocked:no-final', +]; +const ALL_PIPELINE_LABELS = [...STAGE_LABELS, ...BLOCKER_LABELS]; + +const LABEL_SPECS = [ + { name: 'pipeline/reviews-pending', color: 'fbca04', description: 'Waiting for CodeRabbit/Qodo/Copilot reviews' }, + { name: 'pipeline/final-claude-pending', color: 'd4c5f9', description: 'Reviews done; waiting for FINAL @claude' }, + { name: 'pipeline/awaiting-merge-gate', color: 'c5def5', description: 'FINAL done; waiting for merge gate / CI' }, + { name: 'pipeline/merge-ready', color: '0e8a16', description: 'Eligible for merge gate auto-merge' }, + { name: 'pipeline/merged', color: '6f42c1', description: 'Merged via pipeline' }, + { name: 'pipeline/blocked:ci', color: 'd93f0b', description: 'Blocked: CI not green on HEAD' }, + { name: 'pipeline/blocked:conflict', color: 'b60205', description: 'Blocked: merge conflict or rebase pending' }, + { name: 'pipeline/blocked:manual-review', color: 'e99695', description: 'Blocked: requires manual review' }, + { name: 'pipeline/blocked:cooldown', color: 'fef2c0', description: 'Blocked: post-push or @claude cooldown' }, + { name: 'pipeline/blocked:stale-final', color: 'f9d0c4', description: 'Blocked: FINAL stale after new commits' }, + { name: 'pipeline/blocked:no-final', color: 'ededed', description: 'Blocked: no FINAL @claude trigger yet' }, +]; + +function reviewChainStarted(comments) { + if (chain.chainKickPosted(comments)) return true; + return comments.some((c) => config.reviewBotLogins.includes(c.user?.login)); +} + +function deriveStage(comments, evalResult) { + if (evalResult.ready) return 'pipeline/merge-ready'; + if (!reviewChainStarted(comments)) return 'pipeline/reviews-pending'; + if (!mergeGate.hasFinalClaudeReviewTrigger(comments)) return 'pipeline/final-claude-pending'; + return 'pipeline/awaiting-merge-gate'; +} + +function reasonToBlockerLabel(reason) { + const r = (reason || '').toLowerCase(); + if (r.includes('ci not green') || r.includes('sdk code changed but no ci')) return 'pipeline/blocked:ci'; + if ( + r.includes('conflict') || + r.includes('mergestate=dirty') || + r.includes('not mergeable') + ) return 'pipeline/blocked:conflict'; + if ( + r.includes('manual') || + r.includes('requires manual') || + r.includes('sensitive path') || + r.includes('no-auto-merge') || + r.includes('manual-only label') || + r.includes('without test') || + r.includes('product code') || + r.includes('files changed') || + r.includes('possible secret') + ) return 'pipeline/blocked:manual-review'; + if (r.includes('recent @claude') || r.includes('post-push buffer')) return 'pipeline/blocked:cooldown'; + if (r.includes('stale final')) return 'pipeline/blocked:stale-final'; + if (r.includes('no final claude')) return 'pipeline/blocked:no-final'; + if (r.includes('final claude not complete')) return 'pipeline/blocked:stale-final'; + if (r.includes('claude.yml in progress') || r.includes('claude-merge-gate-active')) { + return null; + } + return null; +} + +function deriveBlockerLabels(evalResult) { + if (evalResult.ready) return []; + const labels = new Set(); + for (const reason of evalResult.reasons || []) { + const mapped = reasonToBlockerLabel(reason); + if (mapped && mapped.startsWith('pipeline/blocked:')) labels.add(mapped); + } + return [...labels]; +} + +function computePipelineLabels(comments, evalResult) { + const stage = deriveStage(comments, evalResult); + const blockers = stage === 'pipeline/merge-ready' ? [] : deriveBlockerLabels(evalResult); + return { stage, blockers, all: [stage, ...blockers] }; +} + +async function ensurePipelineLabels(github, owner, repo, core) { + let existing; + if (typeof github.paginate === 'function') { + existing = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner, + repo, + per_page: 100, + }); + } else { + ({ data: existing } = await github.rest.issues.listLabelsForRepo({ + owner, + repo, + per_page: 100, + })); + } + const names = new Set(existing.map((l) => l.name)); + for (const spec of LABEL_SPECS) { + if (names.has(spec.name)) continue; + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: spec.name, + color: spec.color, + description: spec.description, + }); + core?.info?.(`Created label ${spec.name}`); + } catch (err) { + if (err.status !== 422) throw err; + core?.info?.(`Label ${spec.name} already exists`); + } + } +} + +async function syncPipelineLabels(github, owner, repo, prNumber, core) { + const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber); + if (ctx.pr.state !== 'open') return { synced: false, reason: 'not_open' }; + + const evalResult = await mergeGate.evaluatePipelineQuiescent( + github, owner, repo, prNumber, core + ); + const { stage, blockers, all } = computePipelineLabels(ctx.comments, evalResult); + + const current = ctx.labels.filter((l) => l.startsWith(PIPELINE_PREFIX)); + const desired = new Set(all); + const toRemove = current.filter((l) => !desired.has(l) && l !== 'pipeline/merged'); + const toAdd = all.filter((l) => !current.includes(l)); + + for (const name of toRemove) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name }); + } catch (err) { + if (err.status !== 404) throw err; + } + } + if (toAdd.length) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels: toAdd, + }); + } + + core?.info?.(`PR #${prNumber}: stage=${stage} blockers=[${blockers.join(', ')}]`); + + 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 { + await ciFix.maybeClearCiFixLabel( + github, owner, repo, prNumber, ctx.labels, ctx.headSha, core + ); + } + + return { + synced: true, + stage, + blockers, + ready: evalResult.ready, + reasons: evalResult.reasons, + labels: ctx.labels, + createdAt: new Date(ctx.pr.created_at).getTime(), + }; +} + +async function dispatchMergeGateForOldestReady(github, owner, repo, readyCandidates, core) { + if (!readyCandidates.length) return 0; + readyCandidates.sort((a, b) => a.createdAt - b.createdAt); + for (const cand of readyCandidates) { + if ((cand.labels || []).includes('claude-merge-gate-active')) { + core?.info?.(`Skip dispatch PR #${cand.prNumber}: merge gate already active`); + continue; + } + await github.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: 'claude-merge-gate', + client_payload: { pr_number: cand.prNumber }, + }); + core?.info?.(`Dispatched merge gate for ready PR #${cand.prNumber}`); + return cand.prNumber; + } + return 0; +} + +async function syncOpenPullRequests(github, owner, repo, options, core) { + const { maxPrs = 20, dispatchMergeGate = true } = options || {}; + await ensurePipelineLabels(github, owner, repo, core); + let prs; + if (maxPrs <= 100) { + ({ data: prs } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + })); + } else { + prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + } + let synced = 0; + const readyCandidates = []; + for (const pr of prs) { + if (synced >= maxPrs) break; + if (pr.draft) continue; + if (pr.head?.repo?.full_name && pr.head.repo.full_name !== `${owner}/${repo}`) continue; + const result = await syncPipelineLabels(github, owner, repo, pr.number, core); + if (result.ready) { + readyCandidates.push({ + prNumber: pr.number, + createdAt: result.createdAt || new Date(pr.created_at).getTime(), + labels: result.labels || [], + }); + } + synced += 1; + } + let dispatched = 0; + if (dispatchMergeGate && readyCandidates.length) { + dispatched = await dispatchMergeGateForOldestReady( + github, owner, repo, readyCandidates, core + ); + } + core?.info?.(`Pipeline label sync complete (${synced} PR(s), dispatched=${dispatched || 'none'})`); + return synced; +} + +module.exports = { + STAGE_LABELS, + BLOCKER_LABELS, + ALL_PIPELINE_LABELS, + deriveStage, + deriveBlockerLabels, + computePipelineLabels, + ensurePipelineLabels, + syncPipelineLabels, + syncOpenPullRequests, + dispatchMergeGateForOldestReady, +}; diff --git a/.github/scripts/pr-review-chain.js b/.github/scripts/pr-review-chain.js new file mode 100644 index 0000000..02a304b --- /dev/null +++ b/.github/scripts/pr-review-chain.js @@ -0,0 +1,300 @@ +/** + * PR review pipeline: CodeRabbit / Greptile (+ optional Qodo/Gemini) → Claude (FINAL). + * Copilot step optional — set REVIEW_CHAIN_SKIP_COPILOT=1 or skipCopilot: true to bypass. + * @see .github/workflows/auto-pr-comment.yml + */ + +function isSkipCopilot(options = {}) { + if (options.skipCopilot === true) return true; + if (options.skipCopilot === false) return false; + return process.env.REVIEW_CHAIN_SKIP_COPILOT === '1'; +} + +const COPILOT_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']); +const CLAUDE_TRIGGER_LOGINS = new Set(['MervinPraison', 'github-actions[bot]']); + +const REQUIRED_PRIOR = ['coderabbit', 'greptile']; +const OPTIONAL_PRIOR = ['qodo', 'gemini']; +const OPTIONAL_PRIOR_WAIT_MS = 20 * 60 * 1000; + +function loginOf(item) { + return (item?.user?.login || '').toLowerCase(); +} + +function bodyOf(item) { + return item?.body || ''; +} + +function hasCoderabbitSummary(comments) { + return comments.some( + (c) => + loginOf(c).includes('coderabbit') && + bodyOf(c).toLowerCase().includes('summarize by coderabbit') + ); +} + +function hasGreptileReview(comments) { + return comments.some((c) => { + if (!loginOf(c).includes('greptile')) return false; + const body = bodyOf(c); + return body.includes('Greptile Summary') || body.includes('

Greptile Summary

') || body.length > 150; + }); +} + +function hasQodoReview(comments, reviews = []) { + if (comments.some((c) => loginOf(c).includes('qodo'))) return true; + return reviews.some((r) => loginOf(r).includes('qodo')); +} + +function hasGeminiReview(comments, reviews = []) { + if (reviews.some((r) => loginOf(r).includes('gemini'))) return true; + return comments.some((c) => { + if (loginOf(c).includes('gemini')) return true; + const body = bodyOf(c); + return ( + body.includes('Review completed by Gemini CLI') || + body.includes('The fix is ready for review. Please test') + ); + }); +} + +const PRIOR_CHECKS = { + coderabbit: (comments) => hasCoderabbitSummary(comments), + greptile: (comments) => hasGreptileReview(comments), + qodo: (comments, reviews) => hasQodoReview(comments, reviews), + gemini: (comments, reviews) => hasGeminiReview(comments, reviews), +}; + +function priorReviewerStatus(comments, reviews = []) { + const status = {}; + for (const [id, check] of Object.entries(PRIOR_CHECKS)) { + status[id] = check(comments, reviews); + } + return status; +} + +function priorReviewersReady(comments, reviews = [], options = {}) { + const waitMs = options.optionalWaitMs ?? OPTIONAL_PRIOR_WAIT_MS; + const prCreatedAt = options.prCreatedAt || null; + const status = priorReviewerStatus(comments, reviews); + + const missingRequired = REQUIRED_PRIOR.filter((id) => !status[id]); + if (missingRequired.length) { + return { ready: false, reason: `waiting for ${missingRequired.join(', ')}`, status }; + } + + const missingOptional = OPTIONAL_PRIOR.filter((id) => !status[id]); + if (missingOptional.length && prCreatedAt) { + const age = Date.now() - new Date(prCreatedAt).getTime(); + if (age < waitMs) { + return { + ready: false, + reason: `waiting for optional ${missingOptional.join(', ')} (${Math.round((waitMs - age) / 60000)}m left)`, + status, + }; + } + } + + return { ready: true, reason: '', status }; +} + +function copilotTriggerComment(comments) { + return comments.find( + (c) => + bodyOf(c).includes('@copilot') && + COPILOT_TRIGGER_LOGINS.has(c.user?.login) && + !bodyOf(c).toLowerCase().includes('@claude') + ); +} + +function copilotTriggered(comments) { + return Boolean(copilotTriggerComment(comments)); +} + +function copilotReviewReady(comments, reviews = []) { + const trigger = copilotTriggerComment(comments); + if (!trigger) { + return { ready: false, reason: 'copilot not triggered yet' }; + } + const triggerTime = new Date(trigger.created_at).getTime(); + const copilotComment = comments.some((c) => { + if (c === trigger) return false; + if (new Date(c.created_at).getTime() < triggerTime) return false; + const login = loginOf(c); + return ( + login.includes('copilot') || + c.user?.login === 'Copilot' || + c.user?.login === 'copilot-swe-agent' + ); + }); + const copilotReview = reviews.some((r) => { + if (new Date(r.submitted_at).getTime() < triggerTime) return false; + return loginOf(r).includes('copilot'); + }); + if (copilotComment || copilotReview) { + return { ready: true, reason: '' }; + } + return { ready: false, reason: 'awaiting copilot review' }; +} + +function isFinalClaudeTriggerComment(c) { + const body = bodyOf(c).toLowerCase(); + if (!CLAUDE_TRIGGER_LOGINS.has(c.user?.login)) return false; + if (!body.includes('@claude')) return false; + if (body.includes('merge conflict')) return false; + return body.includes('final architecture reviewer') || body.includes('lead engineer'); +} + +function claudeFinalAlreadyTriggered(comments) { + return comments.some(isFinalClaudeTriggerComment); +} + +function claudeFinalReady(comments, reviews = [], options = {}) { + if (!options.allowStaleRepost && claudeFinalAlreadyTriggered(comments)) { + return { ready: false, reason: 'claude FINAL already triggered', already: true }; + } + const prior = priorReviewersReady(comments, reviews, options); + if (!prior.ready) { + return { ready: false, reason: prior.reason }; + } + if (isSkipCopilot(options)) { + return { ready: true, reason: 'copilot skipped', copilotSkipped: true }; + } + const copilot = copilotReviewReady(comments, reviews); + if (copilot.ready) { + return { ready: true, reason: '' }; + } + if (options.allowCopilotTimeout && copilotTriggered(comments)) { + return { ready: true, reason: 'copilot timeout fallback', copilotSkipped: true }; + } + return { ready: false, reason: copilot.reason }; +} + +/** Post @copilot when prior reviewers are ready, then @claude FINAL when Copilot done (or timeout). */ +async function advanceReviewChain(github, owner, repo, prNumber, finalBody, core, options = {}) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + const chainOpts = { + prCreatedAt: pr.created_at, + optionalWaitMs: options.optionalWaitMs ?? 0, + }; + const copilot = options.skipCopilot + ? { triggered: false, reason: 'skipped' } + : await maybeTriggerCopilot(github, owner, repo, prNumber, core, chainOpts); + const claude = await maybeTriggerClaudeFinal( + github, owner, repo, prNumber, finalBody, core, + { ...options, ...chainOpts } + ); + return { copilot, claude }; +} + +async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody, core, options = {}) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber); + const gate = claudeFinalReady(comments, reviews, { + prCreatedAt: pr.created_at, + optionalWaitMs: options.optionalWaitMs, + allowCopilotTimeout: options.allowCopilotTimeout !== false, + allowStaleRepost: options.allowStaleRepost === true, + skipCopilot: options.skipCopilot, + }); + if (!gate.ready) { + core?.info?.(`PR #${prNumber}: skip Claude FINAL — ${gate.reason}`); + return { posted: false, reason: gate.reason }; + } + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: finalBody, + }); + const note = gate.copilotSkipped ? ' (Copilot timeout fallback)' : ''; + core?.info?.(`Posted Claude FINAL on PR #${prNumber}${note}`); + return { posted: true, reason: '' }; +} + +const COPILOT_REVIEW_BODY = + '@copilot Do a thorough review of this PR. Read ALL existing reviewer comments above from Qodo, CodeRabbit, Gemini, and Greptile first — incorporate their findings.\n\nReview areas:\n1. **Bloat check**: Are changes minimal and focused? Any unnecessary code or scope creep?\n2. **Security**: Any hardcoded secrets, unsafe eval/exec, missing input validation?\n3. **Performance**: Any module-level heavy imports? Hot-path regressions?\n4. **Tests**: Are tests included? Do they cover the changes adequately?\n5. **Backward compat**: Any public API changes without deprecation?\n6. **Code quality**: DRY violations, naming conventions, error handling?\n7. **Address reviewer feedback**: If Qodo, CodeRabbit, Gemini, or Greptile flagged valid issues, include them in your review\n8. Suggest specific improvements with code examples where possible'; + +async function listCommentsAndReviews(github, owner, repo, prNumber) { + let comments; + if (typeof github.paginate === 'function') { + comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + } else { + const { data } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + comments = data; + } + const { data: reviews } = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + return { comments, reviews }; +} + +async function maybeTriggerCopilot(github, owner, repo, prNumber, core, options = {}) { + const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber); + if (copilotTriggered(comments)) { + core?.info?.(`Copilot already triggered on PR #${prNumber}`); + return { triggered: false, reason: 'already_triggered' }; + } + const prior = priorReviewersReady(comments, reviews, options); + if (!prior.ready) { + core?.info?.(`PR #${prNumber}: not ready for Copilot — ${prior.reason}`); + return { triggered: false, reason: prior.reason }; + } + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: COPILOT_REVIEW_BODY, + }); + core?.info?.(`Posted @copilot on PR #${prNumber} after prior reviewers`); + return { triggered: true }; +} + +async function pollCopilotResponse(github, owner, repo, prNumber, options = {}) { + const maxAttempts = options.maxAttempts ?? 20; + const delayMs = options.delayMs ?? 30000; + for (let i = 0; i < maxAttempts; i += 1) { + const { comments, reviews } = await listCommentsAndReviews(github, owner, repo, prNumber); + const copilot = copilotReviewReady(comments, reviews); + if (copilot.ready) { + return { ready: true, reason: '' }; + } + if (i < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + return { ready: false, reason: 'copilot timeout' }; +} + +module.exports = { + isSkipCopilot, + REQUIRED_PRIOR, + OPTIONAL_PRIOR, + COPILOT_REVIEW_BODY, + priorReviewerStatus, + priorReviewersReady, + copilotTriggered, + copilotReviewReady, + claudeFinalReady, + claudeFinalAlreadyTriggered, + isFinalClaudeTriggerComment, + maybeTriggerCopilot, + maybeTriggerClaudeFinal, + advanceReviewChain, + pollCopilotResponse, + hasGeminiReview, + listCommentsAndReviews, +}; diff --git a/.github/scripts/release-gate-selftest.js b/.github/scripts/release-gate-selftest.js new file mode 100644 index 0000000..d04fb53 --- /dev/null +++ b/.github/scripts/release-gate-selftest.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * Run: node .github/scripts/release-gate-selftest.js + */ +const rg = require('./release-gate.js'); +const config = require('./gate-config.js'); + +let failed = 0; +function assert(name, cond) { + if (!cond) { + console.error('FAIL:', name); + failed += 1; + } else { + console.log('ok:', name); + } +} + +assert(`package name is ${config.pypiPackageName}`, config.pypiPackageName === 'praisonai-plugins'); +assert('bumpPatch works', rg.bumpPatch('0.3.127') === '0.3.128'); + +try { + const versions = rg.readVersionsFromTree(process.cwd()); + assert('reads current version', /^\d+\.\d+\.\d+$/.test(versions.current)); + assert('computes target patch', versions.target === rg.bumpPatch(versions.current)); +} catch (err) { + console.error('FAIL: readVersionsFromTree', err.message); + failed += 1; +} + +assert('PACKAGE_PATHS includes pyproject', rg.PACKAGE_PATHS.includes('pyproject.toml')); + +process.exit(failed ? 1 : 0); diff --git a/.github/scripts/release-gate.js b/.github/scripts/release-gate.js new file mode 100644 index 0000000..cf9a573 --- /dev/null +++ b/.github/scripts/release-gate.js @@ -0,0 +1,205 @@ +/** + * Release gate preflight — path changes, CI SHA, dedupe, PyPI version checks (PraisonAIUI). + */ + +const https = require('https'); +const config = require('./gate-config.js'); + +const PACKAGE_PATHS = config.packagePaths; +const ACTIVE_RELEASE_STATUSES = new Set([ + 'queued', 'in_progress', 'waiting', 'pending', 'requested', +]); + +function bumpPatch(version) { + const parts = version.split('.'); + if (parts.length !== 3) throw new Error(`Invalid version: ${version}`); + return `${parts[0]}.${parts[1]}.${Number(parts[2]) + 1}`; +} + +function readVersionsFromTree(root = '.') { + const fs = require('fs'); + const path = require('path'); + const toml = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8'); + const match = toml.match(/^version\s*=\s*"([^"]+)"/m); + if (!match) throw new Error('Could not read aiui version from pyproject.toml'); + const current = match[1]; + const target = bumpPatch(current); + return { current, target, packageName: config.pypiPackageName }; +} + +function pypiVersionExists(packageName, version) { + return new Promise((resolve) => { + const req = https.get( + `https://pypi.org/pypi/${packageName}/${version}/json`, + { timeout: 15000 }, + (res) => { + res.resume(); + resolve(res.statusCode === 200); + } + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +async function hasActiveReleaseRun(github, owner, repo) { + const runs = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'pypi-release.yml', + per_page: 20, + }); + return runs.data.workflow_runs.some( + (r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion + ); +} + +function utcDayStart(now = new Date()) { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); +} + +async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) { + const dayStart = utcDayStart(now); + const runs = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: 'pypi-release.yml', + status: 'completed', + per_page: 30, + }); + return runs.data.workflow_runs.some( + (r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart + ); +} + +async function lastGreenCiSha(github, owner, repo) { + const runs = await github.rest.actions.listWorkflowRuns({ + owner, + repo, + workflow_id: config.ciWorkflowFile, + branch: 'main', + status: 'completed', + per_page: 30, + }); + const hit = runs.data.workflow_runs.find((r) => r.conclusion === 'success'); + return hit ? hit.head_sha : ''; +} + +async function evaluateReleasePreflight(github, owner, repo, options, core) { + const { + headSha, + isCiTrigger = false, + bump = 'patch', + } = options; + + const reasons = []; + const out = { + ready: false, + reasons, + headSha: headSha || '', + lastTag: '', + targetVersion: '', + packageName: config.pypiPackageName, + }; + + if (bump !== 'patch') { + reasons.push('only patch auto-release supported'); + return out; + } + + if (await hasActiveReleaseRun(github, owner, repo)) { + reasons.push('PyPI Release already in progress or awaiting approval'); + return out; + } + + const referenceTime = options.now instanceof Date ? options.now : new Date(); + if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) { + const day = referenceTime.toISOString().slice(0, 10); + reasons.push(`already released today (UTC ${day}); max one patch release per day`); + return out; + } + + let versions; + try { + versions = readVersionsFromTree(); + } catch (err) { + reasons.push(err.message); + return out; + } + out.targetVersion = versions.target; + + const onPypi = await pypiVersionExists(config.pypiPackageName, versions.target); + if (onPypi) { + reasons.push(`already published: ${config.pypiPackageName}==${versions.target}`); + return out; + } + + const { execSync } = require('child_process'); + let lastTag = ''; + try { + lastTag = execSync('git describe --tags --match "v*" --abbrev=0', { encoding: 'utf8' }).trim(); + } catch { + reasons.push('no v* tag found'); + return out; + } + out.lastTag = lastTag; + + let changed = ''; + try { + changed = execSync( + `git diff --name-only ${lastTag} HEAD -- ${PACKAGE_PATHS.join(' ')}`, + { encoding: 'utf8' } + ).trim(); + } catch { + reasons.push('git diff failed'); + return out; + } + + if (!changed) { + reasons.push(`no changes in ${PACKAGE_PATHS.join(' or ')} since ${lastTag}`); + return out; + } + + if (isCiTrigger) { + let mainSha = ''; + try { + mainSha = execSync('git rev-parse origin/main', { encoding: 'utf8' }).trim(); + } catch { + reasons.push('could not resolve origin/main'); + return out; + } + if (headSha !== mainSha) { + reasons.push(`superseded: green SHA ${headSha.slice(0, 7)} != main ${mainSha.slice(0, 7)}`); + return out; + } + out.headSha = headSha; + } else { + const evalSha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); + out.headSha = evalSha; + const greenSha = await lastGreenCiSha(github, owner, repo); + if (greenSha !== evalSha) { + reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`); + return out; + } + } + + out.ready = true; + out.reasons = ['ready']; + if (core) core.info(`Release preflight passed for ${out.headSha || headSha}`); + return out; +} + +module.exports = { + bumpPatch, + readVersionsFromTree, + pypiVersionExists, + hasSuccessfulReleaseToday, + utcDayStart, + evaluateReleasePreflight, + lastGreenCiSha, + ACTIVE_RELEASE_STATUSES, + PACKAGE_PATHS, +}; diff --git a/.github/workflows/auto-pr-comment.yml b/.github/workflows/auto-pr-comment.yml index 9038726..3f80619 100644 --- a/.github/workflows/auto-pr-comment.yml +++ b/.github/workflows/auto-pr-comment.yml @@ -15,12 +15,32 @@ on: pull_request_review: types: [submitted] pull_request: - types: [opened] + types: [opened, synchronize] + pull_request_target: + types: [synchronize] + schedule: + - cron: '30 3,9,15,21 * * *' + workflow_dispatch: + inputs: + pr_number: + description: 'PR number for manual FINAL (leave empty to sweep all open PRs)' + required: false + type: number + +permissions: + contents: read + issues: write + pull-requests: write env: MAX_POLL_ATTEMPTS: 20 POLL_DELAY_MS: 30000 CLAUDE_TRIGGER_LOGINS: '["MervinPraison","github-actions[bot]"]' + # GH_TOKEN (user PAT) posts as MervinPraison for @copilot; fallback to github.token for @claude + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + CLAUDE_UI_SCOPE: 'SCOPE: Focus ONLY on PraisonAI-Plugins (src/praisonai_plugins, tests). Do not expand into praisonaiagents or the monorepo unless the issue explicitly requires it.' + CLAUDE_FINAL_PHASES_COMPACT: "**Phase 1: Review per AGENTS.md** (lazy imports, backward compat, import-time <200ms; SDK/product value gate — genuine plugin lifecycle value, correct plugin vs SDK vs Tools layering, no scope creep)\n**Phase 2: FIX valid issues** found by prior reviewers — push to THIS branch\n**Phase 3: Final verdict** — approve PR if ready, else request changes" + CLAUDE_FINAL_PHASES_DETAILED: "**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions (lazy imports, import-time <200ms)\n4. SDK/product value: review whether the change genuinely adds value — correct plugin layering (lifecycle hooks/guardrails/sandbox backends here; agent tools belong in PraisonAI-Tools; core SDK in praisonaiagents). Reject scope creep; request changes or recommend closing if value is unclear\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items" jobs: # ================================================================ @@ -31,7 +51,7 @@ jobs: github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]' && - contains(github.event.comment.body, 'summary by coderabbit') + contains(github.event.comment.body, 'summarize by coderabbit') runs-on: ubuntu-latest permissions: pull-requests: write @@ -43,7 +63,7 @@ jobs: id: trigger uses: actions/github-script@v7 with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const comments = await github.rest.issues.listComments({ issue_number: context.issue.number, @@ -86,7 +106,7 @@ jobs: id: trigger uses: actions/github-script@v7 with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const comments = await github.rest.issues.listComments({ issue_number: context.issue.number, @@ -112,6 +132,90 @@ jobs: core.setOutput('triggered', 'true'); core.setOutput('pr_number', context.issue.number.toString()); + # ================================================================ + # FALLBACK: Claude trigger for cases where Copilot chain fails/skips + # Ensures Claude always runs after sufficient review time + # ================================================================ + claude-fallback-timeout: + if: | + github.event_name == 'pull_request' && + github.event.action == 'opened' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + issues: write + pull-requests: write + steps: + - name: Wait for review window (15 min) + run: sleep 900 + + - name: Check if Claude already triggered + id: check_claude + uses: actions/github-script@v7 + env: + TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const pr = parseInt(process.env.PR_NUMBER, 10); + const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS); + const comments = await github.rest.issues.listComments({ + issue_number: pr, + owner: context.repo.owner, + repo: context.repo.repo + }); + const alreadyPosted = comments.data.some(c => + c.body.includes('@claude') && triggerLogins.includes(c.user.login) + ); + core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false'); + core.setOutput('comments_count', comments.data.length.toString()); + + const botReviews = comments.data.filter(c => + ['coderabbitai[bot]', 'qodo-code-review[bot]', 'gemini-code-assist[bot]', + 'copilot-swe-agent', 'Copilot', 'greptile-apps[bot]'].some(bot => + c.user.login.toLowerCase().includes(bot.toLowerCase()) + ) + ); + core.setOutput('review_count', botReviews.length.toString()); + + - name: Aggregate reviews and trigger Claude + if: steps.check_claude.outputs.already_triggered != 'true' + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const pr = parseInt(process.env.PR_NUMBER, 10); + const comments = await github.rest.issues.listComments({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, per_page: 100}); + const reviews = {coderabbit: [], qodo: [], gemini: [], copilot: [], greptile: [], others: []}; + comments.data.forEach(c => { + const login = c.user.login.toLowerCase(); + const body = c.body.substring(0, 500); + if (login.includes('coderabbit')) reviews.coderabbit.push(body); + else if (login.includes('qodo')) reviews.qodo.push(body); + else if (login.includes('gemini')) reviews.gemini.push(body); + else if (login.includes('copilot')) reviews.copilot.push(body); + else if (login.includes('greptile')) reviews.greptile.push(body); + else if (c.user.type === 'Bot') reviews.others.push(body); + }); + const summaryParts = []; + if (reviews.coderabbit.length) summaryParts.push('CodeRabbit: ' + reviews.coderabbit.length + ' comments'); + if (reviews.qodo.length) summaryParts.push('Qodo: ' + reviews.qodo.length + ' comments'); + if (reviews.gemini.length) summaryParts.push('Gemini: ' + reviews.gemini.length + ' comments'); + if (reviews.copilot.length) summaryParts.push('Copilot: ' + reviews.copilot.length + ' comments'); + if (reviews.greptile.length) summaryParts.push('Greptile: ' + reviews.greptile.length + ' comments'); + const reviewSummary = summaryParts.length ? summaryParts.join(' | ') : 'No bot reviews detected'; + const scope = process.env.CLAUDE_UI_SCOPE; + const phases = process.env.CLAUDE_FINAL_PHASES_COMPACT; + const claudeBody = '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL prior reviewer comments. ' + reviewSummary + '.\n\n' + + 'Review Context: ' + comments.data.length + ' total comments, ' + + (reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' + + phases; + await github.rest.issues.createComment({issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody}); + console.log('Claude fallback triggered on PR #' + pr + ' with ' + reviewSummary); + # ================================================================ # Claude FINAL review — polls for Copilot's response, then triggers # This runs IN-BAND under the PAT context (MervinPraison), avoiding @@ -129,6 +233,10 @@ jobs: issues: write pull-requests: write steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Wait for Copilot to respond (poll) id: poll uses: actions/github-script@v7 @@ -137,7 +245,7 @@ jobs: MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }} POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }} with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const pr = parseInt(process.env.PR_NUMBER, 10); const owner = context.repo.owner; @@ -206,7 +314,7 @@ jobs: PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }} TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }} with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const pr = parseInt(process.env.PR_NUMBER, 10); const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS); @@ -226,17 +334,33 @@ jobs: env: PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }} with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const pr = parseInt(process.env.PR_NUMBER, 10); + const scope = process.env.CLAUDE_UI_SCOPE; + const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED; await github.rest.issues.createComment({ issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, - body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items' + body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases }); console.log(`Claude triggered on PR #${pr}`); + - name: Sync pipeline status labels + if: steps.check_claude.outputs.already_triggered != 'true' + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }} + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const path = require('path'); + const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js')); + const pr = parseInt(process.env.PR_NUMBER, 10); + await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core); + await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core); + # ================================================================ # Claude after Qodo fallback path (same polling approach) # ================================================================ @@ -260,7 +384,7 @@ jobs: MAX_POLL_ATTEMPTS: ${{ env.MAX_POLL_ATTEMPTS }} POLL_DELAY_MS: ${{ env.POLL_DELAY_MS }} with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const pr = parseInt(process.env.PR_NUMBER, 10); const owner = context.repo.owner; @@ -310,7 +434,7 @@ jobs: PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }} TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }} with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const pr = parseInt(process.env.PR_NUMBER, 10); const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS); @@ -330,14 +454,16 @@ jobs: env: PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }} with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const pr = parseInt(process.env.PR_NUMBER, 10); + const scope = process.env.CLAUDE_UI_SCOPE; + const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED; await github.rest.issues.createComment({ issue_number: pr, owner: context.repo.owner, repo: context.repo.repo, - body: '@claude You are the FINAL architecture reviewer. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items' + body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n' + phases }); console.log(`Claude triggered on PR #${pr}`); @@ -365,7 +491,7 @@ jobs: env: TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }} with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS); const comments = await github.rest.issues.listComments({ @@ -382,64 +508,302 @@ jobs: if: steps.check_claude.outputs.already_triggered != 'true' uses: actions/github-script@v7 with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | + const scope = process.env.CLAUDE_UI_SCOPE; + const phases = process.env.CLAUDE_FINAL_PHASES_DETAILED; await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '@claude You are the Lead Engineer. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. If blocking issues remain, request changes / leave clear action items' + body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + scope + ' Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n' + phases }); # ================================================================ - # BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default. - # Trigger them explicitly. Do NOT trigger Copilot here — it will - # be triggered by copilot-after-coderabbit/qodo when they finish. + # Recovery: re-post FINAL @claude after push or when chain missed # ================================================================ - bot-pr-trigger-reviews: + claude-review-recovery: if: | - github.event_name == 'pull_request' && - github.event.action == 'opened' && - ( - github.event.pull_request.user.login == 'github-actions[bot]' || - github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' || - github.event.pull_request.user.type == 'Bot' - ) + github.event_name == 'pull_request_target' && + github.event.action == 'synchronize' runs-on: ubuntu-latest permissions: - pull-requests: write + issues: write + pull-requests: read steps: - - name: Trigger CodeRabbit, Qodo, and Gemini reviews + - uses: actions/checkout@v4 + + - name: Re-post FINAL Claude review when stale or missing uses: actions/github-script@v7 with: - github-token: ${{ secrets.GH_TOKEN }} + github-token: ${{ env.GH_TOKEN }} script: | + const path = require('path'); + const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js')); + const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js')); const pr = context.payload.pull_request.number; const owner = context.repo.owner; const repo = context.repo.repo; - // 1. Trigger CodeRabbit review - await github.rest.issues.createComment({ - issue_number: pr, owner, repo, - body: '@coderabbitai review' + const comments = await mergeGate.listAllComments(github, owner, repo, pr); + const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr)) + || context.payload.pull_request.updated_at; + const headPusher = context.payload.sender?.login || null; + + const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments); + const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt); + + if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) { + core.info('Recent @claude within 35min and FINAL not stale — skip recovery'); + return; + } + + if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) { + core.info('Claude workflow in progress — skip recovery'); + return; + } + + const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY; + const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }; + const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 }; + + if (hasFinal && isStale) { + const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher); + if (gate.skip) { + core.info(`Skip stale-FINAL recovery: ${gate.reason}`); + return; + } + const result = await chain.maybeTriggerClaudeFinal( + github, owner, repo, pr, finalBody, core, staleOpts + ); + if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`); + return; + } + + if (hasFinal) { + core.info('FINAL Claude review current on HEAD — skip recovery'); + return; + } + + const result = await chain.maybeTriggerClaudeFinal( + github, owner, repo, pr, finalBody, core, missingOpts + ); + if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`); + + - name: Sync pipeline status labels + uses: actions/github-script@v7 + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const path = require('path'); + const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js')); + const pr = context.payload.pull_request.number; + await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core); + await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core); + + # Scheduled + manual: post missing or stale FINAL @claude on all open PRs + post-missing-claude-final: + if: | + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && !inputs.pr_number) + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read + steps: + - uses: actions/checkout@v4 + + - name: Post missing/stale FINAL @claude on open PRs + uses: actions/github-script@v7 + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const path = require('path'); + const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js')); + const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js')); + const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js')); + const owner = context.repo.owner; + const repo = context.repo.repo; + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, }); - console.log(`Triggered CodeRabbit for bot PR #${pr}`); + let posted = 0; + for (const pr of prs) { + if (pr.draft) continue; + const comments = await mergeGate.listAllComments(github, owner, repo, pr.number); + const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at; + const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments); + const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt); - // 2. Trigger Qodo review + if (hasFinal && !isStale) continue; + if (mergeGate.hasRecentClaudeTrigger(comments, 10)) { + core.info(`PR #${pr.number}: @claude within 10min — skip`); + continue; + } + if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) { + core.info(`PR #${pr.number}: Claude in progress — skip`); + continue; + } + if (hasFinal && isStale) { + const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt); + if (gate.skip) { + core.info(`PR #${pr.number}: skip stale — ${gate.reason}`); + continue; + } + } + + const opts = hasFinal && isStale + ? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 } + : { allowCopilotTimeout: true, optionalWaitMs: 0 }; + const result = await chain.maybeTriggerClaudeFinal( + github, owner, repo, pr.number, + mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts + ); + if (result.posted) { + posted++; + core.info(`Posted Claude FINAL on PR #${pr.number}`); + } + await ps.syncPipelineLabels(github, owner, repo, pr.number, core); + if (posted >= 10) break; + } + core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`); + + # ================================================================ + # BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT) + # Copilot ignores github-actions[bot] comments; bot PRs use this path. + # ================================================================ + claude-after-coderabbit-bot-pr: + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.user.login == 'coderabbitai[bot]' && + contains(github.event.comment.body, 'summarize by coderabbit') + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: read + steps: + - name: Trigger Claude final review on bot PR + uses: actions/github-script@v7 + env: + TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }} + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const pr = context.issue.number; + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr, + }); + const author = pull.user.login; + const isBot = author === 'github-actions[bot]' || + author === 'praisonai-triage-agent[bot]' || + pull.user.type === 'Bot'; + if (!isBot) { + console.log('Human PR — Copilot chain handles Claude trigger'); + return; + } + const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS); + const comments = await github.rest.issues.listComments({ + issue_number: pr, + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const alreadyPosted = comments.data.some(c => + c.body.includes('@claude') && triggerLogins.includes(c.user.login) + ); + if (alreadyPosted) { + console.log('Claude already triggered on PR #' + pr); + return; + } await github.rest.issues.createComment({ - issue_number: pr, owner, repo, - body: '/review' + issue_number: pr, + owner: context.repo.owner, + repo: context.repo.repo, + body: '@claude You are the FINAL architecture reviewer on this bot-authored PR. Push fixes directly to THIS branch (do NOT open a new PR). ' + process.env.CLAUDE_UI_SCOPE + ' Read ALL CodeRabbit/Qodo comments above.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT }); - console.log(`Triggered Qodo for bot PR #${pr}`); + console.log('Claude final review triggered on bot PR #' + pr); - // 3. Trigger Gemini review + # ================================================================ + # Manual: trigger Claude final review on any PR + # ================================================================ + claude-manual-dispatch: + if: github.event_name == 'workflow_dispatch' && inputs.pr_number + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Trigger Claude final review + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr_number }} + TRIGGER_LOGINS: ${{ env.CLAUDE_TRIGGER_LOGINS }} + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const pr = parseInt(process.env.PR_NUMBER, 10); + const triggerLogins = JSON.parse(process.env.TRIGGER_LOGINS); + const comments = await github.rest.issues.listComments({ + issue_number: pr, + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const alreadyPosted = comments.data.some(c => + c.body.includes('@claude') && triggerLogins.includes(c.user.login) + ); + if (alreadyPosted) { + console.log('Claude already triggered on PR #' + pr); + return; + } await github.rest.issues.createComment({ - issue_number: pr, owner, repo, - body: '@gemini review this PR' + issue_number: pr, + owner: context.repo.owner, + repo: context.repo.repo, + body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI-Plugins (not a fork), you are able to make modifications to this branch and push directly. ' + process.env.CLAUDE_UI_SCOPE + ' Read all prior reviewer comments.\n\n' + process.env.CLAUDE_FINAL_PHASES_COMPACT }); - console.log(`Triggered Gemini for bot PR #${pr}`); + console.log('Claude manually triggered on PR #' + pr); + + # ================================================================ + # BOT PRs: kick CodeRabbit + Qodo + # ================================================================ + bot-pr-trigger-reviews: + if: | + github.event_name == 'pull_request' && + github.event.action == 'opened' && + ( + github.event.pull_request.user.login == 'github-actions[bot]' || + github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' || + github.event.pull_request.user.type == 'Bot' + ) + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Trigger CodeRabbit and Qodo reviews + uses: actions/github-script@v7 + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const path = require('path'); + const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js')); + const pr = context.payload.pull_request.number; + await chain.kickReviewChain(github, context.repo.owner, context.repo.repo, pr, core); - // NOTE: @copilot is NOT triggered here. - // It will be triggered by copilot-after-coderabbit or copilot-after-qodo - // once those bots finish, ensuring Copilot always reviews LAST. - console.log('Copilot will be triggered after CodeRabbit/Qodo complete.'); \ No newline at end of file + - name: Sync pipeline status labels + uses: actions/github-script@v7 + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const path = require('path'); + const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js')); + const pr = context.payload.pull_request.number; + await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core); + await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core); \ No newline at end of file diff --git a/.github/workflows/ci-failure-claude.yml b/.github/workflows/ci-failure-claude.yml new file mode 100644 index 0000000..fd0ee7f --- /dev/null +++ b/.github/workflows/ci-failure-claude.yml @@ -0,0 +1,102 @@ +name: Auto CI Failure → Claude + +# Detects internal PRs with failing CI and posts @claude with extracted failure details. + +on: + workflow_run: + workflows: [CI] + types: [completed] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + pr_number: + description: 'Optional PR number to process (default: scan blocked PRs)' + required: false + type: string + schedule: + - cron: '15 */6 * * *' + +concurrency: + group: ci-failure-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.event.workflow_run.id || github.run_id }} + cancel-in-progress: true + +env: + # PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows) + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + +permissions: + pull-requests: read + issues: write + actions: read + +jobs: + detect-and-trigger: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Detect CI failures and trigger Claude + uses: actions/github-script@v7 + with: + github-token: ${{ env.GH_TOKEN }} + script: | + const path = require('path'); + const ciFix = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/ci-failure-claude.js')); + const owner = context.repo.owner; + const repo = context.repo.repo; + const baseRepo = `${owner}/${repo}`; + + async function processPR(prNumber) { + return ciFix.maybeTriggerCiFixClaude(github, owner, repo, prNumber, core); + } + + let results = []; + + if (context.eventName === 'workflow_run') { + const wr = context.payload.workflow_run; + const result = await ciFix.processWorkflowRunFailure( + github, owner, repo, wr, core + ); + results.push({ event: 'workflow_run', workflow: wr.name, ...result }); + } else if (context.eventName === 'pull_request') { + results.push({ + pr: context.payload.pull_request.number, + ...(await processPR(context.payload.pull_request.number)), + }); + } else { + let prNumbers = []; + if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number) { + prNumbers = [Number(context.payload.inputs.pr_number)]; + } else { + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + for (const pr of prs) { + if (pr.draft) continue; + const headRepo = pr.head?.repo?.full_name; + if (headRepo && headRepo !== baseRepo) continue; + const { data: issue } = await github.rest.issues.get({ + owner, + repo, + issue_number: pr.number, + }); + const labels = issue.labels.map((l) => l.name); + if (labels.includes('pipeline/blocked:ci') || labels.includes(ciFix.CI_FIX_LABEL)) { + prNumbers.push(pr.number); + } + } + } + + for (const num of prNumbers) { + results.push({ pr: num, ...(await processPR(num)) }); + } + } + + core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```'); + await core.summary.write(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a33d5c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + python: + name: python + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install -e ".[dev]" + pip install ruff pytest-timeout + + - name: Lint with ruff + run: ruff check src/praisonai_plugins tests + + - name: Test with pytest + run: pytest tests/ -v --timeout=60 diff --git a/.github/workflows/claude-merge-gate.yml b/.github/workflows/claude-merge-gate.yml new file mode 100644 index 0000000..a6c85e3 --- /dev/null +++ b/.github/workflows/claude-merge-gate.yml @@ -0,0 +1,531 @@ +name: Claude PR Merge Gate + +# Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review). +# Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains. + +on: + schedule: + # Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist). + - cron: '*/10 * * * *' + workflow_dispatch: + inputs: + pr_number: + description: 'Optional PR number (scan all open internal PRs if empty)' + required: false + type: string + merge_method: + description: 'Merge method' + required: false + type: choice + default: merge + options: + - merge + - squash + - rebase + workflow_run: + workflows: + - Claude Assistant + - CI + types: + - completed + repository_dispatch: + types: + - claude-merge-gate + +concurrency: + group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }} + cancel-in-progress: false + +env: + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + MAX_CANDIDATES: 3 + DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }} + +permissions: + contents: read + pull-requests: read + issues: read + actions: read + +jobs: + auto-dispatch: + if: github.event_name == 'workflow_run' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + actions: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.CLAUDE_APP_ID }} + private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Dispatch merge gate when PR pipeline is ready + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const path = require('path'); + const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js')); + const wr = context.payload.workflow_run; + const owner = context.repo.owner; + const repo = context.repo.repo; + + if (wr.conclusion !== 'success') { + core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`); + return; + } + const allowed = ['Claude Assistant', 'CI']; + if (!allowed.includes(wr.name)) { + core.info(`Skip dispatch: workflow ${wr.name}`); + return; + } + + const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr); + if (!prNumber) { + core.info('Skip dispatch: could not resolve PR from workflow run'); + return; + } + + const fromClaude = wr.name === 'Claude Assistant'; + const check = await mergeGate.evaluatePipelineQuiescent( + github, owner, repo, prNumber, core, { + skipGlobalClaudeRunCheck: fromClaude, + skipRecentClaudeCooldown: fromClaude, + } + ); + if (!check.ready) { + core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`); + return; + } + + await github.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: 'claude-merge-gate', + client_payload: { pr_number: prNumber }, + }); + core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`); + + scan-candidates: + if: github.event_name != 'workflow_run' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: write + actions: read + id-token: write + outputs: + matrix: ${{ steps.scan.outputs.matrix }} + has_candidates: ${{ steps.scan.outputs.has_candidates }} + steps: + - uses: actions/checkout@v4 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.CLAUDE_APP_ID }} + private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Scan open PRs for merge gate eligibility + id: scan + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const path = require('path'); + const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js')); + const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js')); + const owner = context.repo.owner; + const repo = context.repo.repo; + const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10); + const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim(); + + const candidates = []; + const skipped = []; + + if (inputPr) { + const n = parseInt(inputPr, 10); + if (Number.isNaN(n)) { + throw new Error(`Invalid pr_number input: ${inputPr}`); + } + const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core); + if (result.ready) { + candidates.push({ pr_number: n, head_sha: result.headSha }); + } else { + skipped.push({ pr: n, reasons: result.reasons }); + } + } else { + const prNumbers = await mergeGate.listPrNumbersForMergeGateScan( + github, owner, repo, core + ); + const selected = await mergeGate.selectMergeGateCandidates( + github, owner, repo, prNumbers, maxCandidates, core + ); + candidates.push(...selected.candidates); + skipped.push(...selected.skipped); + } + + core.info('Skipped: ' + JSON.stringify(skipped)); + core.setOutput('matrix', JSON.stringify({ include: candidates })); + core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false'); + + core.summary.addRaw('## Merge gate scan\n'); + core.summary.addRaw(`Candidates: ${candidates.length}\n`); + core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```'); + await core.summary.write(); + + if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) { + const reasons = skipped[0].reasons; + const body = [ + '**Merge gate scan** — not eligible for auto-merge.', + '', + ...reasons.map((r) => `- \`${r}\``), + '', + '**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.', + '**Opt out:** label `no-auto-merge`.', + ].join('\n'); + await github.rest.issues.createComment({ + owner, repo, issue_number: skipped[0].pr, body, + }); + } else if (inputPr && candidates.length === 1) { + await github.rest.issues.createComment({ + owner, repo, issue_number: candidates[0].pr_number, + body: '**Merge gate scan** — eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.', + }); + } + + await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core); + + claude-assess: + needs: scan-candidates + if: needs.scan-candidates.outputs.has_candidates == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }} + permissions: + contents: read + pull-requests: read + issues: write + actions: read + id-token: write + steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.CLAUDE_APP_ID }} + private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Mark merge gate active + id: mark_active + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + core.setOutput('started_at', new Date().toISOString()); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ matrix.pr_number }}, + labels: ['claude-merge-gate-active'], + }); + + - name: Stash merge gate helpers + run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js + + - name: Fetch PR branch + env: + PR_NUMBER: ${{ matrix.pr_number }} + run: | + git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}" + git checkout "pr-${PR_NUMBER}" + + - name: Claude merge gate assessment (read-only) + id: assess + uses: ./.github/actions/claude-code-action + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + CLAUDE_CODE_SUBAGENT_MODEL: inherit + ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ steps.app-token.outputs.token }} + model: claude-opus-4-8 + trigger_phrase: '@claude-merge-gate-internal' + direct_prompt: | + You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}. + READ-ONLY: do not modify code, commit, push, rebase, or create branches. + + CRITICAL OUTPUT RULE: + Post exactly one PR comment via GitHub MCP containing either: + MERGE_GATE_VERDICT: APPROVE + or + MERGE_GATE_VERDICT: BLOCK + followed by a short rationale on the next lines. + + NEVER include @claude in any comment. NEVER mention merge conflict resolution. + + Review checklist: + 1. Read AGENTS.md and the PR diff on the current branch. + 2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run). + 3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep). + 4. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params. + 5. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required). + 6. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts. + 7. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}. + 8. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons. + + Do not approve or merge via GitHub UI/API — only post the verdict comment. + disallowed_tools: | + Bash + Edit + Write + Replace + allowed_tools: | + View + GlobTool + GrepTool + Read + timeout_minutes: 15 + + - name: Post fallback BLOCK if Opus did not comment + id: fallback_verdict + if: steps.assess.outcome != 'success' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const mergeGate = require('/tmp/merge-gate.js'); + const owner = context.repo.owner; + const repo = context.repo.repo; + const prNumber = ${{ matrix.pr_number }}; + + const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber); + const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString(); + const existing = mergeGate.findMergeGateVerdict( + ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true } + ); + if (existing) { + core.info(`Opus verdict already posted: ${existing}`); + return; + } + + const check = await mergeGate.evaluatePipelineQuiescent( + github, owner, repo, prNumber, core, { forMergeStep: true } + ); + const lines = [ + 'MERGE_GATE_VERDICT: BLOCK', + '', + 'Automated fallback — Opus merge gate assessment did not complete.', + ]; + if (check.ready) { + lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.'); + } else { + lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`)); + } + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, + body: lines.join('\n'), + }); + core.info('Posted fallback BLOCK — Opus verdict required'); + + - name: Require Opus MERGE_GATE_VERDICT + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const mergeGate = require('/tmp/merge-gate.js'); + const comments = await mergeGate.listAllComments( + github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }} + ); + const ctx = await mergeGate.loadPrContext( + github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }} + ); + const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString(); + const verdict = mergeGate.findMergeGateVerdict( + comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true } + ); + if (!verdict) { + core.setFailed( + 'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).' + ); + return; + } + core.info(`Opus merge gate verdict: ${verdict}`); + core.setOutput('verdict', verdict); + + - name: Remove merge gate active label + if: always() + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const prNumber = ${{ matrix.pr_number }}; + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'claude-merge-gate-active', + }); + core.info(`Removed claude-merge-gate-active from PR #${prNumber}`); + } catch (e) { + core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`); + } + + - name: Fail job if assessment failed + if: steps.assess.outcome == 'failure' + run: exit 1 + + merge-only: + needs: [scan-candidates, claude-assess] + if: | + always() && + needs.scan-candidates.outputs.has_candidates == 'true' && + needs.claude-assess.result == 'success' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }} + permissions: + contents: write + pull-requests: write + issues: write + actions: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.CLAUDE_APP_ID }} + private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Re-check gates and merge + uses: actions/github-script@v7 + env: + MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }} + SCAN_HEAD_SHA: ${{ matrix.head_sha }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const path = require('path'); + const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js')); + const owner = context.repo.owner; + const repo = context.repo.repo; + const prNumber = ${{ matrix.pr_number }}; + const scanHeadSha = process.env.SCAN_HEAD_SHA; + + const removeLabels = async () => { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active', + }); + } catch (e) { + core.info('Label claude-merge-gate-active already removed or missing'); + } + }; + + try { + const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber); + const comments = ctx.comments; + const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString(); + const verdict = mergeGate.findMergeGateVerdict( + comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true } + ); + if (verdict !== 'APPROVE') { + core.info(`Skip merge: verdict=${verdict || 'NONE'}`); + await removeLabels(); + return; + } + + const check = await mergeGate.evaluatePipelineQuiescent( + github, owner, repo, prNumber, core, { forMergeStep: true } + ); + if (!check.ready) { + core.info(`Skip merge: ${check.reasons.join(', ')}`); + await removeLabels(); + return; + } + + if (check.headSha !== scanHeadSha) { + core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} → ${check.headSha.slice(0, 7)}`); + await removeLabels(); + return; + } + + const method = process.env.MERGE_METHOD || 'merge'; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + let merged = false; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await github.rest.pulls.merge({ + owner, repo, pull_number: prNumber, merge_method: method, + }); + merged = true; + break; + } catch (e) { + const msg = (e.message || '').toLowerCase(); + if (attempt < 3 && msg.includes('base branch was modified')) { + core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`); + await sleep(8000); + const recheck = await mergeGate.evaluatePipelineQuiescent( + github, owner, repo, prNumber, core, { forMergeStep: true } + ); + if (!recheck.ready) { + core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`); + await removeLabels(); + return; + } + continue; + } + throw e; + } + } + if (!merged) { + core.info('Skip merge: exhausted retries'); + await removeLabels(); + return; + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'], + }); + + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, + body: [ + 'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).', + `Verdict: MERGE_GATE_VERDICT: APPROVE`, + `SHA: \`${check.headSha.slice(0, 7)}\``, + `Method: ${method}`, + ].join('\n'), + }); + + core.info(`Merged PR #${prNumber}`); + } finally { + await removeLabels(); + } diff --git a/.github/workflows/nightly-release-gate.yml b/.github/workflows/nightly-release-gate.yml new file mode 100644 index 0000000..1ee0c34 --- /dev/null +++ b/.github/workflows/nightly-release-gate.yml @@ -0,0 +1,129 @@ +name: Nightly Release Gate + +# Preflight before PyPI Release (patch bump). +# Primary: CI success on main (workflow_run). +# Backstop: nightly cron at 00:00 UTC. + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Run preflight only; do not dispatch pypi-release.yml' + required: false + default: false + type: boolean + workflow_run: + workflows: + - CI + types: + - completed + branches: + - main + +permissions: + contents: read + actions: write + +concurrency: + group: nightly-release-gate + cancel-in-progress: false + +jobs: + preflight: + if: | + github.event_name == 'workflow_dispatch' || + github.event_name == 'schedule' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.event == 'push' + ) + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + steps: + - name: Resolve trigger context + id: ctx + run: | + if [ "${{ github.event_name }}" = "workflow_run" ]; then + echo "checkout_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT" + echo "source=ci" >> "$GITHUB_OUTPUT" + echo "is_ci=true" >> "$GITHUB_OUTPUT" + else + echo "checkout_sha=main" >> "$GITHUB_OUTPUT" + echo "source=nightly" >> "$GITHUB_OUTPUT" + echo "is_ci=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ steps.ctx.outputs.checkout_sha }} + fetch-depth: 0 + + - name: Fetch main and tags + run: git fetch --tags origin main + + - name: Preflight — path changes, CI SHA, dedupe + id: preflight + uses: actions/github-script@v7 + env: + HEAD_SHA: ${{ steps.ctx.outputs.checkout_sha }} + IS_CI: ${{ steps.ctx.outputs.is_ci }} + with: + github-token: ${{ secrets.GH_TOKEN }} + script: | + const path = require('path'); + const rg = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/release-gate.js')); + const owner = context.repo.owner; + const repo = context.repo.repo; + const headSha = process.env.HEAD_SHA; + const isCi = process.env.IS_CI === 'true'; + + const result = await rg.evaluateReleasePreflight( + github, owner, repo, + { headSha, isCiTrigger: isCi, bump: 'patch' }, + core + ); + + core.setOutput('should_release', result.ready ? 'true' : 'false'); + core.setOutput('reason', result.reasons.join('; ')); + core.setOutput('head_sha', result.headSha || headSha); + core.setOutput('last_tag', result.lastTag || ''); + core.setOutput('target_version', result.targetVersion || ''); + + core.summary.addRaw('## Release gate preflight\n'); + core.summary.addRaw(`Ready: ${result.ready}\n`); + core.summary.addRaw(`Reason: ${result.reasons.join('; ')}\n`); + if (result.targetVersion) { + core.summary.addRaw(`Target: package==${result.targetVersion}\n`); + } + await core.summary.write(); + + if (!result.ready) { + core.info(`Skip release: ${result.reasons.join('; ')}`); + } + + - name: Dry run summary + if: inputs.dry_run == true + run: | + echo "Dry run — preflight only, no pypi-release dispatch." + echo "should_release=${{ steps.preflight.outputs.should_release }}" + echo "reason=${{ steps.preflight.outputs.reason }}" + + - name: Dispatch PyPI Release (patch) + if: steps.preflight.outputs.should_release == 'true' && inputs.dry_run != true + run: | + set -euo pipefail + gh workflow run pypi-release.yml \ + --repo "${GITHUB_REPOSITORY}" \ + --ref main \ + -f bump=patch \ + -f source=${{ steps.ctx.outputs.source }} \ + -f trigger_sha=${{ steps.preflight.outputs.head_sha }} \ + -f dry_run=false + echo "Dispatched pypi-release.yml (patch, source=${{ steps.ctx.outputs.source }})" + echo "Uses pypi-auto environment for ci/nightly patch releases." diff --git a/.github/workflows/pipeline-status-sync.yml b/.github/workflows/pipeline-status-sync.yml new file mode 100644 index 0000000..0a6e3bf --- /dev/null +++ b/.github/workflows/pipeline-status-sync.yml @@ -0,0 +1,42 @@ +name: Pipeline Status Sync + +# Sync pipeline/* stage and blocker labels on open internal PRs. + +on: + schedule: + - cron: '*/10 * * * *' + workflow_dispatch: + +permissions: + pull-requests: read + issues: write + actions: write + id-token: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.CLAUDE_APP_ID }} + private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Sync pipeline labels on open PRs + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const path = require('path'); + const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js')); + await ps.syncOpenPullRequests( + github, context.repo.owner, context.repo.repo, + { maxPrs: 30, dispatchMergeGate: true }, core + ); diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml new file mode 100644 index 0000000..f238f6e --- /dev/null +++ b/.github/workflows/pypi-release.yml @@ -0,0 +1,135 @@ +name: PyPI Release + +on: + workflow_dispatch: + inputs: + bump: + description: 'Version bump type' + required: true + default: patch + type: choice + options: [patch, minor, major] + dry_run: + description: 'Preview only; do not publish' + required: false + default: false + type: boolean + version: + description: 'Override version (empty = auto bump)' + required: false + default: '' + type: string + source: + description: 'Trigger source (audit trail)' + required: false + default: manual + type: string + trigger_sha: + description: 'SHA that triggered this release' + required: false + default: '' + type: string + +permissions: + contents: write + +concurrency: + group: pypi-release + cancel-in-progress: false + +jobs: + release: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: >- + ${{ + (inputs.source == 'ci' || inputs.source == 'nightly') && + inputs.bump == 'patch' && + 'pypi-auto' || 'pypi' + }} + env: + PACKAGE: praisonai-plugins + GH_TOKEN: ${{ secrets.GH_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.GH_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - uses: astral-sh/setup-uv@v4 + + - name: Configure git + run: | + git config user.name "MervinPraison" + git config user.email "454862+MervinPraison@users.noreply.github.com" + gh auth setup-git + + - name: Compute release version + id: versions + env: + BUMP: ${{ inputs.bump }} + VERSION_OVERRIDE: ${{ inputs.version }} + run: | + python <<'PY' + import os, re, sys + from pathlib import Path + bump = os.environ["BUMP"] + override = os.environ.get("VERSION_OVERRIDE", "").strip() + content = Path("pyproject.toml").read_text() + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + if not match: + sys.exit("Could not read version from pyproject.toml") + current = match.group(1) + maj, min_, patch = map(int, current.split(".")) + if override: + new_version = override + elif bump == "major": + new_version = f"{maj + 1}.0.0" + elif bump == "minor": + new_version = f"{maj}.{min_ + 1}.0" + else: + new_version = f"{maj}.{min_}.{patch + 1}" + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + fh.write(f"current_version={current}\nnew_version={new_version}\n") + PY + + - name: Bump pyproject.toml + if: inputs.dry_run != true + env: + CURRENT: ${{ steps.versions.outputs.current_version }} + NEW_VERSION: ${{ steps.versions.outputs.new_version }} + run: | + python -c " + from pathlib import Path + import os + p = Path('pyproject.toml') + p.write_text(p.read_text().replace(f'version = \"{os.environ[\"CURRENT\"]}\"', f'version = \"{os.environ[\"NEW_VERSION\"]}\"', 1)) + " + + - name: Publish to PyPI + if: inputs.dry_run != true + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }} + run: | + uv lock + uv build + uv publish + + - name: Commit, tag, and release + if: inputs.dry_run != true + env: + VERSION: ${{ steps.versions.outputs.new_version }} + run: | + TAG="v${VERSION}" + git add pyproject.toml uv.lock + git commit -m "Release ${TAG}" || exit 0 + git tag -f "${TAG}" + git pull --rebase origin main + git push origin main + git push --tags -f + gh release create "${TAG}" --title "praisonai-plugins ${TAG}" --notes "Release ${TAG}" --latest diff --git a/.github/workflows/sync-secrets-from-praisonai.yml b/.github/workflows/sync-secrets-from-praisonai.yml new file mode 100644 index 0000000..c4a0377 --- /dev/null +++ b/.github/workflows/sync-secrets-from-praisonai.yml @@ -0,0 +1,21 @@ +name: Sync Claude Secrets from PraisonAI + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + trigger-sync: + runs-on: ubuntu-latest + steps: + - name: Trigger PraisonAI secret sync + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run sync-secrets-to-aiui.yml \ + --repo MervinPraison/PraisonAI \ + --ref main \ + -f target_repo=MervinPraison/PraisonAI-Plugins + echo "Triggered secret sync → MervinPraison/PraisonAI-Plugins" From 467376786d27c74526cc9e15dacaf8df317a2769 Mon Sep 17 00:00:00 2001 From: MervinPraison Date: Wed, 8 Jul 2026 11:41:15 +0100 Subject: [PATCH 2/3] Fix pre-existing ruff lint errors on main. Unblocks CI introduced by the automation pipeline workflow. --- src/praisonai_plugins/integrations/slack_integration.py | 2 +- src/praisonai_plugins/observability/gateway_forensics.py | 2 +- src/praisonai_plugins/policies/strict_policy.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/praisonai_plugins/integrations/slack_integration.py b/src/praisonai_plugins/integrations/slack_integration.py index 3f061b0..e52737f 100644 --- a/src/praisonai_plugins/integrations/slack_integration.py +++ b/src/praisonai_plugins/integrations/slack_integration.py @@ -3,7 +3,7 @@ """ from praisonaiagents.plugins.plugin import Plugin, PluginInfo from praisonaiagents._logging import get_logger -from typing import Dict, Any, Optional +from typing import Dict, Any logger = get_logger(__name__) diff --git a/src/praisonai_plugins/observability/gateway_forensics.py b/src/praisonai_plugins/observability/gateway_forensics.py index a745e7a..feb6ec1 100644 --- a/src/praisonai_plugins/observability/gateway_forensics.py +++ b/src/praisonai_plugins/observability/gateway_forensics.py @@ -25,7 +25,7 @@ import signal import threading import time -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from praisonaiagents.plugins.plugin import Plugin, PluginInfo, PluginHook from praisonaiagents._logging import get_logger diff --git a/src/praisonai_plugins/policies/strict_policy.py b/src/praisonai_plugins/policies/strict_policy.py index 5db5faa..411a5f3 100644 --- a/src/praisonai_plugins/policies/strict_policy.py +++ b/src/praisonai_plugins/policies/strict_policy.py @@ -2,7 +2,7 @@ Policy Plugin for PraisonAI Agents. """ from praisonaiagents.plugins.plugin import Plugin, PluginInfo -from typing import Dict, Any, Optional +from typing import Optional class StrictTypingPolicyPlugin(Plugin): """ From 4b0b0bd6cbe6b334d6a65417d1d1a71f4a166696 Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:56:33 +0000 Subject: [PATCH 3/3] Harden automation scripts: optional chaining + tighter qodo marker Apply reviewer feedback (Gemini, Greptile): - merge-gate.js / ci-failure-claude.js: use c.user?.login and optional chaining on GraphQL/REST merge-state to avoid TypeError when user/head objects are null (deleted accounts, system actors, partial GraphQL errors, deleted head branch/fork). - release-gate.js: fix copy-paste "aiui version" error message. - bot-pr-review-chain.js: qodoKickPosted now matches full body trim() === '/review' instead of broad substring, preventing false positives from unrelated comments mentioning "/review". Co-authored-by: Mervin Praison --- .github/scripts/bot-pr-review-chain.js | 4 +++- .github/scripts/ci-failure-claude.js | 4 ++-- .github/scripts/merge-gate.js | 22 +++++++++++----------- .github/scripts/release-gate.js | 2 +- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/scripts/bot-pr-review-chain.js b/.github/scripts/bot-pr-review-chain.js index 6ce3157..e5f6293 100644 --- a/.github/scripts/bot-pr-review-chain.js +++ b/.github/scripts/bot-pr-review-chain.js @@ -36,7 +36,9 @@ function coderabbitKickPosted(comments) { } function qodoKickPosted(comments) { - return comments.some((c) => kickAuthored(c, '/review')); + return comments.some( + (c) => KICK_AUTHORS.has(c.user?.login) && (c.body || '').trim() === '/review' + ); } function chainKickPosted(comments) { diff --git a/.github/scripts/ci-failure-claude.js b/.github/scripts/ci-failure-claude.js index 16f18a0..34dbe1b 100644 --- a/.github/scripts/ci-failure-claude.js +++ b/.github/scripts/ci-failure-claude.js @@ -28,7 +28,7 @@ function isCiFixComment(comment) { function hasCiFixCommentForSha(comments, headSha) { const marker = shortSha(headSha).toLowerCase(); return comments.some((c) => { - if (!AUTO_ACTORS.includes(c.user.login)) return false; + if (!AUTO_ACTORS.includes(c.user?.login)) return false; if (!isCiFixComment(c)) return false; return (c.body || '').toLowerCase().includes(marker); }); @@ -38,7 +38,7 @@ function hasRecentCiFixComment(comments, headSha) { const cutoff = Date.now() - COOLDOWN_MS; const marker = shortSha(headSha).toLowerCase(); return comments.some((c) => { - if (!AUTO_ACTORS.includes(c.user.login)) return false; + if (!AUTO_ACTORS.includes(c.user?.login)) return false; if (!isCiFixComment(c)) return false; const body = (c.body || '').toLowerCase(); if (body.includes(marker)) return true; diff --git a/.github/scripts/merge-gate.js b/.github/scripts/merge-gate.js index 198aff3..9224327 100644 --- a/.github/scripts/merge-gate.js +++ b/.github/scripts/merge-gate.js @@ -51,7 +51,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function isFinalClaudeTriggerComment(c) { const body = (c.body || '').toLowerCase(); - if (!AUTO_ACTORS.includes(c.user.login)) return false; + if (!AUTO_ACTORS.includes(c.user?.login)) return false; if (!body.includes('@claude')) return false; if (body.includes('merge conflict')) return false; return body.includes('final architecture reviewer') || body.includes('lead engineer'); @@ -74,7 +74,7 @@ function isClaudeFinalReplyComment(c) { function hasRecentClaudeTrigger(comments, minutes = 35) { const cutoff = Date.now() - minutes * 60 * 1000; return comments.some((c) => { - if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false; + if (!CLAUDE_TRIGGER_LOGINS.includes(c.user?.login)) return false; if (isClaudeTriggerNoise(c)) return false; if (!(c.body || '').includes('@claude')) return false; return new Date(c.created_at).getTime() > cutoff; @@ -82,7 +82,7 @@ function hasRecentClaudeTrigger(comments, minutes = 35) { } function isConflictRebaseTriggerComment(c) { - if (!AUTO_ACTORS.includes(c.user.login)) return false; + if (!AUTO_ACTORS.includes(c.user?.login)) return false; const body = (c.body || '').toLowerCase(); return body.includes('@claude') && body.includes('merge conflict'); } @@ -185,7 +185,7 @@ function isStaleFinalAfterPush(comments, headPushedAt) { }); if (claudeRepliedAfterFinal) return false; const claudeSinceHead = comments.some((c) => { - if (!CLAUDE_TRIGGER_LOGINS.includes(c.user.login)) return false; + if (!CLAUDE_TRIGGER_LOGINS.includes(c.user?.login)) return false; if (isClaudeTriggerNoise(c)) return false; if (!(c.body || '').includes('@claude')) return false; return new Date(c.created_at).getTime() >= headTime - 60000; @@ -280,15 +280,15 @@ async function getMergeState(github, owner, repo, prNumber) { `; for (let attempt = 0; attempt < 3; attempt++) { const result = await github.graphql(query, { owner, repo, number: prNumber }); - const prGql = result.repository.pullRequest; + const prGql = result?.repository?.pullRequest; const status = (prGql?.mergeStateStatus || '').toUpperCase(); if (status && status !== 'UNKNOWN') { return { status, - isDraft: prGql.isDraft, - headRepo: prGql.headRef?.repository?.nameWithOwner, - headSha: prGql.headRefOid, - maintainerCanModify: prGql.maintainerCanModify === true, + isDraft: prGql?.isDraft, + headRepo: prGql?.headRef?.repository?.nameWithOwner, + headSha: prGql?.headRefOid, + maintainerCanModify: prGql?.maintainerCanModify === true, }; } if (attempt < 2) await sleep(10000); @@ -297,8 +297,8 @@ async function getMergeState(github, owner, repo, prNumber) { return { status: (pr.mergeable_state || '').toUpperCase(), isDraft: pr.draft, - headRepo: pr.head.repo?.full_name, - headSha: pr.head.sha, + headRepo: pr.head?.repo?.full_name, + headSha: pr.head?.sha, maintainerCanModify: pr.maintainer_can_modify === true, }; } diff --git a/.github/scripts/release-gate.js b/.github/scripts/release-gate.js index cf9a573..2e135e2 100644 --- a/.github/scripts/release-gate.js +++ b/.github/scripts/release-gate.js @@ -21,7 +21,7 @@ function readVersionsFromTree(root = '.') { const path = require('path'); const toml = fs.readFileSync(path.join(root, 'pyproject.toml'), 'utf8'); const match = toml.match(/^version\s*=\s*"([^"]+)"/m); - if (!match) throw new Error('Could not read aiui version from pyproject.toml'); + if (!match) throw new Error('Could not read version from pyproject.toml'); const current = match[1]; const target = bumpPatch(current); return { current, target, packageName: config.pypiPackageName };