Files
bitvid/scripts/automated-pr-review.mjs
T
Claude ce72c4bbad fix(agents): replace gh CLI with curl for PR checking to prevent duplicate work
Agents do not have access to gh CLI, causing them to skip the pre-flight
PR check and collide on duplicate work. This replaces all gh-based PR
checking commands with curl + jq using the GitHub REST API, updates the
task claiming protocol to use branch push + started log file as the
primary lock mechanism, and updates automation scripts to use curl for
PR listing and git fetch for PR checkout.

Files updated:
- AGENTS.md (Section 12 coordination rules)
- CLAUDE.md (coordination rules)
- META_PROMPTS.md (daily + weekly meta prompts)
- daily-scheduler.md (pre-flight gate, task claiming, error handling)
- weekly-scheduler.md (pre-flight gate, task claiming, error handling)
- bitvid-pr-review-agent.md (PR enumeration + checkout)
- bitvid-ci-health-agent.md (CI run gathering)
- bitvid-weekly-synthesis-agent.md (PR metadata gathering)
- bitvid-changelog-agent.md (PR metadata verification)
- scripts/agent/review-open-prs.mjs (PR listing + checkout)
- scripts/automated-pr-review.mjs (--all mode PR listing + checkout)

https://claude.ai/code/session_01L5bzzGpZ6YTiLXcfqaL81G
2026-02-13 15:47:42 +00:00

