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