# PR Description Generation Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add `guardrail pr-desc` command that generates a PR title, summary, and test plan from the current git diff and cached findings.

**Architecture:** Pure helper functions (truncateDiff, summarizeFindings, parseDescription) are exported for unit testing. `runPrDesc` orchestrates git diff collection, LLM call, and output. Wired as a new subcommand in `src/cli/index.ts`. No new dependencies.

**Tech Stack:** Node.js ESM, tsx, execSync for git/gh calls (input is never user-supplied; safe), existing ReviewEngine interface, existing loadCachedFindings pattern from findings-cache.ts.

---

### Task 1: Pure helper functions in `src/cli/pr-desc.ts`

**Files:**
- Create: `src/cli/pr-desc.ts`
- Test: `tests/pr-desc.test.ts`

- [ ] **Step 1: Write failing tests for pure helpers**

Create `tests/pr-desc.test.ts`:

```typescript
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { truncateDiff, summarizeFindings, parseDescription } from '../../src/cli/pr-desc.ts';
import type { Finding } from '../../src/core/findings/types.ts';

function makeFinding(id: string, severity: Finding['severity'] = 'warning'): Finding {
  return {
    id, source: 'static-rules', category: 'test', file: 'src/a.ts', line: 1, severity,
    message: `Finding ${id}`, protectedPath: false, createdAt: new Date().toISOString(),
  };
}

describe('truncateDiff', () => {
  it('returns diff unchanged when under limit', () => {
    const diff = 'short diff';
    assert.equal(truncateDiff(diff, 6000), diff);
  });

  it('truncates at charLimit with marker', () => {
    const diff = 'x'.repeat(7000);
    const result = truncateDiff(diff, 6000);
    assert.ok(result.startsWith('x'.repeat(6000)));
    assert.ok(result.includes('[...truncated'));
  });

  it('uses default limit of 6000', () => {
    const diff = 'x'.repeat(7000);
    const result = truncateDiff(diff);
    assert.ok(result.length < 7000);
    assert.ok(result.includes('[...truncated'));
  });
});

describe('summarizeFindings', () => {
  it('returns "None" when no findings', () => {
    assert.equal(summarizeFindings([]), 'None');
  });

  it('caps at max entries (default 10)', () => {
    const findings = Array.from({ length: 15 }, (_, i) => makeFinding(`f${i}`));
    const result = summarizeFindings(findings);
    const lines = result.trim().split('\n');
    assert.equal(lines.length, 10);
  });

  it('sorts critical findings first', () => {
    const findings = [makeFinding('warn', 'warning'), makeFinding('crit', 'critical')];
    const result = summarizeFindings(findings);
    assert.ok(result.indexOf('crit') < result.indexOf('warn'));
  });
});

describe('parseDescription', () => {
  it('extracts title and body', () => {
    const raw = 'Title: feat(auth): add JWT rotation\n\n---\n## Summary\n- adds rotation';
    const { title, body } = parseDescription(raw);
    assert.equal(title, 'feat(auth): add JWT rotation');
    assert.ok(body.includes('## Summary'));
  });

  it('returns fallback title when Title: line is missing', () => {
    const { title } = parseDescription('## Summary\n- no title line');
    assert.equal(title, 'chore: update');
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
cd /tmp/claude-autopilot && node --import tsx/esm --test tests/pr-desc.test.ts 2>&1 | head -20
```

Expected: errors — `src/cli/pr-desc.ts` does not exist yet.

- [ ] **Step 3: Create `src/cli/pr-desc.ts` with helpers**

