diff --git a/.github/workflows/agent-changelog.yml b/.github/workflows/agent-changelog.yml new file mode 100644 index 0000000..2418b97 --- /dev/null +++ b/.github/workflows/agent-changelog.yml @@ -0,0 +1,90 @@ +name: Agent Changelog + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Tag to generate changelog for" + required: true + type: string + +env: + BUN_VERSION: "1.2" + +permissions: + contents: write + +jobs: + changelog: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build CLI + run: cd apps/cli && bun run build + + - name: Generate changelog + id: changelog + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ github.ref_name || inputs.tag }}" + echo "πŸ“‹ Generating changelog for $TAG..." + + cd apps/cli + bun run dist/index.js changelog \ + --last-release \ + --output /tmp/CHANGELOG.md + + if [ ! -s /tmp/CHANGELOG.md ]; then + echo "No commits found since last tag β€” using simple release" + echo "# Release $TAG" > /tmp/CHANGELOG.md + echo "" >> /tmp/CHANGELOG.md + echo "Release $TAG of agent-workbench." >> /tmp/CHANGELOG.md + fi + + echo "content<> "$GITHUB_OUTPUT" + cat /tmp/CHANGELOG.md >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Create or update GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ github.ref_name || inputs.tag }}" + CHANGELOG="${{ steps.changelog.outputs.content }}" + + # Check if release already exists + EXISTING=$(gh release view "$TAG" --json id 2>/dev/null || echo "") + + if [ -n "$EXISTING" ]; then + echo "Updating existing release $TAG..." + gh release edit "$TAG" \ + --repo "${{ github.repository }}" \ + --notes "$CHANGELOG" \ + --discussion-category "announcements" + else + echo "Creating new release $TAG..." + gh release create "$TAG" \ + --repo "${{ github.repository }}" \ + --title "$TAG" \ + --notes "$CHANGELOG" \ + --generate-notes \ + --discussion-category "announcements" + fi + + echo "βœ… Release $TAG updated with changelog" diff --git a/.github/workflows/agent-review.yml b/.github/workflows/agent-review.yml new file mode 100644 index 0000000..31ec7bd --- /dev/null +++ b/.github/workflows/agent-review.yml @@ -0,0 +1,72 @@ +name: Agent PR Review + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to review" + required: true + type: number + +env: + BUN_VERSION: "1.2" + +permissions: + contents: read + pull-requests: write + checks: write + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7 + with: + # Fetch full history so git log and gh pr diff work accurately + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build workspace + run: bash scripts/build-all.sh + + - name: Build CLI + run: cd apps/cli && bun run build + + - name: Run PR review + id: review + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ github.event.pull_request.number || inputs.pr_number }}" + if [ -z "$PR_NUMBER" ]; then + echo "No PR number available" + exit 1 + fi + + echo "πŸ” Reviewing PR #$PR_NUMBER..." + cd apps/cli + bun run dist/index.js review --pr "$PR_NUMBER" \ + --repo "${{ github.repository }}" 2>&1 | tee /tmp/review-output.txt + + # Capture exit code + exit ${PIPESTATUS[0]} + + - name: Comment on failure + if: failure() && steps.review.outcome == 'failure' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER="${{ github.event.pull_request.number || inputs.pr_number }}" + gh pr comment "$PR_NUMBER" \ + --repo "${{ github.repository }}" \ + --body "❌ **agent-workbench review found issues** β€” check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details." diff --git a/apps/cli/src/commands/changelog.ts b/apps/cli/src/commands/changelog.ts new file mode 100644 index 0000000..2f47594 --- /dev/null +++ b/apps/cli/src/commands/changelog.ts @@ -0,0 +1,257 @@ +/** + * Changelog command β€” generates a changelog from conventional commits. + * + * Usage: + * agent-workbench changelog [--from ] [--to ] + * agent-workbench changelog --last-release + */ + +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface CommitGroup { + title: string; + commits: ParsedCommit[]; +} + +interface ParsedCommit { + hash: string; + type: string; + scope: string | null; + breaking: boolean; + description: string; + body: string; +} + +// ── Command handler ──────────────────────────────────────────────────────── + +export async function handleChangelog( + args: string[], +): Promise { + const from = parseArg(args, "--from"); + const to = parseArg(args, "--to") ?? "HEAD"; + const lastRelease = args.includes("--last-release"); + const output = parseArg(args, "--output"); + + try { + // Determine the range + let range: string; + + if (lastRelease) { + const lastTag = execSync( + "git describe --tags --abbrev=0 2>/dev/null || echo ''", + { encoding: "utf-8" }, + ).trim(); + if (!lastTag) { + console.error("No tags found. Use --from explicitly or run without --last-release."); + return 1; + } + range = `${lastTag}..${to}`; + console.error(`πŸ“‹ Changes since ${lastTag} β†’ ${to}`); + } else if (from) { + range = `${from}..${to}`; + console.error(`πŸ“‹ Changes from ${from} β†’ ${to}`); + } else { + const lastTag = execSync( + "git describe --tags --abbrev=0 2>/dev/null || echo ''", + { encoding: "utf-8" }, + ).trim(); + range = lastTag ? `${lastTag}..${to}` : `--all`; + console.error(`πŸ“‹ Changes since ${lastTag || 'the beginning'} β†’ ${to}`); + } + + // Fetch commits + const logFormat = "--format=---%n%H%n%s%n%b"; + const rawLog = execSync( + `git log ${range} ${logFormat}`, + { encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 }, + ).trim(); + + if (!rawLog) { + console.error("No commits found in range."); + return 0; + } + + const commits = parseCommits(rawLog); + const groups = groupCommits(commits); + const markdown = formatChangelog(groups, range, commits.length); + + // Output + if (output) { + writeFileSync(output, markdown, "utf-8"); + console.error(`\nβœ… Changelog written to ${output}`); + } else { + console.log(markdown); + } + + return 0; + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + return 1; + } +} + +// ── Parsing ──────────────────────────────────────────────────────────────── + +export function parseCommits(raw: string): ParsedCommit[] { + const blocks = raw.split("\n---\n").filter(Boolean); + const commits: ParsedCommit[] = []; + + for (const block of blocks) { + const lines = block.trim().split("\n"); + const hash = lines[0]?.trim() ?? ""; + const subject = lines[1]?.trim() ?? ""; + const body = lines.slice(2).join("\n").trim(); + + // Parse conventional commit: type(scope)!: description + const conventional = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/.exec(subject); + if (conventional) { + commits.push({ + hash: hash.slice(0, 7), + type: conventional[1]!, + scope: conventional[2] ?? null, + breaking: conventional[3] === "!", + description: conventional[4]!, + body, + }); + } else { + commits.push({ + hash: hash.slice(0, 7), + type: "other", + scope: null, + breaking: false, + description: subject, + body, + }); + } + } + + return commits; +} + +// ── Grouping ─────────────────────────────────────────────────────────────── + +const TYPE_ORDER: Record = { + feat: 0, + feature: 0, + fix: 1, + bugfix: 1, + docs: 2, + documentation: 2, + style: 3, + refactor: 4, + perf: 5, + performance: 5, + test: 6, + tests: 6, + build: 7, + ci: 8, + chore: 9, + other: 10, +}; + +const TYPE_LABELS: Record = { + feat: "πŸš€ Features", + feature: "πŸš€ Features", + fix: "πŸ› Bug Fixes", + bugfix: "πŸ› Bug Fixes", + docs: "πŸ“š Documentation", + documentation: "πŸ“š Documentation", + style: "πŸ’„ Style", + refactor: "♻️ Refactoring", + perf: "⚑ Performance", + performance: "⚑ Performance", + test: "πŸ§ͺ Tests", + tests: "πŸ§ͺ Tests", + build: "πŸ“¦ Build System", + ci: "πŸ€– CI", + chore: "πŸ”§ Chores", + other: "πŸ“ Other", +}; + +export function groupCommits(commits: ParsedCommit[]): CommitGroup[] { + const groups = new Map(); + + for (const commit of commits) { + const label = TYPE_LABELS[commit.type] ?? "πŸ“ Other"; + const existing = groups.get(label); + if (existing) { + existing.push(commit); + } else { + groups.set(label, [commit]); + } + } + + return Array.from(groups.entries()) + .map(([title, commitList]) => ({ title, commits: commitList })) + .sort((a, b) => { + const aOrder = TYPE_ORDER[a.commits[0]?.type ?? "other"] ?? 99; + const bOrder = TYPE_ORDER[b.commits[0]?.type ?? "other"] ?? 99; + return aOrder - bOrder; + }); +} + +// ── Formatting ───────────────────────────────────────────────────────────── + +function formatChangelog( + groups: CommitGroup[], + range: string, + total: number, +): string { + const lines: string[] = [ + `# Changelog`, + ``, + `> ${range} β€” ${total} commit${total === 1 ? "" : "s"}`, + ``, + ]; + + // Breaking changes section + const breaking = groups.flatMap((g) => + g.commits.filter((c) => c.breaking), + ); + if (breaking.length > 0) { + lines.push(`## ⚠️ Breaking Changes`); + lines.push(``); + for (const commit of breaking) { + lines.push(`- **${commit.description}** β€” \`${commit.hash}\``); + if (commit.body) { + lines.push(` ${commit.body.split("\n")[0]}`); + } + } + lines.push(``); + } + + // Grouped commits + for (const group of groups) { + const nonBreaking = group.commits.filter((c) => !c.breaking); + if (nonBreaking.length === 0) continue; + + lines.push(`## ${group.title}`); + lines.push(``); + for (const commit of nonBreaking) { + const scope = commit.scope ? `**${commit.scope}**: ` : ""; + lines.push(`- ${scope}${commit.description} (\`${commit.hash}\`)`); + if (commit.body) { + const firstLine = commit.body.split("\n")[0]?.trim(); + if (firstLine && firstLine.length > 0) { + lines.push(` ${firstLine}`); + } + } + } + lines.push(``); + } + + return lines.join("\n"); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function parseArg(args: string[], key: string): string | undefined { + const idx = args.indexOf(key); + if (idx >= 0 && idx + 1 < args.length) return args[idx + 1]!; + return undefined; +} diff --git a/apps/cli/src/commands/cli-cicd.test.ts b/apps/cli/src/commands/cli-cicd.test.ts new file mode 100644 index 0000000..561cfa4 --- /dev/null +++ b/apps/cli/src/commands/cli-cicd.test.ts @@ -0,0 +1,166 @@ +/** + * Tests for CLI CI/CD commands: review, changelog, pr-describe. + * + * Unit tests for parsing and formatting logic. + */ + +import { describe, expect, it } from "bun:test"; +import { parseCommits as clParseCommits, groupCommits } from "./changelog"; +import { parseCommits as prdParseCommits } from "./pr-describe"; +import { parseChangedFiles } from "./review"; + +// ── Changelog parsing tests ──────────────────────────────────────────────── + +describe("CLI β€” changelog", () => { + it("should parse conventional commit subjects", () => { + const raw = [ + "abc1234", + "feat(core): add widget support", + "", + "---", + "def5678", + "fix!: breaking API change", + "This is a breaking change.", + ].join("\n"); + + const commits = clParseCommits(raw); + expect(commits).toHaveLength(2); + + const feat = commits[0]!; + expect(feat.hash).toBe("abc1234"); + expect(feat.type).toBe("feat"); + expect(feat.scope).toBe("core"); + expect(feat.description).toBe("add widget support"); + expect(feat.breaking).toBe(false); + + const fix = commits[1]!; + expect(fix.hash).toBe("def5678"); + expect(fix.type).toBe("fix"); + expect(fix.breaking).toBe(true); + expect(fix.description).toBe("breaking API change"); + expect(fix.body).toContain("breaking change"); + }); + + it("should handle non-conventional commit subjects", () => { + const raw = "xyz789\nSome random commit message\n"; + const commits = clParseCommits(raw); + expect(commits).toHaveLength(1); + expect(commits[0]!.type).toBe("other"); + expect(commits[0]!.description).toBe("Some random commit message"); + }); + + it("should group commits by conventional type", () => { + const commits = [ + { hash: "a", type: "feat", scope: null, breaking: false, description: "feat 1", body: "" }, + { hash: "b", type: "fix", scope: null, breaking: false, description: "fix 1", body: "" }, + { hash: "c", type: "feat", scope: null, breaking: false, description: "feat 2", body: "" }, + { hash: "d", type: "docs", scope: null, breaking: false, description: "docs 1", body: "" }, + ] as Parameters[0]; + + const groups = groupCommits(commits); + expect(groups).toHaveLength(3); + expect(groups[0]!.title).toContain("Features"); + expect(groups[0]!.commits).toHaveLength(2); + expect(groups[1]!.title).toContain("Bug Fixes"); + expect(groups[1]!.commits).toHaveLength(1); + expect(groups[2]!.title).toContain("Documentation"); + expect(groups[2]!.commits).toHaveLength(1); + }); +}); + +// ── PR describe parsing tests ────────────────────────────────────────────── + +describe("CLI β€” pr-describe", () => { + it("should parse commit blocks with author and date", () => { + const raw = [ + "abc1234", + "Jane Doe", + "2026-07-07T12:00:00Z", + "feat(api): add new endpoint", + "Implementation details", + ].join("\n"); + + const commits = prdParseCommits(raw); + expect(commits).toHaveLength(1); + expect(commits[0]!.hash).toBe("abc1234"); + expect(commits[0]!.author).toBe("Jane Doe"); + expect(commits[0]!.type).toBe("feat"); + expect(commits[0]!.scope).toBe("api"); + expect(commits[0]!.description).toBe("add new endpoint"); + }); +}); + +// ── Review command diff parsing tests ────────────────────────────────────── + +describe("CLI β€” review", () => { + it("should parse changed files from a raw diff", () => { + const diff = [ + "diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts", + "index abc..def 100644", + "--- a/packages/core/src/index.ts", + "+++ b/packages/core/src/index.ts", + "@@ -1,5 +1,8 @@", + " import { foo } from './foo';", + " import { bar } from './bar';", + "+import { baz } from './baz';", + " ", + " export function greet() {", + "+ console.log('hello');", + "+ return true;", + " }", + ].join("\n"); + + const files = parseChangedFiles(diff); + expect(files).toHaveLength(1); + expect(files[0]!.path).toBe("packages/core/src/index.ts"); + expect(files[0]!.status).toBe("modified"); + expect(files[0]!.additions).toBe(3); + expect(files[0]!.deletions).toBe(0); + }); + + it("should detect added files", () => { + const diff = [ + "diff --git a/new-file.ts b/new-file.ts", + "new file mode 100644", + "index 000..abc 100644", + "--- /dev/null", + "+++ b/new-file.ts", + "@@ -0,0 +1,3 @@", + "+const x = 1;", + "+const y = 2;", + "+export { x, y };", + ].join("\n"); + + const files = parseChangedFiles(diff); + expect(files).toHaveLength(1); + expect(files[0]!.path).toBe("new-file.ts"); + expect(files[0]!.status).toBe("added"); + }); + + it("should handle multiple files in a diff", () => { + const diff = [ + "diff --git a/a.ts b/a.ts", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1,2 @@", + " a", + "+b", + "diff --git a/b.ts b/b.ts", + "--- a/b.ts", + "+++ b/b.ts", + "@@ -1 +1,2 @@", + " x", + "+y", + ].join("\n"); + + const files = parseChangedFiles(diff); + expect(files).toHaveLength(2); + expect(files[0]!.path).toBe("a.ts"); + expect(files[1]!.path).toBe("b.ts"); + }); + + it("should return empty array for empty diff", () => { + expect(parseChangedFiles("")).toHaveLength(0); + expect(parseChangedFiles("no diff content here")).toHaveLength(0); + }); +}); diff --git a/apps/cli/src/commands/pr-describe.ts b/apps/cli/src/commands/pr-describe.ts new file mode 100644 index 0000000..281da66 --- /dev/null +++ b/apps/cli/src/commands/pr-describe.ts @@ -0,0 +1,249 @@ +/** + * PR describe command β€” generates a PR description from git log. + * + * Usage: + * agent-workbench pr-describe [--base ] [--head ] + * agent-workbench pr-describe --pr + */ + +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; + +// ── Command handler ──────────────────────────────────────────────────────── + +export async function handlePrDescribe( + args: string[], +): Promise { + const prNumber = parseArg(args, "--pr"); + const baseArg = parseArg(args, "--base") ?? "main"; + const headArg = parseArg(args, "--head") ?? "HEAD"; + const output = parseArg(args, "--output"); + + try { + let baseBranch = baseArg; + let headBranch = headArg; + let repo: string | undefined; + + // Fetch PR info if --pr was given + if (prNumber) { + console.error(`πŸ” Fetching PR #${prNumber}...`); + const prInfo = execSync( + `gh pr view ${prNumber} --json title,headRefName,baseRefName,body`, + { encoding: "utf-8" }, + ); + const pr = JSON.parse(prInfo) as { + title: string; + headRefName: string; + baseRefName: string; + body: string | null; + }; + baseBranch = pr.baseRefName; + headBranch = pr.headRefName; + } + + // Parse conventional commits between base and head + const range = `${baseBranch}..${headBranch}`; + const logFormat = "--format=---%n%H%n%an%n%ai%n%s%n%b"; + let rawLog: string; + + try { + rawLog = execSync( + `git log ${range} ${logFormat}`, + { encoding: "utf-8", maxBuffer: 2 * 1024 * 1024 }, + ).trim(); + } catch { + // Try fetching the head branch first + try { + execSync(`git fetch origin ${headBranch}`, { + encoding: "utf-8", + timeout: 15000, + }); + rawLog = execSync( + `git log ${range} ${logFormat}`, + { encoding: "utf-8", maxBuffer: 2 * 1024 * 1024 }, + ).trim(); + } catch { + console.error( + `Error: Could not get commits between ${baseBranch} and ${headBranch}`, + ); + return 1; + } + } + + if (!rawLog) { + console.error("No commits found in range β€” branches may be up-to-date."); + return 0; + } + + const commits = parseCommits(rawLog); + const summary = generateDescription(commits, baseBranch, headBranch); + + // Output + if (output) { + writeFileSync(output, summary, "utf-8"); + console.error(`\nβœ… PR description written to ${output}`); + } else { + console.log(summary); + } + + return 0; + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + return 1; + } +} + +// ── Types ────────────────────────────────────────────────────────────────── + +interface CommitInfo { + hash: string; + author: string; + date: string; + type: string; + scope: string | null; + breaking: boolean; + description: string; + body: string; +} + +// ── Parsing ──────────────────────────────────────────────────────────────── + +export function parseCommits(raw: string): CommitInfo[] { + const blocks = raw.split("\n---\n").filter(Boolean); + const commits: CommitInfo[] = []; + + for (const block of blocks) { + const lines = block.trim().split("\n"); + const hash = lines[0]?.trim() ?? ""; + const author = lines[1]?.trim() ?? ""; + const date = lines[2]?.trim() ?? ""; + const subject = lines[3]?.trim() ?? ""; + const body = lines.slice(4).join("\n").trim(); + + const conventional = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/.exec(subject); + if (conventional) { + commits.push({ + hash: hash.slice(0, 7), + author, + date, + type: conventional[1]!, + scope: conventional[2] ?? null, + breaking: conventional[3] === "!", + description: conventional[4]!, + body, + }); + } + } + + return commits; +} + +// ── Description generation ───────────────────────────────────────────────── + +function generateDescription( + commits: CommitInfo[], + baseBranch: string, + headBranch: string, +): string { + const lines: string[] = []; + + // Title from first commit or branch name + const branchName = headBranch !== "HEAD" ? headBranch : "current"; + const firstSubject = commits[0]?.description ?? ""; + const title = firstSubject.length > 60 + ? `${firstSubject.slice(0, 57)}...` + : firstSubject; + + lines.push(`## ${title}`); + lines.push(``); + + // Summary + const featCount = commits.filter((c) => c.type === "feat" || c.type === "feature").length; + const fixCount = commits.filter((c) => c.type === "fix" || c.type === "bugfix").length; + const breakingCount = commits.filter((c) => c.breaking).length; + + lines.push(`### πŸ“‹ Overview`); + lines.push(``); + lines.push( + `This PR contains **${commits.length} commit${commits.length === 1 ? "" : "s"}** ` + + `(⟡ ${headBranch} into ${baseBranch}).`, + ); + lines.push(``); + if (featCount > 0) lines.push(`- **${featCount}** feature${featCount === 1 ? "" : "s"} ✨`); + if (fixCount > 0) lines.push(`- **${fixCount}** bug fix${fixCount === 1 ? "" : "es"} πŸ›`); + if (breakingCount > 0) lines.push(`- ⚠️ **${breakingCount} breaking change${breakingCount === 1 ? "" : "s"}**`); + lines.push(``); + + // Commits grouped by type + lines.push(`### πŸ“ Commits`); + lines.push(``); + + for (const commit of commits) { + const typeEmoji = getTypeEmoji(commit.type); + const breaking = commit.breaking ? " ⚠️" : ""; + const scope = commit.scope ? `**${commit.scope}**: ` : ""; + lines.push( + `- ${typeEmoji} ${scope}${commit.description}${breaking} (\`${commit.hash}\`)`, + ); + } + + lines.push(``); + + // Co-authored-by from commit bodies + const coauthors = new Set(); + for (const commit of commits) { + const match = commit.body.match(/Co-authored-by:\s*(.+)/); + if (match) coauthors.add(match[1]!.trim()); + } + if (coauthors.size > 0) { + lines.push(`### πŸ‘₯ Co-Authors`); + lines.push(``); + for (const author of coauthors) { + lines.push(`- ${author}`); + } + lines.push(``); + } + + // Checklist + lines.push(`### βœ… Checklist`); + lines.push(``); + lines.push(`- [ ] Tests pass (\`bun test\`)`); + lines.push(`- [ ] Type-check passes (\`bun run typecheck\`)`); + lines.push(`- [ ] Lint passes (\`bunx @biomejs/biome check .\`)`); + lines.push(`- [ ] Build succeeds (\`bun run build\`)`); + lines.push(``); + + return lines.join("\n"); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function getTypeEmoji(type: string): string { + const emoji: Record = { + feat: "✨", + feature: "✨", + fix: "πŸ›", + bugfix: "πŸ›", + docs: "πŸ“š", + documentation: "πŸ“š", + style: "πŸ’„", + refactor: "♻️", + perf: "⚑", + performance: "⚑", + test: "πŸ§ͺ", + tests: "πŸ§ͺ", + build: "πŸ“¦", + ci: "πŸ€–", + chore: "πŸ”§", + revert: "βͺ", + }; + return emoji[type] ?? "πŸ“"; +} + +function parseArg(args: string[], key: string): string | undefined { + const idx = args.indexOf(key); + if (idx >= 0 && idx + 1 < args.length) return args[idx + 1]!; + return undefined; +} diff --git a/apps/cli/src/commands/review.ts b/apps/cli/src/commands/review.ts new file mode 100644 index 0000000..424fb3d --- /dev/null +++ b/apps/cli/src/commands/review.ts @@ -0,0 +1,342 @@ +/** + * Review command β€” PR review bot. + * + * Fetches a GitHub PR diff, runs type-check and lint on changed files, + * and posts a structured review comment. + * + * Usage: + * agent-workbench review --pr [--repo owner/repo] + * agent-workbench review --diff [--head ] + */ + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; + +// ── Interfaces ───────────────────────────────────────────────────────────── + +interface ChangedFile { + path: string; + status: "added" | "modified" | "deleted" | "renamed"; + additions: number; + deletions: number; +} + +interface ReviewResult { + summary: string; + fileReports: FileReport[]; + errorCount: number; + warningCount: number; +} + +interface FileReport { + path: string; + issues: Issue[]; +} + +interface Issue { + severity: "error" | "warning" | "info"; + line?: number; + message: string; + source: string; +} + +// ── Command handler ──────────────────────────────────────────────────────── + +export async function handleReview( + args: string[], +): Promise { + const prNumber = parseArg(args, "--pr"); + const repo = parseArg(args, "--repo") ?? detectRepo(); + const diffPath = parseArg(args, "--diff"); + + if (!prNumber && !diffPath) { + console.error("Error: --pr or --diff is required"); + console.error("Usage: agent-workbench review --pr [--repo owner/repo]"); + console.error(" agent-workbench review --diff "); + return 1; + } + + try { + // Determine base branch for focused checking + let baseBranch = "main"; + let headBranch = ""; + let diffContent: string; + + if (prNumber) { + console.error(`πŸ” Fetching PR #${prNumber}...`); + const prInfo = execSync( + `gh pr view ${prNumber} ${repo ? `--repo ${repo}` : ""} --json title,headRefName,baseRefName,body`, + { encoding: "utf-8" }, + ); + const pr = JSON.parse(prInfo) as { + title: string; + headRefName: string; + baseRefName: string; + body: string | null; + }; + baseBranch = pr.baseRefName; + headBranch = pr.headRefName; + + console.error(` ${pr.title}`); + console.error(` ${pr.baseRefName} ← ${pr.headRefName}`); + + diffContent = execSync( + `gh pr diff ${prNumber} ${repo ? `--repo ${repo}` : ""}`, + { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }, + ); + } else { + diffContent = readFileSync(diffPath!, "utf-8"); + } + + // Parse changed files from diff + const changedFiles = parseChangedFiles(diffContent); + if (changedFiles.length === 0) { + console.error("No changed files found in diff."); + return 0; + } + + console.error(`\nπŸ“¦ ${changedFiles.length} files changed`); + for (const f of changedFiles) { + const icon = + f.status === "added" ? "βž•" : + f.status === "deleted" ? "βž–" : + f.status === "renamed" ? "πŸ”„" : "✏️"; + console.error(` ${icon} ${f.path} (+${f.additions}/-${f.deletions})`); + } + + // Run analysis + const result = await analyzeDiff(changedFiles); + + // Print detailed report + printReport(result); + + // Post to PR if we have one + if (prNumber) { + const body = formatReviewBody(result); + const reviewBodyPath = "/tmp/agent-workbench-review.md"; + const { writeFileSync } = await import("node:fs"); + writeFileSync(reviewBodyPath, body, "utf-8"); + + execSync( + `gh pr review ${prNumber} ${repo ? `--repo ${repo}` : ""} ` + + `--comment --body-file "${reviewBodyPath}"`, + { encoding: "utf-8" }, + ); + console.error(`\nβœ… Review posted to PR #${prNumber}`); + } + + return result.errorCount > 0 ? 1 : 0; + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + return 1; + } +} + +// ── Diff parsing ─────────────────────────────────────────────────────────── + +export function parseChangedFiles(diff: string): ChangedFile[] { + const files: ChangedFile[] = []; + const fileRegex = /^diff --git a\/(.+?) b\/(.+?)$/gm; + let match: RegExpExecArray | null; + + while ((match = fileRegex.exec(diff)) !== null) { + const path = match[2]!; + // Determine status from diff markers + let status: ChangedFile["status"] = "modified"; + let additions = 0; + let deletions = 0; + + // Count +/- lines in the hunk + const hunkStart = match.index; + const nextFileIdx = diff.indexOf("diff --git", hunkStart + 1); + const hunk = nextFileIdx >= 0 + ? diff.slice(hunkStart, nextFileIdx) + : diff.slice(hunkStart); + + if (hunk.includes("new file mode")) status = "added"; + else if (hunk.includes("deleted file mode")) status = "deleted"; + else if (/^rename from /m.test(hunk)) status = "renamed"; + + // Count additions/deletions in hunks + for (const line of hunk.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) additions++; + else if (line.startsWith("-") && !line.startsWith("---")) deletions++; + } + + files.push({ path, status, additions, deletions }); + } + + return files; +} + +// ── Analysis ─────────────────────────────────────────────────────────────── + +async function analyzeDiff( + files: ChangedFile[], +): Promise { + const fileReports: FileReport[] = []; + let errorCount = 0; + let warningCount = 0; + + const tsFiles = files.filter( + (f) => + f.status !== "deleted" && + /\.(ts|tsx|js|jsx|mts|cts)$/i.test(f.path), + ); + + for (const file of tsFiles) { + const issues: Issue[] = []; + + // Check file exists + if (!existsSync(file.path)) { + issues.push({ + severity: "warning", + message: "File not found locally β€” may need to checkout the branch", + source: "workbench", + }); + fileReports.push({ path: file.path, issues }); + continue; + } + + // Run TypeScript type-check on just this file + try { + const result = execSync( + `npx -y tsc --noEmit --pretty false "${file.path}" 2>&1 || true`, + { encoding: "utf-8", timeout: 30000 }, + ); + for (const line of result.split("\n")) { + if (line.includes("error TS")) { + issues.push({ + severity: "error", + message: line.replace(/^.*error TS\d+:\s*/, "").trim(), + source: "typescript", + }); + errorCount++; + } + } + } catch { + // Individual file tsc may fail if project setup isn't available + issues.push({ + severity: "info", + message: "Could not run isolated type-check (try full project build)", + source: "workbench", + }); + } + + // Run Biome lint on the file + try { + const result = execSync( + `npx @biomejs/biome check --no-errors-on-unmatched "${file.path}" 2>&1 || true`, + { encoding: "utf-8", timeout: 15000 }, + ); + for (const line of result.split("\n")) { + if (line.includes("error[")) { + issues.push({ + severity: "error", + message: line.trim(), + source: "biome", + }); + errorCount++; + } else if (line.includes("warning[")) { + issues.push({ + severity: "warning", + message: line.trim(), + source: "biome", + }); + warningCount++; + } + } + } catch { + // skip + } + + if (issues.length > 0) { + fileReports.push({ path: file.path, issues }); + } + } + + // Summary + const totalIssues = errorCount + warningCount; + let summary: string; + if (totalIssues === 0) { + summary = `βœ… Clean β€” no issues found across ${tsFiles.length} TypeScript files`; + } else { + summary = + `Found ${errorCount} error(s) and ${warningCount} warning(s) ` + + `across ${fileReports.length} of ${tsFiles.length} files checked`; + } + + return { summary, fileReports, errorCount, warningCount }; +} + +// ── Output formatting ────────────────────────────────────────────────────── + +function printReport(result: ReviewResult): void { + console.error(`\n${result.summary}\n`); + + for (const report of result.fileReports) { + console.error(`πŸ“„ ${report.path}`); + for (const issue of report.issues) { + const icon = + issue.severity === "error" ? "❌" : + issue.severity === "warning" ? "⚠️" : "ℹ️"; + const lineStr = issue.line ? `:${issue.line}` : ""; + console.error(` ${icon} [${issue.source}]${lineStr} ${issue.message}`); + } + console.error(""); + } +} + +function formatReviewBody(result: ReviewResult): string { + const lines: string[] = [ + `## πŸ€– agent-workbench PR Review`, + ``, + result.summary, + ``, + ]; + + if (result.fileReports.length === 0) { + lines.push(`**No issues found.** ✨`); + } + + for (const report of result.fileReports) { + lines.push(`### πŸ“„ \`${report.path}\``); + lines.push(``); + for (const issue of report.issues) { + const icon = + issue.severity === "error" ? "❌" : + issue.severity === "warning" ? "⚠️" : "ℹ️"; + const lineStr = issue.line ? ` (line ${issue.line})` : ""; + lines.push( + `- ${icon} **${issue.severity.toUpperCase()}** [${issue.source}]${lineStr}: ${issue.message}`, + ); + } + lines.push(``); + } + + return lines.join("\n"); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function parseArg(args: string[], key: string): string | undefined { + const idx = args.indexOf(key); + if (idx >= 0 && idx + 1 < args.length) return args[idx + 1]!; + return undefined; +} + +function detectRepo(): string | undefined { + try { + const remote = execSync("git remote get-url origin", { + encoding: "utf-8", + }).trim(); + const match = remote.match( + /(?:github\.com[:\/])([\w.-]+\/[\w.-]+?)(?:\.git)?$/, + ); + return match?.[1]; + } catch { + return undefined; + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index f0a25f8..50af933 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,9 +1,13 @@ /** - * agent-workbench CLI β€” entry point for plugin management and other commands. + * agent-workbench CLI β€” entry point for plugin management, CI/CD commands, + * and project scaffolding. * * Usage: * agent-workbench plugin list|install|enable|disable|uninstall * agent-workbench init