344 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawnSync, execSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
// Configurations
const SENSITIVE_PATHS = [
/^js\/nostr\//,
/^js\/storage\//,
/^js\/auth\//,
/^js\/dmDecryptor\.js$/,
/^js\/magnetUtils\.js$/,
/^js\/nostr\/adapters\/nip07Adapter\.js$/,
/^js\/services\/storageService\.js$/,
/^js\/services\/attachmentService\.js$/,
/^js\/services\/hashtagPreferencesService\.js$/,
/^js\/utils\/storage\.js$/
];
function runCommand(command, args = [], options = {}) {
console.log(`\n> ${command} ${args.join(' ')}`);
const result = spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8', ...options });
if (result.error) {
throw new Error(`Failed to execute command "${command}": ${result.error.message}`);
}
return result;
}
function getChangedFiles(baseRef) {
try {
const baseUrl = process.env.GITHUB_BASE_REPO_URL;
let remote = 'origin';
if (baseUrl) {
remote = 'upstream';
try {
execSync(`git remote add upstream ${baseUrl}`, { stdio: 'ignore' });
} catch (e) {
// Ignore if remote already exists
}
console.log(`Fetching ${remote}/${baseRef} from ${baseUrl}...`);
try {
execSync(`git fetch ${remote} ${baseRef}`, { stdio: 'pipe', encoding: 'utf-8' });
} catch (e) {
console.error(`Failed to fetch ${remote}/${baseRef}:`, e.stderr || e.message);
}
} else {
// Fallback for local testing or if env var missing
try {
execSync(`git fetch origin ${baseRef} --depth=1`, { stdio: 'pipe', encoding: 'utf-8' });
} catch (e) {
// Ignore
}
}
// We want the files changed in this PR.
// Use 3-dot diff to compare HEAD against the merge base with upstream
const cmd = `git diff --name-only ${remote}/${baseRef}...HEAD`;
console.log(`Running diff: ${cmd}`);
const output = execSync(cmd, { encoding: 'utf-8' });
return output.split('\n').filter(Boolean).map(f => f.trim());
} catch (error) {
console.error('Error getting changed files:', error.stderr || error.message);
// Fallback: try 2-dot diff if 3-dot failed (e.g. shallow clone issues)
try {
const remote = process.env.GITHUB_BASE_REPO_URL ? 'upstream' : 'origin';
const cmd = `git diff --name-only ${remote}/${baseRef} HEAD`;
console.log(`Retrying with 2-dot diff: ${cmd}`);
const output = execSync(cmd, { encoding: 'utf-8' });
return output.split('\n').filter(Boolean).map(f => f.trim());
} catch (e) {
console.error('Fallback diff failed:', e.stderr || e.message);
return [];
}
}
}
function checkReleaseChannel(changedFiles, baseRef) {
const warnings = [];
if (baseRef === 'main') {
const criticalFiles = ['js/constants.js', 'config/instance-config.js'];
const modifiedCriticalFiles = changedFiles.filter(f => criticalFiles.includes(f));
if (modifiedCriticalFiles.length > 0) {
warnings.push(`⚠️ **Release Channel Warning**: This PR targets \`main\` but modifies the following critical configuration files: ${modifiedCriticalFiles.map(f => `\`${f}\``).join(', ')}.`);
warnings.push(`> **Guidance:** \`main\` is the production track. Ensure feature flags in \`js/constants.js\` are set to safe defaults (usually \`false\`) and \`config/instance-config.js\` has production-appropriate settings (e.g. \`IS_DEV_MODE\`).`);
}
} else if (baseRef === 'unstable') {
const criticalFiles = ['js/constants.js'];
const modifiedCriticalFiles = changedFiles.filter(f => criticalFiles.includes(f));
if (modifiedCriticalFiles.length > 0) {
warnings.push(`ℹ️ **Release Channel Info**: This PR targets \`unstable\`.`);
warnings.push(`> **Guidance:** \`unstable\` is the experimentation lane. If you are adding risky behavior, gate it behind feature flags in \`js/constants.js\` and document the toggle/rollback plan. See \`AGENTS.md\` for details.`);
}
}
return warnings;
}
async function reviewPR(prNumber, baseRef) {
console.log(`Starting Automated PR Review for PR #${prNumber} (base: ${baseRef})...`);
let commentBody = '## 🤖 Automated PR Review\n\n';
let hasFailures = false;
let hasFormatChanges = false;
// 1. Install Dependencies
if (process.env.CI || process.argv.includes('--force-ci')) {
runCommand('npm', ['ci']);
}
// 2. Format
console.log('Checking formatting...');
const formatRes = runCommand('npm', ['run', 'format']);
try {
const diff = execSync('git diff --name-only', { encoding: 'utf-8' });
if (diff.trim().length > 0) {
hasFormatChanges = true;
commentBody += '### 🎨 Formatting\n\n';
commentBody += 'Formatting changes detected.\n\n';
}
} catch (e) {
console.error('Error checking git diff for format:', e);
}
// 3. Lint
console.log('Running lint...');
const lintRes = runCommand('npm', ['run', 'lint']);
if (lintRes.status !== 0) {
hasFailures = true;
commentBody += '### ⚠️ Lint Warnings/Errors\n\n';
commentBody += '```\n' + lintRes.stdout + '\n' + lintRes.stderr + '\n```\n\n';
}
// 4. Unit Tests
console.log('Running unit tests...');
const testRes = runCommand('npm', ['run', 'test:unit']);
if (testRes.status !== 0) {
hasFailures = true;
commentBody += '### ❌ Test Failures\n\n';
commentBody += '```\n' + testRes.stdout + '\n' + testRes.stderr + '\n```\n\n';
commentBody += '> **Suggestion:** Inspect the stack trace above. Run `npm run test:unit` locally to reproduce.\n\n';
} else {
commentBody += '### ✅ Tests Passed\n\nAll unit tests passed.\n\n';
}
// 5. Security/Protocol Review Flags
const changedFiles = getChangedFiles(baseRef);
const sensitiveChanges = changedFiles.filter(file =>
SENSITIVE_PATHS.some(pattern => pattern.test(file))
);
// Release Channel Check
const channelWarnings = checkReleaseChannel(changedFiles, baseRef);
if (channelWarnings.length > 0) {
commentBody += '### 🚀 Release Channel Checks\n\n';
channelWarnings.forEach(w => commentBody += `${w}\n\n`);
}
if (sensitiveChanges.length > 0) {
commentBody += '### 🛡️ Guardrails\n\n';
commentBody += 'This PR modifies sensitive files. **Security and Protocol Review Required.**\n';
commentBody += '`requires-security-review` `requires-protocol-review`\n\n';
commentBody += '<details><summary>Sensitive Files Touched</summary>\n\n';
sensitiveChanges.forEach(f => commentBody += `- ${f}\n`);
commentBody += '\n</details>\n\n';
}
// Audit Log
commentBody += '### 📝 Audit Log\n\n';
commentBody += '| Check | Status | Details |\n';
commentBody += '| :--- | :--- | :--- |\n';
commentBody += `| **Formatting** | ${hasFormatChanges ? '⚠️ Changes' : '✅ Pass'} | ${hasFormatChanges ? 'Fixes applied/suggested' : 'No issues'} |\n`;
commentBody += `| **Lint** | ${lintRes.status === 0 ? '✅ Pass' : '⚠️ Warnings'} | ${lintRes.status === 0 ? '-' : 'See output above'} |\n`;
commentBody += `| **Unit Tests** | ${testRes.status === 0 ? '✅ Pass' : '❌ Fail'} | ${testRes.status === 0 ? '-' : 'See failures above'} |\n`;
commentBody += `| **Security** | ${sensitiveChanges.length > 0 ? '🛡️ Review Req' : '✅ Pass'} | ${sensitiveChanges.length > 0 ? `${sensitiveChanges.length} sensitive files` : 'No sensitive files'} |\n`;
commentBody += `| **Release Channel** | ${channelWarnings.length > 0 ? '⚠️ Warning' : '✅ Pass'} | ${channelWarnings.length > 0 ? 'Critical config modified' : 'Safe'} |\n`;
commentBody += '\n';
// 6. Post Comment (if in CI)
if (process.env.GITHUB_TOKEN && prNumber) {
// Verify gh CLI is available
console.log('Verifying GitHub CLI...');
runCommand('gh', ['--version']);
// Attempt Micro-fixes
if (hasFormatChanges) {
console.log('Attempting to commit formatting fixes...');
try {
execSync('git config user.name "bitvid-agent"');
execSync('git config user.email "[email protected]"');
execSync('git add .');
execSync('git commit -m "fix(ai): formatting (agent) suggested"');
execSync('git push');
commentBody += '> **✅ Micro-fixes applied:** Formatting fixes have been committed to this branch.\n\n';
} catch (e) {
console.error('Failed to push to PR branch:', e.message);
// Fallback: Create a new branch and open a PR
try {
console.log('Attempting to create a follow-up PR with fixes...');
const fixBranchName = `agent-fix/pr-${prNumber}-${Date.now()}`;
// Push the current HEAD (with fixes) to a new branch on origin
execSync(`git push origin HEAD:refs/heads/${fixBranchName}`);
const prTitle = `fix(ai): formatting fixes for PR #${prNumber}`;
const prBody = `This PR applies automated formatting fixes for PR #${prNumber}.`;
const ghCmd = ['pr', 'create', '--base', baseRef, '--head', fixBranchName, '--title', prTitle, '--body', prBody];
const ghRes = spawnSync('gh', ghCmd, { encoding: 'utf-8' });
if (ghRes.status === 0) {
const newPrUrl = ghRes.stdout.trim();
commentBody += `> **ℹ️ Micro-fixes available:** I could not push to your branch, but I have created a follow-up PR with the fixes: ${newPrUrl}\n\n`;
} else {
throw new Error(`gh pr create failed: ${ghRes.stderr}`);
}
} catch (fallbackError) {
console.error('Failed to create follow-up PR:', fallbackError.message);
commentBody += '> **ℹ️ Micro-fixes available:** Formatting issues were found. Please apply `npm run format` and re-run tests.\n\n';
}
}
}
console.log('Posting comment to PR #' + prNumber);
try {
const gh = spawnSync('gh', ['pr', 'comment', prNumber, '-F', '-'], {
input: commentBody,
stdio: ['pipe', 'inherit', 'inherit']
});
if (gh.status !== 0) {
console.error('Failed to post comment via gh CLI');
}
} catch (e) {
console.error('Error posting comment:', e);
}
}
// Always print the report to stdout
console.log('---------------------------------------------------');
console.log(`PR Review Report for PR #${prNumber}:`);
console.log(commentBody);
console.log('---------------------------------------------------');
if (hasFailures) {
console.log('Lint or Test failures detected. Reported to PR.');
// Do not throw error here, as we want the workflow to "pass" so that the comment persists
// and the developer can see the feedback. The actual build job (separate) will fail the PR checks.
}
}
async function main() {
try {
const isAll = process.argv.includes('--all');
if (isAll) {
console.log('Fetching all open PRs via GitHub API...');
// Get list of open PRs via curl (no gh dependency)
const result = spawnSync('curl', ['-s', 'https://api.github.com/repos/PR0M3TH3AN/bitvid/pulls?state=open&per_page=100'], { encoding: 'utf-8' });
if (result.error || result.status !== 0) {
throw new Error(`Failed to list PRs: ${result.stderr || result.error?.message}`);
}
const apiPRs = JSON.parse(result.stdout);
const prs = apiPRs.map(pr => ({
number: pr.number,
baseRefName: pr.base.ref,
headRefName: pr.head.ref
}));
console.log(`Found ${prs.length} open PRs.`);
let hasGlobalFailures = false;
// Save current branch to restore later
let originalBranch;
try {
originalBranch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
} catch (e) {
// Ignore
}
for (const pr of prs) {
try {
console.log(`\n=== Processing PR #${pr.number} ===`);
// Checkout PR via git fetch
runCommand('git', ['fetch', 'origin', `pull/${pr.number}/head:pr-${pr.number}`]);
runCommand('git', ['checkout', `pr-${pr.number}`]);
// Run Review
await reviewPR(pr.number, pr.baseRefName);
} catch (e) {
console.error(`❌ Failed to process PR #${pr.number}:`, e.message);
hasGlobalFailures = true;
// Continue to next PR
} finally {
// Clean up or reset if needed?
// Since we fetch and checkout each PR branch, we are on a different branch.
// Next iteration will checkout another branch.
}
}
if (originalBranch) {
console.log(`Restoring original branch: ${originalBranch}`);
try {
execSync(`git checkout ${originalBranch}`);
} catch(e) {
console.error('Failed to restore original branch');
}
}
if (hasGlobalFailures) {
process.exit(1);
}
} else {
const prNumber = process.env.PR_NUMBER;
const baseRef = process.env.GITHUB_BASE_REF || 'main';
if (prNumber) {
await reviewPR(prNumber, baseRef);
} else {
// Local run or manual invocation without specific PR env vars
console.log('No PR_NUMBER found and no --all flag. Running local review against ' + baseRef);
await reviewPR('local', baseRef);
}
}
} catch (error) {
console.error('Automated PR Review failed:', error.message);
process.exit(1);
}
}
main().catch(error => {
console.error('Unhandled error in main execution:', error);
process.exit(1);
});