```typescript
import type { Finding } from '../core/findings/types.ts';
import * as fs from 'node:fs';
import { execSync } from 'node:child_process';

export interface PrDescOptions {
  base?: string;
  post?: boolean;
  yes?: boolean;
  output?: string;
  // Injection slots for testing (bypass real git/LLM calls)
  _gitDiff?: string;
  _branchName?: string;
  _cachedFindings?: Finding[];
  _reviewEngine?: {
    review(input: { content: string; kind: string }): Promise<{ rawOutput: string }>;
  };
}

export interface PrDescResult {
  title: string;
  body: string;
  prUrl?: string;
}

export function truncateDiff(diff: string, charLimit = 6000): string {
  if (diff.length <= charLimit) return diff;
  const remaining = diff.length - charLimit;
  return `${diff.slice(0, charLimit)}[...truncated ${remaining} chars]`;
}

export function summarizeFindings(findings: Finding[], max = 10): string {
  if (findings.length === 0) return 'None';
  const order: Record<string, number> = { critical: 0, error: 1, warning: 2, info: 3 };
  const sorted = [...findings].sort(
    (a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9),
  );
  return sorted
    .slice(0, max)
    .map(f => `- [${f.severity.toUpperCase()}] ${f.file}:${f.line ?? '?'} — ${f.message}`)
    .join('\n');
}

export function parseDescription(raw: string): { title: string; body: string } {
  const titleMatch = raw.match(/^Title:\s*(.+)$/m);
  const title = titleMatch ? titleMatch[1].trim() : 'chore: update';
  const sepIdx = raw.indexOf('\n\n---\n');
  const body = sepIdx !== -1 ? raw.slice(sepIdx + 5).trim() : raw.replace(/^Title:.*\n?/m, '').trim();
  return { title, body };
}
```

- [ ] **Step 4: Run tests to verify helpers pass**

```bash
cd /tmp/claude-autopilot && node --import tsx/esm --test tests/pr-desc.test.ts 2>&1
```

Expected: all 7 tests pass.

- [ ] **Step 5: Commit**

```bash
cd /tmp/claude-autopilot && git add src/cli/pr-desc.ts tests/pr-desc.test.ts
git commit -m "feat(pr-desc): pure helper functions truncateDiff, summarizeFindings, parseDescription"
```

---

### Task 2: `runPrDesc` orchestrator

**Files:**
- Modify: `src/cli/pr-desc.ts`
- Modify: `tests/pr-desc.test.ts`

- [ ] **Step 1: Write failing tests for runPrDesc**

Add to `tests/pr-desc.test.ts` (after existing imports/helpers):

```typescript
import { runPrDesc } from '../../src/cli/pr-desc.ts';
import * as os from 'node:os';
import * as path from 'node:path';

const MOCK_ENGINE = {
  async review(_: { content: string; kind: string }) {
    return { rawOutput: 'Title: feat(test): add thing\n\n---\n## Summary\n- adds thing\n\n## Test Plan\n- [ ] verify thing' };
  },
};

describe('runPrDesc', () => {
  it('returns early with "No changes detected" when diff is empty', async () => {
    const result = await runPrDesc({
      _gitDiff: '',
      _branchName: 'feat/test',
      _cachedFindings: [],
      _reviewEngine: MOCK_ENGINE,
    });
    assert.equal(result.title, 'No changes detected');
  });

  it('generates title and body from LLM output', async () => {
    const result = await runPrDesc({
      _gitDiff: 'diff --git a/src/a.ts b/src/a.ts\n+const x = 1;',
      _branchName: 'feat/test',
      _cachedFindings: [],
      _reviewEngine: MOCK_ENGINE,
    });
    assert.equal(result.title, 'feat(test): add thing');
    assert.ok(result.body.includes('## Summary'));
  });

  it('includes findings summary in prompt when findings exist', async () => {
    let capturedContent = '';
    const engine = {
      async review(input: { content: string; kind: string }) {
        capturedContent = input.content;
        return { rawOutput: 'Title: test\n\n---\nbody' };
      },
    };
    await runPrDesc({
      _gitDiff: 'diff line',
      _branchName: 'main',
      _cachedFindings: [makeFinding('f1', 'critical')],
      _reviewEngine: engine,
    });
    assert.ok(capturedContent.includes('[CRITICAL]'));
  });

  it('includes "None" in prompt when no findings', async () => {
    let capturedContent = '';
    const engine = {
      async review(input: { content: string; kind: string }) {
        capturedContent = input.content;
        return { rawOutput: 'Title: test\n\n---\nbody' };
      },
    };
    await runPrDesc({
      _gitDiff: 'diff line',
      _branchName: 'main',
      _cachedFindings: [],
      _reviewEngine: engine,
    });
    assert.ok(capturedContent.includes('None'));
  });

  it('writes to output file instead of stdout', async () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-desc-'));
    const outPath = path.join(dir, 'pr.md');
    await runPrDesc({
      _gitDiff: 'diff line',
      _branchName: 'feat/x',
      _cachedFindings: [],
      _reviewEngine: MOCK_ENGINE,
      output: outPath,
    });
    const contents = fs.readFileSync(outPath, 'utf8');
    assert.ok(contents.includes('feat(test): add thing'));
    fs.rmSync(dir, { recursive: true });
  });
});
```

