diff --git a/.github/workflows/agent-pr-review.yml b/.github/workflows/agent-pr-review.yml new file mode 100644 index 00000000..ea69e2b2 --- /dev/null +++ b/.github/workflows/agent-pr-review.yml @@ -0,0 +1,34 @@ +name: Automated Agent PR Review + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + +jobs: + agent-review: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Run Agent Review + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_BASE_REF: ${{ github.base_ref }} + GITHUB_BASE_REPO_URL: ${{ github.event.pull_request.base.repo.clone_url }} + run: node scripts/automated-pr-review.mjs diff --git a/scripts/automated-pr-review.mjs b/scripts/automated-pr-review.mjs new file mode 100644 index 00000000..2b5d6e9c --- /dev/null +++ b/scripts/automated-pr-review.mjs @@ -0,0 +1,176 @@ +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\/dmDecryptor\.js$/ +]; + +function runCommand(command, args = [], options = {}) { + console.log(`\n> ${command} ${args.join(' ')}`); + return spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8', ...options }); +} + +function getChangedFiles() { + try { + const baseRef = process.env.GITHUB_BASE_REF || 'main'; + 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} --depth=1`, { stdio: 'ignore' }); + } catch (e) { + console.error(`Failed to fetch ${remote}/${baseRef}:`, e.message); + } + } else { + // Fallback for local testing or if env var missing + try { + execSync(`git fetch origin ${baseRef} --depth=1`, { stdio: 'ignore' }); + } 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.message); + // Fallback: try 2-dot diff if 3-dot failed (e.g. shallow clone issues) + try { + const baseRef = process.env.GITHUB_BASE_REF || 'main'; + 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.message); + return []; + } + } +} + +async function main() { + console.log('Starting Automated PR Review...'); + let commentBody = '## 🤖 Automated PR Review\n\n'; + let hasFailures = false; + let hasFormatChanges = false; + let microFixesStatus = ''; + + // 1. Install Dependencies + if (process.env.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(); + const sensitiveChanges = changedFiles.filter(file => + SENSITIVE_PATHS.some(pattern => pattern.test(file)) + ); + + 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 += '
Sensitive Files Touched\n\n'; + sensitiveChanges.forEach(f => commentBody += `- ${f}\n`); + commentBody += '\n
\n\n'; + } + + // 6. Post Comment (if in CI) + if (process.env.GITHUB_TOKEN && process.env.PR_NUMBER) { + + // 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 "agent@bitvid.network"'); + execSync('git add .'); + execSync('git commit -m "fix(ai): formatting (agent)"'); + execSync('git push'); + + commentBody += '> **✅ Micro-fixes applied:** Formatting fixes have been committed to this branch.\n\n'; + } catch (e) { + console.error('Failed to auto-commit/push:', e.message); + commentBody += '> **â„šī¸ Micro-fixes available:** Formatting issues were found, but I could not automatically commit the fixes (likely due to permissions on a fork). Please run `npm run format` locally and push.\n\n'; + } + } + + console.log('Posting comment to PR #' + process.env.PR_NUMBER); + try { + const gh = spawnSync('gh', ['pr', 'comment', process.env.PR_NUMBER, '-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); + } + + } else { + console.log('Not in CI or missing tokens. Printing report to stdout:'); + console.log('---------------------------------------------------'); + console.log(commentBody); + console.log('---------------------------------------------------'); + } + + if (hasFailures) { + process.exit(1); + } +} + +main();