From 1e8654733688363ab7573dac4181fc05d58cd5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nerijus=20Bend=C5=BEi=C5=ABnas?= Date: Sun, 26 Apr 2026 06:51:44 +0300 Subject: [PATCH] feat: enable structured output without losing human-readable CI logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing JSONL to stdout forced users to choose between human-readable CI logs and machine-readable output. --output-file writes JSONL to a file while text continues to stdout, making both available at once. The action exposes the path as an output so downstream steps can consume the file directly without knowing its location. Signed-off-by: Nerijus Bendžiūnas --- README.md | 22 ++++++++ action.yml | 11 ++++ src/git_commit_guard/__init__.py | 83 ++++++++++++++++++---------- tests/test_git_commit_guard.py | 95 ++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 89d7c6a..37c6622 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,16 @@ checks pass. Pipe to `jq` for filtering: commit-guard --range origin/main..HEAD --output jsonl | jq 'select(.ok == false)' ``` +Use `--output-file FILE` to write JSONL to a file while keeping human-readable +text on stdout: + +```bash +commit-guard --range origin/main..HEAD --output-file results.jsonl +``` + +`--output-file` is independent of `--output`: combining both writes JSONL to +both stdout and the file. + ### GitHub Actions ```yaml @@ -299,6 +309,18 @@ jobs: require-trailer: 'Closes,Reviewed-by' max-subject-length: '100' min-description-length: '10' + output-file: results.jsonl +``` + +When `output-file` is set the action exposes the path as an output: + +```yaml + - uses: benner/commit-guard@v0.14.1 + id: cg + with: + range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }} + output-file: results.jsonl + - run: jq 'select(.ok == false)' "${{ steps.cg.outputs.output-file }}" ``` ### pre-commit diff --git a/action.yml b/action.yml index 057e772..0bfac85 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,13 @@ inputs: require-trailer: description: Comma-separated list of required trailers (e.g. Closes,Reviewed-by) required: false + output-file: + description: Write JSONL results to this file path (text still goes to stdout) + required: false +outputs: + output-file: + description: Path to the JSONL output file (set only when output-file input is provided) + value: ${{ steps.run.outputs.output-file }} runs: using: composite steps: @@ -57,6 +64,7 @@ runs: run: uv tool install git-commit-guard shell: bash - name: Run commit-guard + id: run env: CG_REV: ${{ inputs.rev }} CG_RANGE: ${{ inputs.range }} @@ -70,6 +78,7 @@ runs: CG_ALLOW_EMPTY: ${{ inputs.allow-empty }} CG_INCLUDE_MERGES: ${{ inputs.include-merges }} CG_REQUIRE_TRAILER: ${{ inputs.require-trailer }} + CG_OUTPUT_FILE: ${{ inputs.output-file }} run: | ARGS=() [[ -n "$CG_REV" ]] && ARGS+=("$CG_REV") @@ -86,5 +95,7 @@ runs: [[ "$CG_ALLOW_EMPTY" == "true" ]] && ARGS+=(--allow-empty) [[ "$CG_INCLUDE_MERGES" == "true" ]] && ARGS+=(--include-merges) [[ -n "$CG_REQUIRE_TRAILER" ]] && ARGS+=(--require-trailer "$CG_REQUIRE_TRAILER") + [[ -n "$CG_OUTPUT_FILE" ]] && ARGS+=(--output-file "$CG_OUTPUT_FILE") commit-guard "${ARGS[@]}" + [[ -n "$CG_OUTPUT_FILE" ]] && echo "output-file=$CG_OUTPUT_FILE" >> "$GITHUB_OUTPUT" shell: bash diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 79434ba..7d4474e 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -1,3 +1,4 @@ +import contextlib import json import os import re @@ -305,6 +306,7 @@ class Args: include_merges: bool required_trailers: list output: OutputFormat + output_file: Path | None def _resolve_enabled(args, config, parser): @@ -454,6 +456,12 @@ def _parse_args(): default=OutputFormat.TEXT, help="output format: text (default) or jsonl", ) + parser.add_argument( + "--output-file", + type=Path, + metavar="FILE", + help="write JSONL results to FILE (text still goes to stdout)", + ) args = parser.parse_args() config = _load_config() enabled = _resolve_enabled(args, config, parser) @@ -500,11 +508,12 @@ def _parse_args(): include_merges=args.include_merges, required_trailers=required_trailers, output=OutputFormat(args.output), + output_file=args.output_file, ) -def _report_jsonl(result, sha, subject): - record = { +def _jsonl_record(result, sha, subject): + return { "sha": sha, "subject": subject, "ok": result.ok, @@ -513,10 +522,17 @@ def _report_jsonl(result, sha, subject): for check, level, msg in result.errors ], } - print(json.dumps(record)) + + +def _report_jsonl(result, sha, subject): + print(json.dumps(_jsonl_record(result, sha, subject))) return 0 if result.ok else 1 +def _write_jsonl_record(result, sha, subject, file): + file.write(json.dumps(_jsonl_record(result, sha, subject)) + "\n") + + def _report_text(result): color = sys.stdout.isatty() for check, level, msg in result.errors: @@ -561,29 +577,38 @@ def _run_checks(args, rev, message, result): def main(): args = _parse_args() - if args.rev_range: - revs = _get_range_revs(args.rev_range, include_merges=args.include_merges) - if not revs: - sys.stderr.write("no commits in range\n") - return 0 if args.allow_empty else 1 - failed = False - for rev in revs: - message = _strip_comments(_get_message(rev)) - subject = message.split("\n")[0] - result = Result() - _run_checks(args, rev, message, result) - if args.output == OutputFormat.JSONL: - if _report_jsonl(result, rev, subject) != 0: - failed = True - else: - print(f"{rev[:7]} {subject}") - if _report_text(result) != 0: - failed = True - return 1 if failed else 0 - - subject = args.message.split("\n")[0] - result = Result() - _run_checks(args, args.rev, args.message, result) - if args.output == OutputFormat.JSONL: - return _report_jsonl(result, args.rev, subject) - return _report_text(result) + with ( + args.output_file.open("w") + if args.output_file + else contextlib.nullcontext() as out_file + ): + if args.rev_range: + revs = _get_range_revs(args.rev_range, include_merges=args.include_merges) + if not revs: + sys.stderr.write("no commits in range\n") + return 0 if args.allow_empty else 1 + failed = False + for rev in revs: + message = _strip_comments(_get_message(rev)) + subject = message.split("\n")[0] + result = Result() + _run_checks(args, rev, message, result) + if args.output == OutputFormat.JSONL: + if _report_jsonl(result, rev, subject) != 0: + failed = True + else: + print(f"{rev[:7]} {subject}") + if _report_text(result) != 0: + failed = True + if out_file: + _write_jsonl_record(result, rev, subject, out_file) + return 1 if failed else 0 + + subject = args.message.split("\n")[0] + result = Result() + _run_checks(args, args.rev, args.message, result) + if out_file: + _write_jsonl_record(result, args.rev, subject, out_file) + if args.output == OutputFormat.JSONL: + return _report_jsonl(result, args.rev, subject) + return _report_text(result) diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index de42794..45c0c17 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -1421,3 +1421,98 @@ def test_range_emits_one_line_per_commit(self, capsys): data = json.loads(line) assert data["sha"] == rev assert data["ok"] is True + + +class TestOutputFile: + def test_single_commit_writes_jsonl_to_file(self, tmp_path, capsys): + msg_file = tmp_path / "msg" + msg_file.write_text(_VALID_MSG) + out_file = tmp_path / "results.jsonl" + with patch( + "sys.argv", + [ + "cg", + "--message-file", + str(msg_file), + "--disable", + "signature,imperative", + "--output-file", + str(out_file), + ], + ): + assert main() == 0 + assert "all checks passed" in capsys.readouterr().out + data = json.loads(out_file.read_text()) + assert data["ok"] is True + assert data["subject"] == "fix: add thing" + + def test_output_jsonl_and_output_file_both_written(self, tmp_path, capsys): + msg_file = tmp_path / "msg" + msg_file.write_text(_VALID_MSG) + out_file = tmp_path / "results.jsonl" + with patch( + "sys.argv", + [ + "cg", + "--message-file", + str(msg_file), + "--disable", + "signature,imperative", + "--output", + "jsonl", + "--output-file", + str(out_file), + ], + ): + assert main() == 0 + stdout_data = json.loads(capsys.readouterr().out) + file_data = json.loads(out_file.read_text()) + assert stdout_data["ok"] is True + assert file_data["ok"] is True + assert stdout_data["subject"] == file_data["subject"] + + def test_range_writes_one_line_per_commit(self, tmp_path): + revs = ["aaa", "bbb"] + messages = [_VALID_MSG] * len(revs) + out_file = tmp_path / "results.jsonl" + with ( + patch( + "sys.argv", + [ + "cg", + "--range", + "HEAD~2..HEAD", + "--disable", + "signature,imperative", + "--output-file", + str(out_file), + ], + ), + patch("git_commit_guard._get_range_revs", return_value=revs), + patch("git_commit_guard._get_message", side_effect=messages), + ): + assert main() == 0 + lines = out_file.read_text().strip().splitlines() + assert len(lines) == len(revs) + for line, rev in zip(lines, revs, strict=True): + assert json.loads(line)["sha"] == rev + + def test_failed_commit_written_to_file(self, tmp_path): + msg_file = tmp_path / "msg" + msg_file.write_text("fix: add thing") + out_file = tmp_path / "results.jsonl" + with patch( + "sys.argv", + [ + "cg", + "--message-file", + str(msg_file), + "--disable", + "signature,imperative", + "--output-file", + str(out_file), + ], + ): + assert main() == 1 + data = json.loads(out_file.read_text()) + assert data["ok"] is False