- [ ] **Step 2: Run tests to confirm they fail**

```bash
cd /tmp/claude-autopilot && node --import tsx/esm --test tests/pr-desc.test.ts 2>&1 | grep -c "fail"
```

Expected: 5 new failures.

- [ ] **Step 3: Add `runPrDesc` to `src/cli/pr-desc.ts`**

Append to `src/cli/pr-desc.ts` after the existing exports:

```typescript
export async function runPrDesc(options: PrDescOptions): Promise<PrDescResult> {
  const branchName = options._branchName ?? getBranchName();
  const diff = options._gitDiff ?? getGitDiff(options.base);

  if (!diff.trim()) {
    process.stdout.write('No changes detected\n');
    return { title: 'No changes detected', body: '' };
  }

  const findings = options._cachedFindings ?? loadCachedFindings();
  const prompt = buildPrompt(branchName, truncateDiff(diff), summarizeFindings(findings));

  const engine = options._reviewEngine ?? (await resolveEngine());
  const { rawOutput } = await engine.review({ content: prompt, kind: 'pr-diff' });
  const { title, body } = parseDescription(rawOutput);

  const formatted = `Title: ${title}\n\n---\n${body}`;

  if (options.output) {
    fs.writeFileSync(options.output, formatted, 'utf8');
  } else {
    process.stdout.write(formatted + '\n');
  }

  if (options.post) {
    return createPr(title, body, options.yes ?? false);
  }

  return { title, body };
}

function getBranchName(): string {
  try {
    return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
  } catch {
    return 'unknown';
  }
}

function getGitDiff(base?: string): string {
  try {
    const ref = base ?? getUpstreamBase();
    return execSync(`git diff ${ref}...HEAD`, { encoding: 'utf8' });
  } catch {
    return '';
  }
}

function getUpstreamBase(): string {
  try {
    return execSync(
      'git rev-parse --abbrev-ref --symbolic-full-name @{u}',
      { encoding: 'utf8' },
    ).trim();
  } catch {
    return 'HEAD~1';
  }
}

function loadCachedFindings(): Finding[] {
  try {
    return JSON.parse(fs.readFileSync('.guardrail-cache/findings.json', 'utf8')) as Finding[];
  } catch {
    return [];
  }
}

function buildPrompt(branch: string, diff: string, findingsSummary: string): string {
  return `Generate a pull request description with three sections:

## Summary
<3-5 bullet points describing what changed and why>

## Changes
<grouped by file/area, concise>

## Test Plan
<checklist of what to verify before merging>

Branch: ${branch}
Diff:
${diff}

Guardrail findings in this diff:
${findingsSummary}`;
}

async function resolveEngine() {
  const { loadAdapter } = await import('../adapters/review-engine/loader.ts');
  const { loadConfig } = await import('../core/config/loader.ts');
  const cfg = await loadConfig(undefined);
  return loadAdapter(cfg.reviewEngine);
}

async function createPr(title: string, body: string, yes: boolean): Promise<PrDescResult> {
  if (!yes) {
    process.stdout.write('\nCreate PR with this description? [y/N] ');
    const answer = await new Promise<string>(resolve => {
      process.stdin.setEncoding('utf8');
      process.stdin.once('data', (chunk: string) => resolve(chunk.split('\n')[0]));
    });
    if (!answer.toLowerCase().startsWith('y')) return { title, body };
  }
  const prUrl = execSync(
    `gh pr create --title ${JSON.stringify(title)} --body ${JSON.stringify(body)}`,
    { encoding: 'utf8' },
  ).trim();
  process.stdout.write(`\nPR created: ${prUrl}\n`);
  return { title, body, prUrl };
}
```

