diff --git a/action.yml b/action.yml index da07125..1782a5b 100644 --- a/action.yml +++ b/action.yml @@ -108,9 +108,8 @@ runs: taskbound_args+=(--scope-llm "$scope_llm") fi - node "$GITHUB_ACTION_PATH/dist/index.js" "${taskbound_args[@]}" --format markdown | tee "$report_file" - node "$GITHUB_ACTION_PATH/dist/index.js" "${taskbound_args[@]}" --format json > "$json_file" - node "$GITHUB_ACTION_PATH/dist/index.js" "${taskbound_args[@]}" --format github + node "$GITHUB_ACTION_PATH/dist/index.js" "${taskbound_args[@]}" --format github --markdown-output "$report_file" --json-output "$json_file" + cat "$report_file" if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then cat "$report_file" >> "$GITHUB_STEP_SUMMARY" diff --git a/src/index.ts b/src/index.ts index 1d215f0..9f566ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ #!/usr/bin/env node +import { writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { renderReport, type ReportFormat } from './report.js'; +import type { BoundReport } from './report.js'; import { runTaskReview } from './review.js'; import { resolveTaskSource } from './task-source.js'; @@ -56,10 +58,21 @@ async function runReviewCommand(argv: string[]): Promise { scopeLlmModel: parsed.scopeLlmModel }); + await writeSupplementalOutputs(report, parsed); process.stdout.write(renderReport(report, parsed.format)); return 0; } +async function writeSupplementalOutputs( + report: BoundReport, + options: { markdownOutput?: string; jsonOutput?: string } +): Promise { + await Promise.all([ + options.markdownOutput ? writeFile(options.markdownOutput, renderReport(report, 'markdown'), 'utf8') : undefined, + options.jsonOutput ? writeFile(options.jsonOutput, renderReport(report, 'json'), 'utf8') : undefined + ]); +} + type ParsedReviewArgs = | { ok: true; @@ -68,6 +81,8 @@ type ParsedReviewArgs = githubEventPath?: string; scopeContext?: string; scopeLlmModel?: string; + markdownOutput?: string; + jsonOutput?: string; oldRoot: string; newRoot: string; format: ReportFormat; @@ -79,6 +94,8 @@ type ParsedReviewArgs = githubEventPath?: string; scopeContext?: string; scopeLlmModel?: string; + markdownOutput?: string; + jsonOutput?: string; repo: string; base: string; head: string; @@ -91,6 +108,8 @@ function parseReviewArgs(argv: string[]): ParsedReviewArgs { let githubEventPath: string | undefined; let scopeContext: string | undefined; let scopeLlmModel: string | undefined; + let markdownOutput: string | undefined; + let jsonOutput: string | undefined; let oldRoot: string | undefined; let newRoot: string | undefined; let base: string | undefined; @@ -114,6 +133,18 @@ function parseReviewArgs(argv: string[]): ParsedReviewArgs { } else if (arg === '--scope-llm') { scopeLlmModel = value; index += 1; + } else if (arg === '--markdown-output') { + if (!value) { + return { ok: false, error: 'Missing required --markdown-output argument.' }; + } + markdownOutput = value; + index += 1; + } else if (arg === '--json-output') { + if (!value) { + return { ok: false, error: 'Missing required --json-output argument.' }; + } + jsonOutput = value; + index += 1; } else if (arg === '--old') { oldRoot = value; index += 1; @@ -156,7 +187,20 @@ function parseReviewArgs(argv: string[]): ParsedReviewArgs { return { ok: false, error: 'Missing required --head argument.' }; } - return { ok: true, mode: 'git', task, githubEventPath, scopeContext, scopeLlmModel, repo, base, head, format }; + return { + ok: true, + mode: 'git', + task, + githubEventPath, + scopeContext, + scopeLlmModel, + markdownOutput, + jsonOutput, + repo, + base, + head, + format + }; } if (!oldRoot) { @@ -167,7 +211,19 @@ function parseReviewArgs(argv: string[]): ParsedReviewArgs { return { ok: false, error: 'Missing required --new argument.' }; } - return { ok: true, mode: 'directories', task, githubEventPath, scopeContext, scopeLlmModel, oldRoot, newRoot, format }; + return { + ok: true, + mode: 'directories', + task, + githubEventPath, + scopeContext, + scopeLlmModel, + markdownOutput, + jsonOutput, + oldRoot, + newRoot, + format + }; } function isReportFormat(value: string | undefined): value is ReportFormat { @@ -183,8 +239,8 @@ if (invokedPath) { function usage(): string { return [ 'Usage:', - ' taskbound review --task "" [--scope-context ""] [--scope-llm ] --old --new [--format text|markdown|json|github]', - ' taskbound review --task "" [--scope-context ""] [--scope-llm ] --repo --base --head [--format text|markdown|json|github]', - ' taskbound review --github-event [--scope-llm ] --repo --base --head [--format text|markdown|json|github]' + ' taskbound review --task "" [--scope-context ""] [--scope-llm ] --old --new [--format text|markdown|json|github] [--markdown-output ] [--json-output ]', + ' taskbound review --task "" [--scope-context ""] [--scope-llm ] --repo --base --head [--format text|markdown|json|github] [--markdown-output ] [--json-output ]', + ' taskbound review --github-event [--scope-llm ] --repo --base --head [--format text|markdown|json|github] [--markdown-output ] [--json-output ]' ].join('\n'); } diff --git a/test/cli-output.test.mjs b/test/cli-output.test.mjs index f40675f..d36e82b 100644 --- a/test/cli-output.test.mjs +++ b/test/cli-output.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; @@ -74,6 +76,47 @@ test('CLI emits GitHub warning annotations', async () => { assert.match(stdout, /::warning file=\.github\/workflows\/ci\.yml/); }); +test('CLI writes supplemental report files from one review', async () => { + const oldDir = join(testDir, 'fixtures', 'scope-creep', 'old'); + const newDir = join(testDir, 'fixtures', 'scope-creep', 'new'); + const outputDir = await mkdtemp(join(tmpdir(), 'taskbound-output-')); + const markdownOutput = join(outputDir, 'taskbound-report.md'); + const jsonOutput = join(outputDir, 'taskbound-report.json'); + + try { + const { stdout } = await execFileAsync( + process.execPath, + [ + 'dist/index.js', + 'review', + '--task', + TASK, + '--old', + oldDir, + '--new', + newDir, + '--format', + 'github', + '--markdown-output', + markdownOutput, + '--json-output', + jsonOutput + ], + { cwd: packageRoot } + ); + + const markdown = await readFile(markdownOutput, 'utf8'); + const json = JSON.parse(await readFile(jsonOutput, 'utf8')); + + assert.match(stdout, /::warning file=\.mcp\.json/); + assert.match(markdown, /# TaskBound scope review: CRITICAL/); + assert.equal(json.rating, 'critical'); + assert.ok(json.findings.some((finding) => finding.kind === 'script_pipe_to_shell')); + } finally { + await rm(outputDir, { recursive: true, force: true }); + } +}); + test('CLI uses GitHub pull request body as additional scope context', async () => { const oldDir = join(testDir, 'fixtures', 'scope-creep', 'old'); const newDir = join(testDir, 'fixtures', 'scope-creep', 'new'); diff --git a/test/workflow.test.mjs b/test/workflow.test.mjs index 8789ffc..f3fadf8 100644 --- a/test/workflow.test.mjs +++ b/test/workflow.test.mjs @@ -9,6 +9,8 @@ const packageRoot = join(testDir, '..'); test('action.yml can derive task scope from pull request event context', async () => { const action = await readFile(join(packageRoot, 'action.yml'), 'utf8'); + const reviewInvocations = action.match(/node "\$GITHUB_ACTION_PATH\/dist\/index\.js" "\$\{taskbound_args\[@\]\}"/g) ?? []; + assert.match(action, /name: TaskBound/); assert.match(action, /task:/); assert.match(action, /required: false/); @@ -21,6 +23,10 @@ test('action.yml can derive task scope from pull request event context', async ( assert.match(action, /--scope-llm/); assert.match(action, /npm ci --ignore-scripts/); assert.match(action, /scope-match-count/); + assert.equal(reviewInvocations.length, 1); + assert.match(action, /--format github/); + assert.match(action, /--markdown-output "\$report_file"/); + assert.match(action, /--json-output "\$json_file"/); }); test('self-dogfood workflow uses trusted action ref with pull request event task fallback', async () => {