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
8 changes: 4 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ runs:
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
ACTION_PATH: ${{ github.action_path }}
RUNNER_TEMP: ${{ runner.temp }}
run: |
set -euo pipefail
Expand Down Expand Up @@ -247,10 +248,9 @@ runs:
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"

# Best-effort one-line summary: last non-empty line of the captured output.
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
# Keep the summary single-line and bounded for the step output.
summary="$(printf '%s' "${summary}" | cut -c1-280)"
# Best-effort one-line summary. Structured output ends with lifecycle
# metadata, so parse final/error events instead of tailing stdout.
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
{
echo "summary<<ZERO_EOF"
printf '%s\n' "${summary}"
Expand Down
83 changes: 83 additions & 0 deletions scripts/action-summary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env node

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

const SUMMARY_LIMIT = 280;
const STRUCTURED_FORMATS = new Set(['json', 'stream-json']);

function normalizeSummary(value) {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, SUMMARY_LIMIT);
}

function truncateSummary(value) {
return String(value ?? '').slice(0, SUMMARY_LIMIT);
}

function lastNonEmptyLine(contents) {
let last = '';
for (const line of String(contents ?? '').split(/\r?\n/)) {
if (line.trim()) {
last = line;
}
}
return last;
}

function summarizeStructured(contents) {
let summary;

for (const line of String(contents ?? '').split(/\r?\n/)) {
if (!line.trim()) {
continue;
}

let event;
try {
event = JSON.parse(line);
} catch {
continue;
}

if (event?.type === 'final' && typeof event.text === 'string') {
summary = event.text;
} else if (event?.type === 'error' && typeof event.message === 'string') {
summary = event.message;
}
}

if (summary !== undefined) {
return normalizeSummary(summary);
}
return normalizeSummary(lastNonEmptyLine(contents));
}

export function summarizeOutput(format, contents) {
const normalizedFormat = String(format ?? 'text').toLowerCase();
if (!STRUCTURED_FORMATS.has(normalizedFormat)) {
return truncateSummary(lastNonEmptyLine(contents));
}
return summarizeStructured(contents);
}

function isMainModule() {
return (
process.argv[1] !== undefined &&
pathToFileURL(resolve(process.argv[1])).href === import.meta.url
);
}

if (isMainModule()) {
const [format, outputFile] = process.argv.slice(2);
let contents = '';
try {
contents = readFileSync(outputFile, 'utf8');
} catch {
contents = '';
}
process.stdout.write(`${summarizeOutput(format, contents)}\n`);
}
148 changes: 148 additions & 0 deletions scripts/action-summary.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { summarizeOutput } from './action-summary.mjs';

const scriptPath = fileURLToPath(new URL('./action-summary.mjs', import.meta.url));
const repoRoot = fileURLToPath(new URL('..', import.meta.url));

function jsonl(events) {
return events.map((event) => JSON.stringify(event)).join('\n');
}

test('stream-json final followed by run_end returns final text', () => {
const output = jsonl([
{ type: 'final', text: 'Done. Tests passed.' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]);

assert.equal(summarizeOutput('stream-json', output), 'Done. Tests passed.');
});

test('stream-json error followed by run_end returns error message', () => {
const output = jsonl([
{ type: 'error', code: 'provider_error', message: 'provider request failed' },
{ type: 'run_end', status: 'error', exitCode: 3 },
]);

assert.equal(summarizeOutput('stream-json', output), 'provider request failed');
});

test('stream-json final then error returns error message', () => {
const output = jsonl([
{ type: 'final', text: 'partial answer' },
{ type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' },
{ type: 'run_end', status: 'incomplete', exitCode: 4 },
]);

assert.equal(summarizeOutput('stream-json', output), 'run stopped with work unfinished');
});

test('json final followed by done returns final text', () => {
const output = jsonl([
{ type: 'final', text: 'Review complete.' },
{ type: 'done', exit_code: 0 },
]);

assert.equal(summarizeOutput('json', output), 'Review complete.');
});

test('json error followed by done returns error message', () => {
const output = jsonl([
{ type: 'error', code: 'exec_failed', message: 'command failed' },
{ type: 'done', exit_code: 1 },
]);

assert.equal(summarizeOutput('json', output), 'command failed');
});

test('json final then error returns error message', () => {
const output = jsonl([
{ type: 'final', text: 'partial answer' },
{ type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' },
{ type: 'done', exit_code: 4 },
]);

assert.equal(summarizeOutput('json', output), 'run stopped with work unfinished');
});

test('text output returns last non-empty line', () => {
const output = '\nfirst line\n\n final streamed line \n';

assert.equal(summarizeOutput('text', output), ' final streamed line ');
});

test('unknown format falls back to text behavior', () => {
const output = 'alpha\n beta \n';

assert.equal(summarizeOutput('xml', output), ' beta ');
});

test('malformed structured output falls back safely', () => {
const output = '{not-json}\nraw fallback\n';

assert.equal(summarizeOutput('stream-json', output), 'raw fallback');
});

test('long multiline final becomes one-line 280-char summary', () => {
const text = `first line\n${'x'.repeat(400)}`;
const summary = summarizeOutput('stream-json', jsonl([{ type: 'final', text }]));

assert.equal(summary.length, 280);
assert.equal(summary.includes('\n'), false);
assert.equal(summary, `first line ${'x'.repeat(400)}`.slice(0, 280));
});

test('cli reads output file and prints summary', () => {
const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-'));
try {
const outputFile = join(dir, 'zero-output.jsonl');
writeFileSync(
outputFile,
jsonl([
{ type: 'final', text: 'CLI summary' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]),
);

const result = spawnSync(process.execPath, [scriptPath, 'stream-json', outputFile], {
encoding: 'utf8',
});

assert.equal(result.status, 0);
assert.equal(result.stderr, '');
assert.equal(result.stdout, 'CLI summary\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test('cli runs when invoked with relative script path', () => {
const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-'));
try {
const outputFile = join(dir, 'zero-output.jsonl');
writeFileSync(
outputFile,
jsonl([
{ type: 'final', text: 'relative CLI summary' },
{ type: 'run_end', status: 'success', exitCode: 0 },
]),
);

const result = spawnSync(process.execPath, ['scripts/action-summary.mjs', 'stream-json', outputFile], {
cwd: repoRoot,
encoding: 'utf8',
});

assert.equal(result.status, 0);
assert.equal(result.stderr, '');
assert.equal(result.stdout, 'relative CLI summary\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
Loading