- [ ] **Step 4: Run all pr-desc tests**

```bash
cd /tmp/claude-autopilot && node --import tsx/esm --test tests/pr-desc.test.ts 2>&1
```

Expected: all 12 tests pass.

- [ ] **Step 5: Commit**

```bash
cd /tmp/claude-autopilot && git add src/cli/pr-desc.ts tests/pr-desc.test.ts
git commit -m "feat(pr-desc): runPrDesc orchestrator with git diff, LLM call, output modes"
```

---

### Task 3: Wire `pr-desc` subcommand in `src/cli/index.ts`

**Files:**
- Modify: `src/cli/index.ts`

- [ ] **Step 1: Read `src/cli/index.ts` to understand current dispatch structure**

```bash
cd /tmp/claude-autopilot && grep -n "case\|SUBCOMMANDS\|subcommand\|pr-desc\|hook\|scan\|explain" src/cli/index.ts | head -40
```

Note the pattern used for existing subcommands — whether they use a switch/case, if/else chain, or dispatch map. Note how args are parsed (looking for `--flag` patterns).

- [ ] **Step 2: Add pr-desc case to the command dispatch**

In `src/cli/index.ts`, find the existing subcommand dispatch section. It likely has cases for `run`, `scan`, `hook`, `explain`, `test-gen`, `worker`. Add a new case:

```typescript
case 'pr-desc': {
  const { runPrDesc } = await import('./pr-desc.ts');
  const baseIdx = args.indexOf('--base');
  const base = baseIdx !== -1 ? args[baseIdx + 1] : undefined;
  const outputIdx = args.indexOf('--output');
  const output = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
  await runPrDesc({
    base,
    post: args.includes('--post'),
    yes: args.includes('--yes'),
    output,
  });
  break;
}
```

Also add `pr-desc` to the help text string. Find the existing help output lines and add:

```
  pr-desc         Generate PR title, summary, and test plan from current diff
```

- [ ] **Step 3: Smoke test CLI help**

```bash
cd /tmp/claude-autopilot && node --import tsx/esm src/cli/index.ts --help 2>&1 | grep "pr-desc"
```

Expected: `pr-desc` line appears in help output.

- [ ] **Step 4: Run full test suite**

```bash
cd /tmp/claude-autopilot && node --import tsx/esm --test 'tests/**/*.test.ts' 2>&1 | tail -20
```

Expected: all tests pass, zero regressions.

- [ ] **Step 5: Typecheck**

```bash
cd /tmp/claude-autopilot && npx tsc --noEmit 2>&1 | grep -v "node_modules" | head -20
```

Expected: zero errors in `src/cli/pr-desc.ts` and `src/cli/index.ts`.

- [ ] **Step 6: Commit**

```bash
cd /tmp/claude-autopilot && git add src/cli/index.ts
git commit -m "feat(pr-desc): wire pr-desc subcommand in CLI index"
```

---

### Task 4: Final typecheck + full test run

**Files:** No new files — verification only.

- [ ] **Step 1: Full typecheck**

```bash
cd /tmp/claude-autopilot && npx tsc --noEmit 2>&1 | grep -v "node_modules"
```

Expected: zero new errors introduced by pr-desc feature.

- [ ] **Step 2: Full test suite with pr-desc tests counted**

```bash
cd /tmp/claude-autopilot && node --import tsx/esm --test 'tests/**/*.test.ts' 2>&1 | tail -10
```

Expected: all existing tests + 12 new pr-desc tests pass.

- [ ] **Step 3: Final cleanup commit if needed**

```bash
cd /tmp/claude-autopilot && git status
```

If any unstaged edits remain, stage and commit:

```bash
git add -p && git commit -m "chore(pr-desc): final cleanup"
```
