Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/squad-impact.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Squad Impact Analysis

on:
pull_request_target:
branches: [dev]
types: [opened, synchronize, reopened]

# Security: Using pull_request_target so we get a write token for fork PRs.
# Scripts are checked out from the BASE branch (trusted), not the PR head.
# PR data is fetched read-only via gh CLI — no untrusted code is executed.
permissions:
contents: read
pull-requests: read
issues: write

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
impact:
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.actor != 'dependabot[bot]'
steps:
- name: Checkout scripts (base branch only)
uses: actions/checkout@v4
with:
sparse-checkout: |
scripts/analyze-impact.mjs
scripts/impact-utils/parse-diff.mjs
scripts/impact-utils/risk-scorer.mjs
scripts/impact-utils/report-generator.mjs
sparse-checkout-cone-mode: false

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22

- name: Run impact analysis
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: node scripts/analyze-impact.mjs ${{ github.event.pull_request.number }}

- name: Post impact report
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const marker = '<!-- squad-impact-report -->';
const report = fs.readFileSync('impact-report.md', 'utf8');
const body = `${marker}\n${report}`;
const prNumber = ${{ github.event.pull_request.number }};

// Upsert: find existing comment by marker, update or create.
// paginate() follows Link headers so we never miss an existing marker.
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});

const existing = comments.find(c => c.body && c.body.includes(marker));

if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
core.info('Updated existing impact report comment');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
core.info('Created new impact report comment');
}
126 changes: 126 additions & 0 deletions scripts/analyze-impact.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* PR Architectural Impact Analysis
*
* Usage: node scripts/analyze-impact.mjs <PR_NUMBER>
*
* Uses the gh CLI to fetch PR data, then:
* 1. Maps changed files → modules
* 2. Calculates a risk tier (LOW / MEDIUM / HIGH / CRITICAL)
* 3. Writes impact-report.md to cwd
* 4. Outputs JSON summary to stdout
*
* Uses only Node.js built-ins (no npm dependencies).
* Issue: #733
*/

import { execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { parseDiffNames, enrichFileStatuses } from './impact-utils/parse-diff.mjs';
import { calculateRisk } from './impact-utils/risk-scorer.mjs';
import { generateReport } from './impact-utils/report-generator.mjs';

// ── Module mapping ────────────────────────────────────────────────────────
// Directory prefix → module name (first match wins).
const MODULE_MAP = [
['packages/squad-sdk/', 'squad-sdk'],
['packages/squad-cli/', 'squad-cli'],
['.squad-templates/', 'templates'],
['.github/', 'ci-workflows'],
['scripts/', 'scripts'],
['.copilot/', 'copilot-config'],
['.squad/', 'squad-state'],
['test/', 'tests'],
['docs/', 'docs'],
];

// Patterns that flag a file as "critical" (config or entry points).
const CRITICAL_PATTERNS = [/package\.json$/, /tsconfig\.json$/, /index\.ts$/];

function mapFileToModule(filePath) {
for (const [prefix, mod] of MODULE_MAP) {
if (filePath.startsWith(prefix)) return mod;
}
return 'root';
}

function isCriticalFile(filePath) {
return CRITICAL_PATTERNS.some((p) => p.test(filePath));
}

// ── Main ──────────────────────────────────────────────────────────────────

const prNumberRaw = process.argv[2];
const prNumber = parseInt(prNumberRaw, 10);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
console.error('Usage: node scripts/analyze-impact.mjs <PR_NUMBER> (must be a positive integer)');
process.exit(1);
}

// Resolve repo slug (works in CI via env var, locally via gh).
const repoSlug =
process.env.GITHUB_REPOSITORY ||
execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
encoding: 'utf8',
}).trim();

// 1. Get changed files with statuses
let files;
try {
// --paginate can emit multiple JSON arrays; use --jq '.[]' to emit one
// JSON object per line, then parse each line individually.
const apiOutput = execSync(
`gh api repos/${repoSlug}/pulls/${prNumber}/files --paginate --jq '.[]'`,
{ encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 },
);
const apiFiles = apiOutput
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
files = enrichFileStatuses(apiFiles);
} catch {
// Fallback: name-only diff (no added/deleted distinction)
console.error('⚠ API file listing unavailable, falling back to gh pr diff --name-only');
const diffOutput = execSync(`gh pr diff ${prNumber} --name-only`, {
encoding: 'utf8',
});
files = parseDiffNames(diffOutput);
}

// 2. Map files → modules
const modules = {};
for (const filePath of files.all) {
const mod = mapFileToModule(filePath);
if (!modules[mod]) modules[mod] = [];
modules[mod].push(filePath);
}

// 3. Identify critical files
const criticalFiles = files.all.filter((f) => isCriticalFile(f));

// 4. Calculate risk tier
const risk = calculateRisk({
filesChanged: files.all.length,
filesDeleted: files.deleted.length,
modulesTouched: Object.keys(modules).length,
criticalFiles,
});

