From 658086622670cdc1c5174da53096da88c2d63f31 Mon Sep 17 00:00:00 2001 From: "Dina Berry (She/her)" Date: Sun, 5 Apr 2026 12:55:53 -0700 Subject: [PATCH] ci: smart PR nudge for stale PRs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/readiness-file-list.md | 7 + .changeset/scope-boundary-check.md | 8 + .changeset/smart-pr-nudge.md | 8 + .github/copilot-instructions.md | 4 + .github/workflows/squad-pr-nudge.yml | 184 +++++++++++++++++ .github/workflows/squad-repo-health.yml | 4 +- .github/workflows/squad-scope-check.yml | 31 +++ CONTRIBUTING.md | 2 + scripts/pr-readiness.mjs | 103 +++++++++- test/pr-readiness.test.ts | 249 +++++++++++++++++++++++- 10 files changed, 593 insertions(+), 7 deletions(-) create mode 100644 .changeset/readiness-file-list.md create mode 100644 .changeset/scope-boundary-check.md create mode 100644 .changeset/smart-pr-nudge.md create mode 100644 .github/workflows/squad-pr-nudge.yml create mode 100644 .github/workflows/squad-scope-check.yml diff --git a/.changeset/readiness-file-list.md b/.changeset/readiness-file-list.md new file mode 100644 index 000000000..5cc36c951 --- /dev/null +++ b/.changeset/readiness-file-list.md @@ -0,0 +1,7 @@ +--- +--- + +ci: add file list with line stats to PR readiness comment + +The PR readiness bot now shows changed files with per-file addition/deletion +counts, scope classification (Product/Infrastructure/Mixed), and totals. diff --git a/.changeset/scope-boundary-check.md b/.changeset/scope-boundary-check.md new file mode 100644 index 000000000..6f98a8bd3 --- /dev/null +++ b/.changeset/scope-boundary-check.md @@ -0,0 +1,8 @@ +--- +--- + +ci: scope boundary enforcement for repo-health PRs + +New CI check that fails repo-health PRs if they modify product source +code under packages/*/src/. Enforces separation between infrastructure +and product changes. diff --git a/.changeset/smart-pr-nudge.md b/.changeset/smart-pr-nudge.md new file mode 100644 index 000000000..2144528df --- /dev/null +++ b/.changeset/smart-pr-nudge.md @@ -0,0 +1,8 @@ +--- +--- + +ci: add smart PR nudge for stale PRs + +New workflow that runs on weekdays and posts actionable diagnoses on PRs +stale for 7+ days. Checks CI status, unresolved threads, missing reviews, +outdated branches, and draft status. Won't nudge the same PR twice per week. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5271c6c53..a99f6ced6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -146,6 +146,10 @@ Any PR that modifies files under `packages/squad-cli/src/` or `packages/squad-sd - The `changelog-gate` CI check will fail without this - Escape hatch: add the `skip-changelog` label (use sparingly) +## Automated PR Nudge + +The **PR Nudge** workflow (`.github/workflows/squad-pr-nudge.yml`) runs on weekdays at 2pm UTC and posts actionable comments on open PRs that have been stale for 7+ days. It diagnoses specific blockers — failing CI checks, unresolved review threads, missing approvals, outdated branches, and draft status — so PR authors know exactly what to do next. Draft PRs get a 14-day grace period. The workflow won't nudge the same PR more than once per week. + ## Decisions If you make a decision that affects other team members, write it to: diff --git a/.github/workflows/squad-pr-nudge.yml b/.github/workflows/squad-pr-nudge.yml new file mode 100644 index 000000000..5e7c55738 --- /dev/null +++ b/.github/workflows/squad-pr-nudge.yml @@ -0,0 +1,184 @@ +name: PR Nudge +on: + schedule: + - cron: '0 14 * * 1-5' # 2pm UTC weekdays (morning US Pacific) + workflow_dispatch: {} # manual trigger for testing + +permissions: + contents: read + pull-requests: write + checks: read + issues: read + +jobs: + nudge-stale-prs: + name: "Nudge Stale PRs" + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const STALE_DAYS = 7; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - STALE_DAYS); + + // Get all open PRs, oldest-updated first + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'asc', + per_page: 50 + }); + + for (const pr of prs.data) { + // Skip PRs updated recently + const lastPush = new Date(pr.updated_at); + if (lastPush > cutoff) continue; + + // Give draft PRs 14 days grace period instead of 7 + if (pr.draft) { + const created = new Date(pr.created_at); + const draftCutoff = new Date(); + draftCutoff.setDate(draftCutoff.getDate() - 14); + if (created > draftCutoff) continue; + } + + // Don't nudge the same PR more than once per week + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 5, + sort: 'created', + direction: 'desc' + }); + const recentNudge = comments.data.find(c => + c.user.login === 'github-actions[bot]' && + c.body.includes('') && + new Date(c.created_at) > cutoff + ); + if (recentNudge) continue; + + // Build the diagnosis — collect actionable items + const actions = []; + const daysSinceUpdate = Math.floor((Date.now() - lastPush) / (1000 * 60 * 60 * 24)); + + // 1. Check if still in draft + if (pr.draft) { + actions.push('📝 **Still in draft** — mark as "Ready for review" when you\'re done, or close if abandoned.'); + } + + // 2. Check CI status for failures + const checks = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: pr.head.sha, + per_page: 50 + }); + const failedChecks = checks.data.check_runs.filter(c => + c.conclusion === 'failure' + ).map(c => c.name); + if (failedChecks.length > 0) { + actions.push(`🔴 **${failedChecks.length} CI check(s) failing:** ${failedChecks.join(', ')}. Fix these first.`); + } + + // 3. Check for unresolved review threads (Copilot vs human) + const threads = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 50) { + nodes { + isResolved + isOutdated + comments(first: 1) { + nodes { author { login } } + } + } + } + } + } + } + `, { + owner: context.repo.owner, + repo: context.repo.repo, + number: pr.number + }); + const unresolvedThreads = threads.repository.pullRequest.reviewThreads.nodes + .filter(t => !t.isResolved && !t.isOutdated); + if (unresolvedThreads.length > 0) { + const copilotThreads = unresolvedThreads.filter(t => + t.comments.nodes[0]?.author?.login?.includes('copilot') + ); + const humanThreads = unresolvedThreads.length - copilotThreads.length; + const parts = []; + if (copilotThreads.length > 0) parts.push(`${copilotThreads.length} from Copilot`); + if (humanThreads > 0) parts.push(`${humanThreads} from reviewers`); + actions.push(`đŸ’Ŧ **${unresolvedThreads.length} unresolved review thread(s)** (${parts.join(', ')}). Address and resolve them.`); + } + + // 4. Check review state (changes requested vs approved vs none) + const reviews = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + const latestByUser = {}; + for (const r of reviews.data) { + if (r.state === 'COMMENTED') continue; + latestByUser[r.user.login] = r; + } + const changesRequested = Object.values(latestByUser).filter(r => r.state === 'CHANGES_REQUESTED'); + const approvals = Object.values(latestByUser).filter(r => r.state === 'APPROVED'); + if (changesRequested.length > 0) { + const reviewers = changesRequested.map(r => `@${r.user.login}`).join(', '); + actions.push(`🔄 **Changes requested** by ${reviewers}. Address their feedback and request re-review.`); + } else if (approvals.length === 0 && !pr.draft) { + actions.push('👀 **No approving reviews yet.** Request a review from a teammate.'); + } + + // 5. Check if branch is behind base + const comparison = await github.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base: pr.head.sha, + head: pr.base.ref + }); + if (comparison.data.ahead_by > 10) { + actions.push(`âŦ‡ī¸ **${comparison.data.ahead_by} commits behind ${pr.base.ref}.** Rebase to pick up latest changes.`); + } + + // 6. If everything looks good and approved — it's ready to merge + if (actions.length === 0 && approvals.length > 0) { + actions.push('✅ **Looks ready to merge!** All checks pass, approved — just needs someone to click merge.'); + } + + // Fallback if no specific blockers found + if (actions.length === 0) { + actions.push('🤔 **No obvious blockers found** — but this PR has been quiet. Is it still active?'); + } + + // Post the nudge comment + const body = [ + '', + `👋 **Friendly nudge** — this PR has had no activity for **${daysSinceUpdate} days**.`, + '', + '**What needs attention:**', + ...actions.map(a => `- ${a}`), + '', + '---', + '*If this PR is abandoned, please close it. If it\'s blocked on something external, leave a comment so the team knows.*', + '*This is an automated check that runs on weekdays. It won\'t nudge the same PR more than once per week.*' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: body + }); + + core.info(`Nudged PR #${pr.number}: ${pr.title} (${daysSinceUpdate} days stale, ${actions.length} action items)`); + } diff --git a/.github/workflows/squad-repo-health.yml b/.github/workflows/squad-repo-health.yml index a559fc353..4fd722927 100644 --- a/.github/workflows/squad-repo-health.yml +++ b/.github/workflows/squad-repo-health.yml @@ -113,7 +113,7 @@ jobs: # ─── Architectural Review (INFORMATIONAL) ─────────────────────────── architectural-review: - name: Architectural Review + name: Architectural Review — Structure & Design Rules runs-on: ubuntu-latest timeout-minutes: 5 if: github.actor != 'dependabot[bot]' @@ -150,7 +150,7 @@ jobs: # ─── Security Review (INFORMATIONAL) ──────────────────────────────── security-review: - name: Security Review + name: Security Review — Permissions & Secrets runs-on: ubuntu-latest timeout-minutes: 5 if: github.actor != 'dependabot[bot]' diff --git a/.github/workflows/squad-scope-check.yml b/.github/workflows/squad-scope-check.yml new file mode 100644 index 000000000..477dc19c5 --- /dev/null +++ b/.github/workflows/squad-scope-check.yml @@ -0,0 +1,31 @@ +name: Scope Check +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + +permissions: + pull-requests: read + contents: read + +jobs: + scope-boundary: + name: "Scope Boundary" + runs-on: ubuntu-latest + if: >- + startsWith(github.head_ref, 'repo-health/') || + contains(github.event.pull_request.labels.*.name, 'repo-health') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for product code in repo-health PR + run: | + PRODUCT_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'packages/squad-cli/src/' 'packages/squad-sdk/src/') + if [ -n "$PRODUCT_FILES" ]; then + echo "::error::Repo-health PRs must not modify product source code." + echo "::error::The following product files were changed:" + echo "$PRODUCT_FILES" | while read -r f; do echo "::error:: - $f"; done + echo "::error::Move product changes to a separate PR." + exit 1 + fi + echo "✅ No product source files in this repo-health PR." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d64591dbc..ff58fd677 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,6 +170,8 @@ An automated readiness check runs on every PR and posts a checklist comment. Add | **No merge conflicts** | Resolve any conflicts with the target branch | | **CI passing** | All CI checks (build, test, lint) must be green | +The readiness comment also includes a **file list with line stats** — each changed file is shown with per-file addition/deletion counts, a scope classification (Product/Infrastructure/Mixed), and totals. This helps reviewers quickly gauge PR size and impact. + The readiness check is **informational** — it helps you self-serve before a human reviewer looks at your PR. It automatically re-runs after Squad CI completes, so the checklist stays up to date without manual intervention. See `.github/PR_REQUIREMENTS.md` for the full requirements spec. ## Code Style & Conventions diff --git a/scripts/pr-readiness.mjs b/scripts/pr-readiness.mjs index 20ca9d851..7b49cbdd5 100644 --- a/scripts/pr-readiness.mjs +++ b/scripts/pr-readiness.mjs @@ -246,6 +246,86 @@ export function checkCIStatus(checkRuns, statuses) { return { pass: true, detail: 'All checks passing' }; } +// --------------------------------------------------------------------------- +// Scope classification +// --------------------------------------------------------------------------- + +/** + * Classify the PR scope based on changed files. + * @param {Array<{ filename: string }>} files + * @returns {{ label: string, emoji: string }} + */ +export function classifyScope(files) { + const hasProduct = (files || []).some((f) => SOURCE_PATTERN.test(f.filename)); + const hasInfra = (files || []).some((f) => !SOURCE_PATTERN.test(f.filename)); + + if (hasProduct && hasInfra) { + return { label: 'Mixed (product + infrastructure)', emoji: 'đŸ“Ļ🔧' }; + } + if (hasProduct) { + return { label: 'Product', emoji: 'đŸ“Ļ' }; + } + return { label: 'Infrastructure', emoji: '🔧' }; +} + +// --------------------------------------------------------------------------- +// File list builder +// --------------------------------------------------------------------------- + +/** Maximum files shown in the file list before truncation. */ +export const MAX_FILE_LIST = 50; + +/** + * Sanitize a filename for safe inclusion in a markdown table cell. + * Escapes pipe characters, replaces backticks, and collapses newlines. + * @param {string} name + * @returns {string} + */ +export function sanitizeFilename(name) { + return name + .replace(/\|/g, '\\|') + .replace(/`/g, "'") + .replace(/[\r\n]+/g, ' '); +} + +/** + * Build a markdown section listing changed files with per-file line stats. + * @param {Array<{ filename: string, additions?: number, deletions?: number }>} files + * @returns {string} + */ +export function buildFileList(files) { + if (!files || files.length === 0) { + return ''; + } + + const totalAdded = files.reduce((sum, f) => sum + (f.additions || 0), 0); + const totalDeleted = files.reduce((sum, f) => sum + (f.deletions || 0), 0); + + const displayed = files.slice(0, MAX_FILE_LIST); + const truncated = files.length > MAX_FILE_LIST; + + const rows = displayed.map((f) => { + const added = f.additions || 0; + const deleted = f.deletions || 0; + return `| \`${sanitizeFilename(f.filename)}\` | +${added} −${deleted} |`; + }); + + if (truncated) { + const remaining = files.length - MAX_FILE_LIST; + rows.push(`| ... | **+${remaining} more files** | |`); + } + + return [ + `### Files Changed (${files.length} file${files.length === 1 ? '' : 's'}, +${totalAdded} −${totalDeleted})`, + '', + '| File | +/− |', + '|------|-----|', + ...rows, + '', + `**Total: +${totalAdded} −${totalDeleted}**`, + ].join('\n'); +} + // --------------------------------------------------------------------------- // Checklist markdown builder // --------------------------------------------------------------------------- @@ -257,9 +337,10 @@ export function checkCIStatus(checkRuns, statuses) { * @param {string} repo * @param {string} baseRef * @param {string} [headSha] — commit SHA that triggered the check + * @param {Array<{ filename: string, additions?: number, deletions?: number }>} [files] * @returns {string} */ -export function buildChecklist(checks, owner, repo, baseRef, headSha) { +export function buildChecklist(checks, owner, repo, baseRef, headSha, files) { const allPass = checks.every((c) => c.pass); const passCount = checks.filter((c) => c.pass).length; @@ -272,23 +353,37 @@ export function buildChecklist(checks, owner, repo, baseRef, headSha) { return `| ${icon} | **${c.name}** | ${c.detail} |`; }); - return [ + const scope = classifyScope(files); + + const sections = [ COMMENT_MARKER, '## đŸ›Ģ PR Readiness Check', ...(headSha ? [`> â„šī¸ This comment updates on each push. Last checked: commit \`${headSha.slice(0, 7)}\``] : []), '', + `**PR Scope:** ${scope.emoji} ${scope.label}`, + '', status, '', '| Status | Check | Details |', '|--------|-------|---------|', ...rows, + ]; + + const fileList = buildFileList(files); + if (fileList) { + sections.push('', fileList); + } + + sections.push( '', '---', '*This check runs automatically on every push. Fix any ❌ items and push again.*', `*See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/${baseRef}/CONTRIBUTING.md#pr-readiness-checklist) and [PR Requirements](https://github.com/${owner}/${repo}/blob/${baseRef}/.github/PR_REQUIREMENTS.md) for details.*`, - ].join('\n'); + ); + + return sections.join('\n'); } // --------------------------------------------------------------------------- @@ -492,7 +587,7 @@ export async function run({ env = process.env, fetchFn = globalThis.fetch } = {} checks.push({ name: 'CI passing', ...checkCIStatus(checkRuns, statusEntries) }); // ── Build checklist and upsert comment ── - const body = buildChecklist(checks, owner, repo, prBaseRef, prHeadSha); + const body = buildChecklist(checks, owner, repo, prBaseRef, prHeadSha, files); // Find existing comment const existingComments = await paginate( diff --git a/test/pr-readiness.test.ts b/test/pr-readiness.test.ts index 05333c723..c78425cfe 100644 --- a/test/pr-readiness.test.ts +++ b/test/pr-readiness.test.ts @@ -17,6 +17,10 @@ import { checkCopilotThreads, checkCIStatus, buildChecklist, + buildFileList, + sanitizeFilename, + MAX_FILE_LIST, + classifyScope, paginate, run, COMMENT_MARKER, @@ -495,6 +499,229 @@ describe('buildChecklist', () => { expect(body).toContain('| ✅ | **A** | OK |'); expect(body).toContain('| ❌ | **B** | Bad |'); }); + + it('includes file list when files are provided', () => { + const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; + const files = [ + { filename: 'src/index.ts', additions: 10, deletions: 3 }, + { filename: 'README.md', additions: 2, deletions: 1 }, + ]; + const body = buildChecklist(checks, 'o', 'r', 'dev', undefined, files); + expect(body).toContain('### Files Changed (2 files, +12 −4)'); + expect(body).toContain('| `src/index.ts` | +10 −3 |'); + expect(body).toContain('| `README.md` | +2 −1 |'); + expect(body).toContain('**Total: +12 −4**'); + }); + + it('omits file list when files are undefined', () => { + const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; + const body = buildChecklist(checks, 'o', 'r', 'dev'); + expect(body).not.toContain('Files Changed'); + }); + + it('omits file list when files array is empty', () => { + const checks = [{ name: 'Test', pass: true, detail: 'OK' }]; + const body = buildChecklist(checks, 'o', 'r', 'dev', undefined, []); + expect(body).not.toContain('Files Changed'); + }); +}); + +// --------------------------------------------------------------------------- +// buildFileList +// --------------------------------------------------------------------------- + +describe('buildFileList', () => { + it('returns empty string for null/undefined input', () => { + expect(buildFileList(null)).toBe(''); + expect(buildFileList(undefined)).toBe(''); + }); + + it('returns empty string for empty array', () => { + expect(buildFileList([])).toBe(''); + }); + + it('renders a single file with correct stats', () => { + const files = [{ filename: 'scripts/moderate-spam.mjs', additions: 142, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('### Files Changed (1 file, +142 −0)'); + expect(result).toContain('| `scripts/moderate-spam.mjs` | +142 −0 |'); + expect(result).toContain('**Total: +142 −0**'); + }); + + it('renders multiple files with totals', () => { + const files = [ + { filename: 'scripts/moderate-spam.mjs', additions: 142, deletions: 0 }, + { filename: 'test/scripts/moderate-spam.test.ts', additions: 98, deletions: 0 }, + { filename: '.github/workflows/squad-comment-moderation.yml', additions: 45, deletions: 0 }, + ]; + const result = buildFileList(files); + expect(result).toContain('### Files Changed (3 files, +285 −0)'); + expect(result).toContain('| `scripts/moderate-spam.mjs` | +142 −0 |'); + expect(result).toContain('| `test/scripts/moderate-spam.test.ts` | +98 −0 |'); + expect(result).toContain('| `.github/workflows/squad-comment-moderation.yml` | +45 −0 |'); + expect(result).toContain('**Total: +285 −0**'); + }); + + it('handles files with 0 additions and 0 deletions', () => { + const files = [{ filename: 'empty-change.ts', additions: 0, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('| `empty-change.ts` | +0 −0 |'); + expect(result).toContain('**Total: +0 −0**'); + }); + + it('handles files with both additions and deletions', () => { + const files = [ + { filename: 'src/refactored.ts', additions: 50, deletions: 30 }, + { filename: 'src/old.ts', additions: 0, deletions: 100 }, + ]; + const result = buildFileList(files); + expect(result).toContain('### Files Changed (2 files, +50 −130)'); + expect(result).toContain('| `src/refactored.ts` | +50 −30 |'); + expect(result).toContain('| `src/old.ts` | +0 −100 |'); + expect(result).toContain('**Total: +50 −130**'); + }); + + it('treats missing additions/deletions as 0', () => { + const files = [{ filename: 'binary-file.png' }]; + const result = buildFileList(files); + expect(result).toContain('| `binary-file.png` | +0 −0 |'); + expect(result).toContain('**Total: +0 −0**'); + }); + + it('uses singular "file" for single file', () => { + const files = [{ filename: 'one.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('1 file,'); + expect(result).not.toContain('1 files,'); + }); + + it('includes table headers', () => { + const files = [{ filename: 'a.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('| File | +/− |'); + expect(result).toContain('|------|-----|'); + }); + + it('truncates file list beyond MAX_FILE_LIST and shows summary row', () => { + const files = Array.from({ length: 60 }, (_, i) => ({ + filename: `src/file-${i}.ts`, + additions: 1, + deletions: 0, + })); + const result = buildFileList(files); + // Header should show the total count (60), not the truncated count + expect(result).toContain('### Files Changed (60 files, +60 −0)'); + // First 50 files should be present + expect(result).toContain('`src/file-0.ts`'); + expect(result).toContain('`src/file-49.ts`'); + // File 50 should NOT be present + expect(result).not.toContain('`src/file-50.ts`'); + // Summary row + expect(result).toContain('**+10 more files**'); + // Totals should include ALL files + expect(result).toContain('**Total: +60 −0**'); + }); + + it('does not truncate when file count equals MAX_FILE_LIST', () => { + const files = Array.from({ length: MAX_FILE_LIST }, (_, i) => ({ + filename: `src/file-${i}.ts`, + additions: 1, + deletions: 0, + })); + const result = buildFileList(files); + expect(result).not.toContain('more files'); + expect(result).toContain(`${MAX_FILE_LIST} files`); + }); + + it('sanitizes pipe characters in filenames', () => { + const files = [{ filename: 'path/with|pipe.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain("path/with\\|pipe.ts"); + expect(result).not.toContain('| path/with|pipe.ts'); + }); + + it('sanitizes backticks in filenames', () => { + const files = [{ filename: 'file`name.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain("file'name.ts"); + }); + + it('sanitizes newlines in filenames', () => { + const files = [{ filename: 'file\nname.ts', additions: 1, deletions: 0 }]; + const result = buildFileList(files); + expect(result).toContain('file name.ts'); + // The sanitized filename should not contain the literal newline + expect(result).not.toContain('file\nname.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// sanitizeFilename +// --------------------------------------------------------------------------- + +describe('sanitizeFilename', () => { + it('escapes pipe characters', () => { + expect(sanitizeFilename('a|b|c')).toBe('a\\|b\\|c'); + }); + + it('replaces backticks with single quotes', () => { + expect(sanitizeFilename('file`name`test')).toBe("file'name'test"); + }); + + it('replaces newlines with spaces', () => { + expect(sanitizeFilename('line1\nline2\r\nline3')).toBe('line1 line2 line3'); + }); + + it('handles all special characters together', () => { + expect(sanitizeFilename('a|b`c\nd')).toBe("a\\|b'c d"); + }); + + it('returns normal filenames unchanged', () => { + expect(sanitizeFilename('src/components/App.tsx')).toBe('src/components/App.tsx'); + }); +}); + +// --------------------------------------------------------------------------- +// classifyScope +// --------------------------------------------------------------------------- + +describe('classifyScope', () => { + it('returns Infrastructure for only infrastructure files', () => { + const files = [ + { filename: '.github/workflows/ci.yml' }, + { filename: 'scripts/build.mjs' }, + { filename: 'test/foo.test.ts' }, + ]; + const result = classifyScope(files); + expect(result.label).toBe('Infrastructure'); + expect(result.emoji).toBe('🔧'); + }); + + it('returns Product for only product source files', () => { + const files = [ + { filename: 'packages/squad-sdk/src/index.ts' }, + { filename: 'packages/squad-cli/src/main.ts' }, + ]; + const result = classifyScope(files); + expect(result.label).toBe('Product'); + expect(result.emoji).toBe('đŸ“Ļ'); + }); + + it('returns Mixed for both product and infrastructure files', () => { + const files = [ + { filename: 'packages/squad-sdk/src/index.ts' }, + { filename: 'scripts/build.mjs' }, + ]; + const result = classifyScope(files); + expect(result.label).toBe('Mixed (product + infrastructure)'); + expect(result.emoji).toBe('đŸ“Ļ🔧'); + }); + + it('returns Infrastructure for empty array', () => { + const result = classifyScope([]); + expect(result.label).toBe('Infrastructure'); + expect(result.emoji).toBe('🔧'); + }); }); // --------------------------------------------------------------------------- @@ -596,7 +823,7 @@ describe('run()', () => { commits: [{ sha: 'abc123' }], compare: { behind_by: 0 }, reviews: [{ user: { login: 'copilot-pull-request-reviewer' }, state: 'APPROVED', submitted_at: '2025-01-01T00:00:00Z' }], - files: [{ filename: '.changeset/feat.md' }], + files: [{ filename: '.changeset/feat.md', additions: 5, deletions: 0 }], pr: { mergeable: true }, checkRuns: { check_runs: [{ name: 'build', conclusion: 'success', status: 'completed' }] }, status: { statuses: [{ state: 'success' }] }, @@ -861,4 +1088,24 @@ describe('run()', () => { expect(draftCheck.pass).toBe(false); expect(draftCheck.detail).toContain('draft'); }); + + it('includes file list with line stats in upserted comment', async () => { + const mockFetch = createMockFetch({ + files: [ + { filename: 'src/index.ts', additions: 25, deletions: 10 }, + { filename: 'test/index.test.ts', additions: 50, deletions: 0 }, + ], + }); + await run({ env: baseEnv, fetchFn: mockFetch }); + + const postCall = mockFetch.mock.calls.find( + ([url, opts]) => opts && opts.method === 'POST' && url.includes('/comments'), + ); + expect(postCall).toBeDefined(); + const body = JSON.parse(postCall[1].body).body; + expect(body).toContain('### Files Changed (2 files, +75 −10)'); + expect(body).toContain('| `src/index.ts` | +25 −10 |'); + expect(body).toContain('| `test/index.test.ts` | +50 −0 |'); + expect(body).toContain('**Total: +75 −10**'); + }); });