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
5 changes: 2 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
66 changes: 61 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -56,10 +58,21 @@ async function runReviewCommand(argv: string[]): Promise<number> {
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<void> {
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;
Expand All @@ -68,6 +81,8 @@ type ParsedReviewArgs =
githubEventPath?: string;
scopeContext?: string;
scopeLlmModel?: string;
markdownOutput?: string;
jsonOutput?: string;
oldRoot: string;
newRoot: string;
format: ReportFormat;
Expand All @@ -79,6 +94,8 @@ type ParsedReviewArgs =
githubEventPath?: string;
scopeContext?: string;
scopeLlmModel?: string;
markdownOutput?: string;
jsonOutput?: string;
repo: string;
base: string;
head: string;
Expand All @@ -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;
Expand All @@ -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 <file> argument.' };
}
markdownOutput = value;
index += 1;
} else if (arg === '--json-output') {
if (!value) {
return { ok: false, error: 'Missing required --json-output <file> argument.' };
}
jsonOutput = value;
index += 1;
} else if (arg === '--old') {
oldRoot = value;
index += 1;
Expand Down Expand Up @@ -156,7 +187,20 @@ function parseReviewArgs(argv: string[]): ParsedReviewArgs {
return { ok: false, error: 'Missing required --head <ref> 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) {
Expand All @@ -167,7 +211,19 @@ function parseReviewArgs(argv: string[]): ParsedReviewArgs {
return { ok: false, error: 'Missing required --new <dir> 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 {
Expand All @@ -183,8 +239,8 @@ if (invokedPath) {
function usage(): string {
return [
'Usage:',
' taskbound review --task "<stated task>" [--scope-context "<extra context>"] [--scope-llm <model>] --old <dir> --new <dir> [--format text|markdown|json|github]',
' taskbound review --task "<stated task>" [--scope-context "<extra context>"] [--scope-llm <model>] --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github]',
' taskbound review --github-event <event.json> [--scope-llm <model>] --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github]'
' taskbound review --task "<stated task>" [--scope-context "<extra context>"] [--scope-llm <model>] --old <dir> --new <dir> [--format text|markdown|json|github] [--markdown-output <file>] [--json-output <file>]',
' taskbound review --task "<stated task>" [--scope-context "<extra context>"] [--scope-llm <model>] --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github] [--markdown-output <file>] [--json-output <file>]',
' taskbound review --github-event <event.json> [--scope-llm <model>] --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github] [--markdown-output <file>] [--json-output <file>]'
].join('\n');
}
43 changes: 43 additions & 0 deletions test/cli-output.test.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions test/workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand All @@ -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 () => {
Expand Down