// 5. Generate markdown report and write to cwd
const report = generateReport({ prNumber, risk, modules, files, criticalFiles });
writeFileSync('impact-report.md', report, 'utf8');

// 6. Output JSON summary to stdout
const result = {
prNumber: Number(prNumber),
risk,
modules: Object.fromEntries(Object.entries(modules).map(([k, v]) => [k, v.length])),
filesChanged: files.all.length,
filesAdded: files.added.length,
filesModified: files.modified.length,
filesDeleted: files.deleted.length,
criticalFiles,
};

console.log(JSON.stringify(result, null, 2));
53 changes: 53 additions & 0 deletions scripts/impact-utils/parse-diff.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Parse PR diff output into structured file data.
* Uses only Node.js built-ins.
*
* Issue: #733
*/

/**
* Parse `gh pr diff --name-only` output into structured data.
* @param {string} diffOutput — raw output from `gh pr diff --name-only`
* @returns {{ added: string[], modified: string[], deleted: string[], all: string[] }}
*/
export function parseDiffNames(diffOutput) {
const all = diffOutput
.trim()
.split('\n')
.map((line) => line.trim())
.filter(Boolean);

// Name-only output has no status info; classify all as modified.
// Caller should use enrichFileStatuses() when API data is available.
return { added: [], modified: [...all], deleted: [], all };
}
Comment thread
diberry marked this conversation as resolved.

/**
* Build structured file data from the GitHub Pulls files API response.
* Each entry has {filename, status} where status is added|removed|modified|renamed|copied|changed.
* @param {Array<{filename: string, status: string}>} apiFiles
* @returns {{ added: string[], modified: string[], deleted: string[], all: string[] }}
*/
export function enrichFileStatuses(apiFiles) {
const added = [];
const modified = [];
const deleted = [];
const all = [];

for (const f of apiFiles) {
all.push(f.filename);
switch (f.status) {
case 'added':
added.push(f.filename);
break;
case 'removed':
deleted.push(f.filename);
break;
default: // modified, renamed, copied, changed
modified.push(f.filename);
break;
}
}

return { added, modified, deleted, all };
}
87 changes: 87 additions & 0 deletions scripts/impact-utils/report-generator.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Generate markdown impact report from analysis results.
* Uses only Node.js built-ins.
*
* Issue: #733
*/

const TIER_EMOJI = {
LOW: '🟢',
MEDIUM: '🟡',
HIGH: '🟠',
CRITICAL: '🔴',
};

/**
* Generate a markdown impact report.
*
* @param {{ prNumber: number|string, risk: {tier: string, factors: string[]}, modules: Record<string, string[]>, files: {added: string[], modified: string[], deleted: string[], all: string[]}, criticalFiles: string[] }} params
* @returns {string} Markdown report body
*/
export function generateReport({ prNumber, risk, modules, files, criticalFiles }) {
const emoji = TIER_EMOJI[risk.tier] || '⚪';
const lines = [];

lines.push(`## ${emoji} Impact Analysis — PR #${prNumber}`);
lines.push('');
lines.push(`**Risk tier:** ${emoji} **${risk.tier}**`);
lines.push('');

// Summary table
lines.push('### 📊 Summary');
lines.push('');
lines.push('| Metric | Count |');
lines.push('|--------|-------|');
lines.push(`| Files changed | ${files.all.length} |`);
lines.push(`| Files added | ${files.added.length} |`);
lines.push(`| Files modified | ${files.modified.length} |`);
lines.push(`| Files deleted | ${files.deleted.length} |`);
lines.push(`| Modules touched | ${Object.keys(modules).length} |`);
if (criticalFiles.length > 0) {
lines.push(`| Critical files | ${criticalFiles.length} |`);
}
lines.push('');

// Risk factors
lines.push('### 🎯 Risk Factors');
lines.push('');
for (const factor of risk.factors) {
lines.push(`- ${factor}`);
}
lines.push('');

// Module breakdown
lines.push('### 📦 Modules Affected');
lines.push('');
const moduleNames = Object.keys(modules).sort();
for (const mod of moduleNames) {
const modFiles = modules[mod];
lines.push(
`<details><summary><strong>${mod}</strong> (${modFiles.length} file${modFiles.length === 1 ? '' : 's'})</summary>`,
);
lines.push('');
for (const f of modFiles) {
lines.push(`- \`${f}\``);
}
lines.push('');
lines.push('</details>');
lines.push('');
}

// Critical files
if (criticalFiles.length > 0) {
lines.push('### ⚠️ Critical Files');
lines.push('');
for (const f of criticalFiles) {
lines.push(`- \`${f}\``);
}
lines.push('');
}

lines.push('---');
lines.push(
'*This report is generated automatically for every PR. See [#733](https://github.com/bradygaster/squad/issues/733) for details.*',
);

return lines.join('\n');
}
Loading
Loading