From 4f7f632681ede6e7d9e11eb65f7964e00e595d2a Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 4 Aug 2026 21:18:42 +0300 Subject: [PATCH 01/18] feat: render structured output for step log, job summary, and PR comments Collect results via commit-check --format json and render all three output surfaces from the structured data instead of scraping plain text: - step log: grouped sections with ::error annotations per rule ID - job summary: policy report table with rule links and collapsible details - PR comment: compact table with rule links and collapsible details - new result output exposing structured JSON for downstream jobs --- README.md | 22 ++ action.yml | 4 + main.py | 474 ++++++++++++++++++++++----------- main_test.py | 732 +++++++++++++++++++++++++++++---------------------- 4 files changed, 768 insertions(+), 464 deletions(-) diff --git a/README.md b/README.md index 2bfa154..3edb9d5 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,28 @@ for all available options. > [Optional Inputs](#optional-inputs), so env vars and config files are the > recommended way to customize. +## Outputs + +### `result` + +Structured check results as JSON, available to downstream steps via +[`fromJSON`](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#fromjson): + +```yaml +- uses: commit-check/commit-check-action@v2 + id: commit-check + +- name: Inspect results + run: | + echo "Status: ${{ fromJSON(steps.commit-check.outputs.result).status }}" + echo "Scopes: ${{ toJSON(fromJSON(steps.commit-check.outputs.result).scopes) }}" +``` + +Each scope carries the check outcomes (`rule_id`, `check`, `status`, `value`, +`error`, `suggest`, `docs_url`) exactly as produced by +`commit-check --format json`, so downstream jobs can build their own reports +or gate on individual rules. + ## GitHub Action Job Summary By default, commit-check-action results are shown on the job summary page of the workflow. diff --git a/action.yml b/action.yml index 0018a52..4e19145 100644 --- a/action.yml +++ b/action.yml @@ -37,6 +37,10 @@ inputs: description: check pull request title following conventional commits required: false default: false +outputs: + result: + description: Structured check results as JSON (status + per-scope checks). Consume with fromJSON(steps..outputs.result). + runs: using: "composite" steps: diff --git a/main.py b/main.py index 1db4704..a0b9ec0 100755 --- a/main.py +++ b/main.py @@ -1,20 +1,39 @@ #!/usr/bin/env python3 +"""GitHub Action that runs commit-check and renders results. + +The action runs ``commit-check --format json`` to collect structured check +results (rule IDs, error messages, suggestions, docs links), then renders +them to three output surfaces: + +* **step log** — grouped sections with ``::error`` annotations per rule +* **job summary** — a Markdown policy report table +* **PR comment** — a compact Markdown summary (idempotently updated) +""" + import json import os import re import subprocess import sys import tempfile -from typing import TextIO +from dataclasses import dataclass, field +from typing import Any # Constants for message titles SUCCESS_TITLE = "# Commit-Check ✔️" FAILURE_TITLE = "# Commit-Check ❌" COMMIT_MESSAGE_DELIMITER = "\x00" -COMMIT_SECTION_SEPARATOR = "\n---\n" +RULES_URL = "https://commit-check.com/rules/" GITHUB_STEP_SUMMARY = os.environ["GITHUB_STEP_SUMMARY"] +#: Human-readable labels for the non-message CLI flags. +CHECK_LABELS = { + "--branch": "Branch", + "--author-name": "Author name", + "--author-email": "Author email", +} + def env_flag(name: str, default: str = "false") -> bool: """Read a GitHub Action boolean-style environment variable.""" @@ -31,6 +50,33 @@ def env_flag(name: str, default: str = "false") -> bool: PR_TITLE_ENABLED = env_flag("PR_TITLE") +@dataclass +class ScopeResult: + """Result of running commit-check against one scope (PR title, one commit, + branch, author, ...). + + ``checks`` holds the parsed JSON check outcomes (only set when the CLI + produced valid JSON); ``raw_text`` holds the raw CLI output when parsing + failed (a defensive fallback so unexpected output is never swallowed). + """ + + label: str + checks: list[dict[str, str]] = field(default_factory=list) + raw_text: str = "" + + @property + def status(self) -> str: + """Overall status: ``pass`` when every check passed.""" + if self.raw_text and not self.checks: + return "fail" + return "fail" if any(c["status"] == "fail" for c in self.checks) else "pass" + + @property + def failures(self) -> list[dict[str, str]]: + """The checks that failed in this scope.""" + return [c for c in self.checks if c["status"] == "fail"] + + def log_env_vars(): """Logs the environment variables for debugging purposes. @@ -143,14 +189,15 @@ def get_pr_commit_messages() -> list[str]: return [] -def run_check_command( - args: list[str], - result_file: TextIO, - input_text: str | None = None, - output_prefix: str | None = None, -) -> int: - """Run commit-check and write both stdout and stderr to the result file.""" - command = ["commit-check"] + args +def run_check_json( + args: list[str], input_text: str | None = None +) -> tuple[int, dict[str, Any] | None, str]: + """Run ``commit-check --format json`` and return (exit code, parsed JSON, raw output). + + The parsed JSON is ``None`` when the CLI did not produce valid JSON; the + raw output is kept so callers can fall back to showing it as text. + """ + command = ["commit-check", "--format", "json"] + args result = subprocess.run( command, input=input_text, @@ -160,59 +207,42 @@ def run_check_command( encoding="utf-8", check=False, ) - if result.stdout: - if output_prefix: - result_file.write(output_prefix) - result_file.write(result.stdout.rstrip("\n")) - result_file.write("\n") - return result.returncode - - -def run_pr_message_checks( - pr_messages: list[str], - result_file: TextIO, - initial_emitted: bool = False, -) -> int: - """Checks each PR commit message individually via commit-check --message. - - Parameters - ---------- - initial_emitted : bool - Whether another check (e.g. PR title) has already produced banner output, - so the first failing commit should use --no-banner. - - Returns 1 if any message fails, 0 if all pass. - """ - has_failure = False - emitted_failure_output = initial_emitted - total = len(pr_messages) - for index, msg in enumerate(pr_messages, start=1): - command_args = ["--message"] - if emitted_failure_output: - command_args.append("--no-banner") + raw = result.stdout or "" + try: + return result.returncode, json.loads(raw), raw + except json.JSONDecodeError: + return result.returncode, None, raw + + +def check_scope( + label: str, args: list[str], input_text: str | None = None +) -> ScopeResult: + """Run commit-check for one scope and wrap the outcome in a ScopeResult.""" + _rc, data, raw = run_check_json(args, input_text=input_text) + if isinstance(data, dict): + return ScopeResult(label=label, checks=data.get("checks", [])) + return ScopeResult(label=label, raw_text=raw) - if emitted_failure_output: - output_prefix = f"\n--- Commit {index}/{total}:\n" - else: - output_prefix = None - return_code = run_check_command( - command_args, - result_file, - input_text=msg, - output_prefix=output_prefix, +def run_pr_message_checks(pr_messages: list[str]) -> list[ScopeResult]: + """Check each PR commit message individually via commit-check --message.""" + results: list[ScopeResult] = [] + total = len(pr_messages) + for index, msg in enumerate(pr_messages, start=1): + results.append( + check_scope(f"Commit {index}/{total}", ["--message"], input_text=msg) ) - if return_code != 0: - has_failure = True - emitted_failure_output = True - return 1 if has_failure else 0 + return results -def run_other_checks(args: list[str], result_file: TextIO) -> int: - """Runs non-message checks (branch, author) once. Returns 0 if args is empty.""" - if not args: - return 0 - return run_check_command(args, result_file) +def run_other_checks(args: list[str]) -> list[ScopeResult]: + """Run each non-message check (branch, author) once, as its own scope.""" + results: list[ScopeResult] = [] + for flag in args: + label = CHECK_LABELS.get(flag) + if label: + results.append(check_scope(label, [flag])) + return results def build_check_args() -> list[str]: @@ -226,19 +256,8 @@ def build_check_args() -> list[str]: return [flag for flag, enabled in flags if enabled] -def get_result_path() -> str: - """Return a safe path for the result file using a temp directory. - - In GitHub Actions this uses ``RUNNER_TEMP`` which is cleaned up - automatically after the job. Falls back to ``tempfile.gettempdir()`` - for local testing. - """ - base = os.environ.get("RUNNER_TEMP") or tempfile.gettempdir() - return os.path.join(base, "commit-check-result.txt") - - -def run_commit_check() -> int: - """Runs all enabled checks and returns the overall exit code. +def run_commit_check() -> tuple[int, list[ScopeResult]]: + """Runs all enabled checks and returns the overall exit code and results. Checks are evaluated in order: 1. PR title (when ``pr-title: true`` and in a PR event) @@ -248,82 +267,230 @@ def run_commit_check() -> int: Outside of a PR event all enabled checks are handed to the CLI at once. """ args = build_check_args() - exit_code = 0 - emitted_failure_output = False - - with open(get_result_path(), "w", encoding="utf-8") as result_file: - # ---- 1. PR title check ------------------------------------------------ - # Always label the PR title section and suppress its banner so the - # output flows consistently with the commit-message section labels: - # - # --- PR Title: - # - # --- Commit 1/1: - # - if PR_TITLE_ENABLED and is_pr_event(): - pr_title = get_pr_title() - if pr_title: - rc = run_check_command( - ["--message", "--no-banner"], - result_file, - input_text=pr_title, - output_prefix=f"--- PR Title:\n", - ) - if rc != 0: - exit_code = max(exit_code, rc) - emitted_failure_output = True - - # ---- 2. Commit message checks ----------------------------------------- - if MESSAGE_ENABLED: - pr_messages = get_pr_commit_messages() - if pr_messages: - # In PR context: check each commit individually to avoid - # only validating the synthetic merge commit at HEAD. - rc = run_pr_message_checks( - pr_messages, result_file, initial_emitted=emitted_failure_output - ) - if rc != 0: - exit_code = max(exit_code, rc) - args = [a for a in args if a != "--message"] - - # ---- 3. Remaining checks (branch, author, etc.) ----------------------- - if args: - rc = run_other_checks(args, result_file) - if rc != 0: - exit_code = max(exit_code, rc) - - return 1 if exit_code else 0 - - -def read_result_file() -> str | None: - """Reads the result.txt file and removes ANSI color codes.""" - if os.path.getsize(get_result_path()) > 0: - with open(get_result_path(), "r", encoding="utf-8") as result_file: - result_text = re.sub( - r"\x1B\[[0-9;]*[a-zA-Z]", "", result_file.read() - ) # Remove ANSI colors - return result_text.rstrip() - return None + results: list[ScopeResult] = [] + + # ---- 1. PR title check ------------------------------------------------ + if PR_TITLE_ENABLED and is_pr_event(): + pr_title = get_pr_title() + if pr_title: + results.append(check_scope("PR title", ["--message"], input_text=pr_title)) + + # ---- 2. Commit message checks ----------------------------------------- + if MESSAGE_ENABLED: + pr_messages = get_pr_commit_messages() + if pr_messages: + # In PR context: check each commit individually to avoid + # only validating the synthetic merge commit at HEAD. + results.extend(run_pr_message_checks(pr_messages)) + args = [a for a in args if a != "--message"] + + # ---- 3. Remaining checks (branch, author, etc.) ----------------------- + # Outside a PR, check the HEAD commit message directly. + if "--message" in args: + results.append(check_scope("Commit message", ["--message"])) + args = [a for a in args if a != "--message"] + results.extend(run_other_checks(args)) + + exit_code = 1 if any(scope.status == "fail" for scope in results) else 0 + return exit_code, results + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def _rule_label(check: dict[str, str]) -> str: + """Human-readable label for a check: ``CC001 message`` (kebab-case).""" + rule_id = check.get("rule_id", "") + name = check.get("check", "").replace("_", "-") + return f"{rule_id} {name}" if rule_id else name + + +def _rule_markdown_link(check: dict[str, str]) -> str: + """Markdown link for a check: ``[CC001 message](docs_url)``.""" + label = _rule_label(check) + docs_url = check.get("docs_url", "") + return f"[{label}]({docs_url})" if docs_url else label + + +def _scope_group(label: str) -> str: + """Group name for a scope label, used to fold the step log output.""" + if label == "PR title" or label.startswith("Commit"): + return "Commit message" + if label.startswith("Author"): + return "Author" + return label + + +def _grouped(results: list[ScopeResult]) -> list[tuple[str, list[ScopeResult]]]: + """Split results into ordered groups for step log folding.""" + groups: list[tuple[str, list[ScopeResult]]] = [] + for scope in results: + group_name = _scope_group(scope.label) + if groups and groups[-1][0] == group_name: + groups[-1][1].append(scope) + else: + groups.append((group_name, [scope])) + return groups + + +def render_step_log(results: list[ScopeResult]) -> None: + """Print results to the step log with folded groups and error annotations.""" + for group_name, scopes in _grouped(results): + print(f"::group::{group_name}") + for scope in scopes: + if scope.status == "pass": + print(f" \u2714 {scope.label}") + continue + failures = scope.failures + count = f" ({len(failures)} failure{'s' if len(failures) != 1 else ''})" + print(f" \u2716 {scope.label}{count}") + if scope.raw_text and not scope.checks: + # Defensive fallback: commit-check produced unexpected output. + for line in scope.raw_text.strip().splitlines(): + print(f" {line}") + continue + for check in failures: + title = _rule_label(check) + error = check.get("error", "") + first_line = error.splitlines()[0] if error else "check failed" + print(f"::error title={title}::{first_line}") + if check.get("value"): + print(f" value: {check['value']}") + if error: + print(f" {error}") + if check.get("suggest"): + print(f" Suggest: {check['suggest']}") + if check.get("docs_url"): + print(f" Docs: {check['docs_url']}") + print("::endgroup::") + + if all(scope.status == "pass" for scope in results): + print("\u2714 commit-check: all checks passed") + + +def _failure_count(results: list[ScopeResult]) -> int: + return sum(len(scope.failures) for scope in results) + + +def _markdown_table(results: list[ScopeResult]) -> str: + """Render the scope/result table shared by summary and PR comment.""" + rows = ["| Scope | Failed checks | Result |", "|---|---|---|"] + for scope in results: + if scope.status == "pass": + rows.append(f"| {scope.label} | \u2014 | \u2705 |") + else: + links = " \u00b7 ".join( + _rule_markdown_link(check) for check in scope.failures + ) + rows.append(f"| {scope.label} | {links} | \u274c |") + return "\n".join(rows) + + +def _markdown_details(results: list[ScopeResult]) -> str: + """Render the collapsible failure details section.""" + sections: list[str] = ["
", "Failure details", ""] + for scope in results: + if scope.status == "pass": + continue + sections.append(f"**{scope.label}**") + sections.append("") + for check in scope.failures: + error = check.get("error", "") + sections.append(f"- **{_rule_markdown_link(check)}** \u2014 {error}") + if check.get("value"): + sections.append(f" - value: `{check['value']}`") + if check.get("suggest"): + sections.append(f" - suggest: {check['suggest']}") + sections.append("") + sections.append("
") + return "\n".join(sections) + + +def render_job_summary(results: list[ScopeResult]) -> str: + """Create the Markdown body for the GitHub job summary.""" + header = "# Commit Check Policy Report" + if all(scope.status == "pass" for scope in results): + scopes = "scope" if len(results) == 1 else "scopes" + return f"{header}\n\n\u2705 **All checks passed** ({len(results)} {scopes})" + + failures = _failure_count(results) + unit = "failure" if failures == 1 else "failures" + scopes = "scope" if len(results) == 1 else "scopes" + lines = [ + header, + "", + f"**{failures} {unit}** across {len(results)} {scopes}", + "", + _markdown_table(results), + "", + _markdown_details(results), + "", + f"_Rules reference: {RULES_URL}_", + ] + return "\n".join(lines) + + +def render_pr_comment(results: list[ScopeResult]) -> str: + """Create the Markdown body for the PR comment.""" + if all(scope.status == "pass" for scope in results): + return f"{SUCCESS_TITLE} All checks passed" + + failures = _failure_count(results) + unit = "failure" if failures == 1 else "failures" + lines = [ + f"{FAILURE_TITLE} {failures} {unit}", + "", + _markdown_table(results), + "", + _markdown_details(results), + ] + return "\n".join(lines) def build_result_body(result_text: str | None) -> str: - """Create the human-readable result body used in summaries and PR comments.""" + """Legacy helper kept for backward compatibility with existing callers.""" if result_text is None: return SUCCESS_TITLE return f"{FAILURE_TITLE}\n```\n{result_text}\n```" -def add_job_summary() -> int: +# --------------------------------------------------------------------------- +# Output surfaces +# --------------------------------------------------------------------------- + + +def add_job_summary(results: list[ScopeResult]) -> int: """Adds the commit check result to the GitHub job summary.""" if not JOB_SUMMARY_ENABLED: return 0 - result_text = read_result_file() - with open(GITHUB_STEP_SUMMARY, "a", encoding="utf-8") as summary_file: - summary_file.write(build_result_body(result_text)) + summary_file.write(render_job_summary(results)) + + return 0 if all(scope.status == "pass" for scope in results) else 1 - return 0 if result_text is None else 1 + +def set_result_output(results: list[ScopeResult]) -> None: + """Expose the structured results as the ``result`` action output. + + Uses the heredoc form of ``GITHUB_OUTPUT`` so multi-line JSON survives. + """ + output_path = os.getenv("GITHUB_OUTPUT") + if not output_path: + return + payload = { + "status": "pass" if all(s.status == "pass" for s in results) else "fail", + "scopes": [ + {"label": scope.label, "status": scope.status, "checks": scope.checks} + for scope in results + ], + } + with open(output_path, "a", encoding="utf-8") as f: + f.write("result< bool: @@ -383,7 +550,7 @@ def get_pr_number() -> int: ) -def add_pr_comments() -> int: +def add_pr_comments(results: list[ScopeResult]) -> int: """Posts the commit check result as a comment on the pull request.""" if not PR_COMMENTS_ENABLED: return 0 @@ -428,8 +595,7 @@ def add_pr_comments() -> int: repo = g.get_repo(repo_name) pull_request = repo.get_issue(pr_number) - result_text = read_result_file() - pr_comment_body = build_result_body(result_text) + pr_comment_body = render_pr_comment(results) comments = pull_request.get_comments() matching_comments = [ @@ -442,7 +608,7 @@ def add_pr_comments() -> int: last_comment = matching_comments[-1] if last_comment.body == pr_comment_body: print(f"PR comment already up-to-date for PR #{pr_number}.") - return 0 + return 0 if all(scope.status == "pass" for scope in results) else 1 print(f"Updating the last comment on PR #{pr_number}.") last_comment.edit(pr_comment_body) for comment in matching_comments[:-1]: @@ -452,7 +618,7 @@ def add_pr_comments() -> int: print(f"Creating a new comment on PR #{pr_number}.") pull_request.create_comment(body=pr_comment_body) - return 0 if result_text is None else 1 + return 0 if all(scope.status == "pass" for scope in results) else 1 except GithubException as e: if e.status == 403: print( @@ -469,34 +635,30 @@ def add_pr_comments() -> int: return 0 -def log_error_and_exit( - failure_title: str, result_text: str | None, ret_code: int -) -> None: - """ - Logs an error message to GitHub Actions and exits with the specified return code. - - Args: - failure_title (str): The title of the failure message. - result_text (str): The detailed result text to include in the error message. - ret_code (int): The return code to exit with. - """ - if result_text: - error_message = f"{failure_title}\n```\n{result_text}\n```" - print(f"::error::{error_message}") +def log_error_and_exit(ret_code: int, results: list[ScopeResult]) -> None: + """Logs a summary error to GitHub Actions and exits with the given code.""" + if ret_code != 0 and results: + failures = _failure_count(results) + unit = "failure" if failures == 1 else "failures" + print(f"::error::commit-check found {failures} {unit}.") sys.exit(ret_code) def main(): - """Main function to run commit-check, add job summary and post PR comments.""" + """Main function to run commit-check and render all output surfaces.""" log_env_vars() - ret_code = max(run_commit_check(), add_job_summary(), add_pr_comments()) + ret_code, results = run_commit_check() + + render_step_log(results) + set_result_output(results) + + ret_code = max(ret_code, add_job_summary(results), add_pr_comments(results)) if DRY_RUN_ENABLED: ret_code = 0 - result_text = read_result_file() - log_error_and_exit(FAILURE_TITLE, result_text, ret_code) + log_error_and_exit(ret_code, results) if __name__ == "__main__": diff --git a/main_test.py b/main_test.py index 1f5ef70..d0d3f91 100644 --- a/main_test.py +++ b/main_test.py @@ -3,6 +3,8 @@ import io import json import os +import sys +import tempfile import unittest from unittest.mock import MagicMock, patch @@ -12,6 +14,58 @@ import main # noqa: E402 +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_check( + check: str, + status: str = "pass", + rule_id: str = "CC001", + value: str = "", + error: str = "", + suggest: str = "", + docs_url: str = "", +) -> dict[str, str]: + """Build a single check outcome dict as produced by commit-check JSON.""" + return { + "rule_id": rule_id, + "check": check, + "status": status, + "value": value, + "error": error, + "suggest": suggest, + "docs_url": docs_url, + } + + +def json_output(*checks) -> str: + """Serialize checks to the CLI JSON output shape.""" + status = "fail" if any(c["status"] == "fail" for c in checks) else "pass" + return json.dumps({"status": status, "checks": list(checks)}) + + +def pass_scope(label: str = "Branch") -> main.ScopeResult: + return main.ScopeResult(label=label, checks=[make_check("branch")]) + + +def fail_scope(label: str = "Commit 1/1") -> main.ScopeResult: + return main.ScopeResult( + label=label, + checks=[ + make_check( + "message", + status="fail", + rule_id="CC001", + value="bad message", + error="The commit message should follow Conventional Commits.", + suggest="Use (): ", + docs_url="https://commit-check.com/rules/#cc001", + ) + ], + ) + class TestEnvFlag(unittest.TestCase): def test_true_value(self): @@ -73,8 +127,6 @@ def test_non_pr_event_returns_none(self): self.assertIsNone(main.get_pr_title()) def test_pr_event_returns_title(self): - import tempfile - event = { "pull_request": {"title": "feat: add login page"}, } @@ -91,8 +143,6 @@ def test_pr_event_returns_title(self): os.unlink(event_path) def test_pull_request_target_event(self): - import tempfile - event = { "pull_request": {"title": "fix: resolve timeout"}, } @@ -112,17 +162,12 @@ def test_pull_request_target_event(self): os.unlink(event_path) def test_missing_event_path_returns_none(self): - with ( - patch.dict(os.environ, {"GITHUB_EVENT_NAME": "pull_request"}), - patch.dict(os.environ, {}, clear=True), - ): + with patch.dict(os.environ, {}, clear=True): os.environ["GITHUB_EVENT_NAME"] = "pull_request" os.environ.pop("GITHUB_EVENT_PATH", None) self.assertIsNone(main.get_pr_title()) def test_invalid_json_returns_none(self): - import tempfile - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write("not valid json") event_path = f.name @@ -137,157 +182,166 @@ def test_invalid_json_returns_none(self): os.unlink(event_path) -class TestRunCheckCommand(unittest.TestCase): - def test_with_args_calls_subprocess(self): - mock_result = MagicMock(returncode=0, stdout="") +class TestRunCheckJson(unittest.TestCase): + def test_parses_json_output(self): + mock_result = MagicMock(returncode=0, stdout=json_output(make_check("branch"))) with patch("main.subprocess.run", return_value=mock_result) as mock_run: - rc = main.run_check_command(["--branch"], io.StringIO()) + rc, data, raw = main.run_check_json(["--branch"]) self.assertEqual(rc, 0) - self.assertEqual(mock_run.call_args[0][0], ["commit-check", "--branch"]) + self.assertEqual(data["status"], "pass") + self.assertEqual(len(data["checks"]), 1) + self.assertIn("checks", raw) + + def test_command_includes_format_json(self): + mock_result = MagicMock(returncode=0, stdout="{}") + with patch("main.subprocess.run", return_value=mock_result) as mock_run: + main.run_check_json(["--branch"]) + self.assertEqual( + mock_run.call_args[0][0], + ["commit-check", "--format", "json", "--branch"], + ) - def test_with_input_uses_text_mode(self): - mock_result = MagicMock(returncode=0, stdout="") + def test_input_text_is_passed_through(self): + mock_result = MagicMock(returncode=0, stdout="{}") with patch("main.subprocess.run", return_value=mock_result) as mock_run: - main.run_check_command(["--message"], io.StringIO(), input_text="fix: demo") + main.run_check_json(["--message"], input_text="fix: demo") self.assertEqual(mock_run.call_args[1]["input"], "fix: demo") self.assertTrue(mock_run.call_args[1]["text"]) - def test_success_returns_zero(self): - mock_result = MagicMock(returncode=0, stdout="") + def test_invalid_json_returns_none_with_raw_output(self): + mock_result = MagicMock(returncode=1, stdout="Commit rejected.\n") with patch("main.subprocess.run", return_value=mock_result): - rc = main.run_check_command(["--branch"], io.StringIO()) - self.assertEqual(rc, 0) + rc, data, raw = main.run_check_json(["--branch"]) + self.assertEqual(rc, 1) + self.assertIsNone(data) + self.assertEqual(raw, "Commit rejected.\n") + + +class TestScopeResult(unittest.TestCase): + def test_status_pass_when_all_checks_pass(self): + scope = main.ScopeResult( + label="Branch", checks=[make_check("branch"), make_check("merge_base")] + ) + self.assertEqual(scope.status, "pass") + self.assertEqual(scope.failures, []) + + def test_status_fail_when_any_check_fails(self): + scope = main.ScopeResult( + label="Branch", + checks=[ + make_check("branch", status="fail"), + make_check("merge_base"), + ], + ) + self.assertEqual(scope.status, "fail") + self.assertEqual(len(scope.failures), 1) + + def test_raw_text_fallback_is_failure(self): + scope = main.ScopeResult(label="Branch", raw_text="unexpected output") + self.assertEqual(scope.status, "fail") + + +class TestCheckScope(unittest.TestCase): + def test_parses_checks_into_scope(self): + mock_result = MagicMock( + returncode=1, stdout=json_output(make_check("branch", status="fail")) + ) + with patch("main.subprocess.run", return_value=mock_result): + scope = main.check_scope("Branch", ["--branch"]) + self.assertEqual(scope.label, "Branch") + self.assertEqual(scope.status, "fail") + self.assertEqual(scope.failures[0]["rule_id"], "CC001") + + def test_invalid_json_falls_back_to_raw_text(self): + mock_result = MagicMock(returncode=1, stdout="unexpected output") + with patch("main.subprocess.run", return_value=mock_result): + scope = main.check_scope("Branch", ["--branch"]) + self.assertEqual(scope.label, "Branch") + self.assertEqual(scope.raw_text, "unexpected output") + self.assertEqual(scope.status, "fail") class TestRunPrMessageChecks(unittest.TestCase): def test_single_message_pass(self): - mock_result = MagicMock(returncode=0, stdout="") - result_file = io.StringIO() + mock_result = MagicMock(returncode=0, stdout=json_output(make_check("message"))) with patch("main.subprocess.run", return_value=mock_result) as mock_run: - rc = main.run_pr_message_checks(["fix: something"], result_file) - self.assertEqual(rc, 0) - self.assertEqual(mock_run.call_args[0][0], ["commit-check", "--message"]) + scopes = main.run_pr_message_checks(["fix: something"]) + self.assertEqual(len(scopes), 1) + self.assertEqual(scopes[0].status, "pass") + self.assertEqual(scopes[0].label, "Commit 1/1") + self.assertEqual( + mock_run.call_args[0][0], + ["commit-check", "--format", "json", "--message"], + ) self.assertEqual(mock_run.call_args[1]["input"], "fix: something") - self.assertEqual(result_file.getvalue(), "") - def test_failed_message_writes_output(self): - mock_result = MagicMock(returncode=1, stdout="Commit rejected.\n") - result_file = io.StringIO() + def test_failed_message_marks_scope_failed(self): + mock_result = MagicMock( + returncode=1, + stdout=json_output(make_check("message", status="fail")), + ) with patch("main.subprocess.run", return_value=mock_result): - rc = main.run_pr_message_checks(["fix: something"], result_file) - self.assertEqual(rc, 1) - self.assertIn("Commit rejected.", result_file.getvalue()) + scopes = main.run_pr_message_checks(["bad commit"]) + self.assertEqual(scopes[0].status, "fail") + self.assertEqual(len(scopes[0].failures), 1) - def test_multiple_messages_partial_failure(self): + def test_labels_commits_in_order(self): results = [ - MagicMock(returncode=0, stdout=""), - MagicMock(returncode=1, stdout="Commit rejected.\n"), - MagicMock(returncode=0, stdout=""), + MagicMock(returncode=0, stdout=json_output(make_check("message"))), + MagicMock( + returncode=1, + stdout=json_output(make_check("message", status="fail")), + ), + MagicMock(returncode=0, stdout=json_output(make_check("message"))), ] with patch("main.subprocess.run", side_effect=results): - rc = main.run_pr_message_checks(["ok", "bad", "ok"], io.StringIO()) - self.assertEqual(rc, 1) + scopes = main.run_pr_message_checks(["ok", "bad", "ok"]) + self.assertEqual( + [s.label for s in scopes], ["Commit 1/3", "Commit 2/3", "Commit 3/3"] + ) + self.assertEqual(scopes[1].status, "fail") def test_empty_list(self): with patch("main.subprocess.run") as mock_run: - rc = main.run_pr_message_checks([], io.StringIO()) - self.assertEqual(rc, 0) + scopes = main.run_pr_message_checks([]) + self.assertEqual(scopes, []) mock_run.assert_not_called() - def test_first_failure_keeps_banner_and_later_failures_use_no_banner(self): - results = [ - MagicMock(returncode=0, stdout=""), - MagicMock(returncode=1, stdout="Commit rejected.\n"), - MagicMock(returncode=1, stdout="Type subject_imperative check failed\n"), - ] - with patch("main.subprocess.run", side_effect=results) as mock_run: - main.run_pr_message_checks( - ["ok first", "bad second", "bad third"], io.StringIO() - ) - self.assertEqual( - mock_run.call_args_list[0][0][0], ["commit-check", "--message"] - ) - self.assertEqual( - mock_run.call_args_list[1][0][0], - ["commit-check", "--message"], - ) - self.assertEqual( - mock_run.call_args_list[2][0][0], - ["commit-check", "--message", "--no-banner"], - ) +class TestRunOtherChecks(unittest.TestCase): + def test_empty_args_returns_no_scopes(self): + with patch("main.subprocess.run") as mock_run: + scopes = main.run_other_checks([]) + self.assertEqual(scopes, []) + mock_run.assert_not_called() - def test_initial_emitted_suppresses_banner_for_first_failure(self): + def test_runs_each_flag_as_its_own_scope(self): results = [ - MagicMock(returncode=1, stdout="Commit rejected.\n"), + MagicMock( + returncode=1, stdout=json_output(make_check("branch", status="fail")) + ), + MagicMock(returncode=0, stdout=json_output(make_check("author_name"))), ] with patch("main.subprocess.run", side_effect=results) as mock_run: - main.run_pr_message_checks( - ["bad commit"], io.StringIO(), initial_emitted=True - ) + scopes = main.run_other_checks(["--branch", "--author-name"]) + self.assertEqual([s.label for s in scopes], ["Branch", "Author name"]) + self.assertEqual(scopes[0].status, "fail") + self.assertEqual(scopes[1].status, "pass") self.assertEqual( - mock_run.call_args[0][0], - ["commit-check", "--message", "--no-banner"], + mock_run.call_args_list[0][0][0], + ["commit-check", "--format", "json", "--branch"], ) - - def test_initial_not_emitted_allows_banner(self): - results = [ - MagicMock(returncode=1, stdout="Commit rejected.\n"), - ] - with patch("main.subprocess.run", side_effect=results) as mock_run: - main.run_pr_message_checks( - ["bad commit"], io.StringIO(), initial_emitted=False - ) self.assertEqual( - mock_run.call_args[0][0], - ["commit-check", "--message"], - ) - - def test_later_failure_prefix_uses_short_separator_without_extra_blank_lines(self): - results = [ - MagicMock(returncode=0, stdout=""), - MagicMock(returncode=1, stdout="Commit rejected.\n"), - MagicMock( - returncode=1, - stdout=( - "Type subject_imperative check failed ==> bad third\n" - "Commit message should use imperative mood\n" - "Suggest: Use imperative mood\n\n" - ), - ), - ] - result_file = io.StringIO() - with patch("main.subprocess.run", side_effect=results): - main.run_pr_message_checks( - ["ok first", "bad second", "bad third"], result_file - ) - - output = result_file.getvalue() - self.assertIn("Commit rejected.\n", output) - self.assertIn( - "\n--- Commit 3/3:\nType subject_imperative check failed ==> bad third\n", - output, - ) - self.assertNotIn( - "------------------------------------------------------------------------", - output, + mock_run.call_args_list[1][0][0], + ["commit-check", "--format", "json", "--author-name"], ) - self.assertNotIn("\n\n\n", output) - -class TestRunOtherChecks(unittest.TestCase): - def test_empty_args_returns_zero(self): + def test_unknown_flag_is_skipped(self): with patch("main.subprocess.run") as mock_run: - rc = main.run_other_checks([], io.StringIO()) - self.assertEqual(rc, 0) + scopes = main.run_other_checks(["--unknown"]) + self.assertEqual(scopes, []) mock_run.assert_not_called() - def test_with_args_returns_returncode(self): - mock_result = MagicMock(returncode=1, stdout="branch check failed\n") - with patch("main.subprocess.run", return_value=mock_result): - rc = main.run_other_checks(["--branch", "--author-name"], io.StringIO()) - self.assertEqual(rc, 1) - class TestGetPrCommitMessages(unittest.TestCase): def test_non_pr_event_returns_empty(self): @@ -378,46 +432,34 @@ def test_get_messages_from_head_ref(self): class TestRunCommitCheck(unittest.TestCase): - def setUp(self): - self._orig_dir = os.getcwd() - import tempfile - - self._tmpdir = tempfile.mkdtemp() - os.environ["RUNNER_TEMP"] = self._tmpdir - os.chdir(self._tmpdir) - - def tearDown(self): - os.chdir(self._orig_dir) - os.environ.pop("RUNNER_TEMP", None) - - def test_pr_path_calls_pr_message_checks(self): + def test_pr_path_checks_each_commit(self): with ( patch("main.MESSAGE_ENABLED", True), patch("main.BRANCH_ENABLED", False), patch("main.AUTHOR_NAME_ENABLED", False), patch("main.AUTHOR_EMAIL_ENABLED", False), patch("main.get_pr_commit_messages", return_value=["fix: something"]), - patch("main.run_pr_message_checks", return_value=0) as mock_pr, - patch("main.run_other_checks", return_value=0), - patch("main.run_check_command") as mock_command, + patch("main.run_pr_message_checks", return_value=[pass_scope()]) as mock_pr, + patch("main.run_other_checks", return_value=[]), ): - rc = main.run_commit_check() + rc, results = main.run_commit_check() self.assertEqual(rc, 0) - mock_pr.assert_called_once() - mock_command.assert_not_called() + mock_pr.assert_called_once_with(["fix: something"]) + self.assertEqual(len(results), 1) - def test_pr_path_returns_nonzero_when_any_check_fails(self): + def test_pr_path_fails_when_any_scope_fails(self): with ( patch("main.MESSAGE_ENABLED", True), patch("main.BRANCH_ENABLED", True), patch("main.AUTHOR_NAME_ENABLED", False), patch("main.AUTHOR_EMAIL_ENABLED", False), patch("main.get_pr_commit_messages", return_value=["bad msg"]), - patch("main.run_pr_message_checks", return_value=1), - patch("main.run_other_checks", return_value=1), + patch("main.run_pr_message_checks", return_value=[fail_scope()]), + patch("main.run_other_checks", return_value=[pass_scope()]), ): - rc = main.run_commit_check() + rc, results = main.run_commit_check() self.assertEqual(rc, 1) + self.assertEqual(len(results), 2) def test_pr_title_check_runs_when_enabled(self): with ( @@ -428,16 +470,16 @@ def test_pr_title_check_runs_when_enabled(self): patch("main.AUTHOR_EMAIL_ENABLED", False), patch("main.is_pr_event", return_value=True), patch("main.get_pr_title", return_value="feat: a feature"), - patch("main.run_check_command", return_value=0) as mock_cmd, - patch("main.run_other_checks", return_value=0), + patch( + "main.check_scope", return_value=pass_scope("PR title") + ) as mock_scope, + patch("main.run_other_checks", return_value=[]), ): - rc = main.run_commit_check() + rc, results = main.run_commit_check() self.assertEqual(rc, 0) - self.assertEqual( - mock_cmd.call_args[0][0], - ["--message", "--no-banner"], + mock_scope.assert_called_once_with( + "PR title", ["--message"], input_text="feat: a feature" ) - self.assertEqual(mock_cmd.call_args[1]["input_text"], "feat: a feature") def test_pr_title_failure_propagates(self): with ( @@ -448,10 +490,10 @@ def test_pr_title_failure_propagates(self): patch("main.AUTHOR_EMAIL_ENABLED", False), patch("main.is_pr_event", return_value=True), patch("main.get_pr_title", return_value="bad title"), - patch("main.run_check_command", return_value=1), - patch("main.run_other_checks", return_value=0), + patch("main.check_scope", return_value=fail_scope("PR title")), + patch("main.run_other_checks", return_value=[]), ): - rc = main.run_commit_check() + rc, results = main.run_commit_check() self.assertEqual(rc, 1) def test_pr_title_skipped_outside_pr_context(self): @@ -463,36 +505,13 @@ def test_pr_title_skipped_outside_pr_context(self): patch("main.AUTHOR_EMAIL_ENABLED", False), patch("main.is_pr_event", return_value=False), patch("main.get_pr_title") as mock_title, - patch("main.run_check_command", return_value=0), - patch("main.run_other_checks", return_value=0), + patch("main.run_other_checks", return_value=[]), ): - rc = main.run_commit_check() + rc, results = main.run_commit_check() self.assertEqual(rc, 0) mock_title.assert_not_called() - def test_pr_title_and_message_both_run(self): - with ( - patch("main.PR_TITLE_ENABLED", True), - patch("main.MESSAGE_ENABLED", True), - patch("main.BRANCH_ENABLED", False), - patch("main.AUTHOR_NAME_ENABLED", False), - patch("main.AUTHOR_EMAIL_ENABLED", False), - patch("main.is_pr_event", return_value=True), - patch("main.get_pr_title", return_value="feat: nice pr"), - patch( - "main.get_pr_commit_messages", - return_value=["fix: first", "feat: second"], - ), - patch("main.run_check_command", return_value=0) as mock_cmd, - patch("main.run_pr_message_checks", return_value=0) as mock_pr, - patch("main.run_other_checks", return_value=0), - ): - rc = main.run_commit_check() - self.assertEqual(rc, 0) - mock_cmd.assert_called_once() # PR title check - mock_pr.assert_called_once() # commit message checks - - def test_non_pr_path_uses_direct_command(self): + def test_non_pr_message_check_uses_commit_message_scope(self): with ( patch("main.MESSAGE_ENABLED", True), patch("main.BRANCH_ENABLED", False), @@ -500,44 +519,22 @@ def test_non_pr_path_uses_direct_command(self): patch("main.AUTHOR_EMAIL_ENABLED", False), patch("main.get_pr_commit_messages", return_value=[]), patch("main.run_pr_message_checks") as mock_pr, - patch("main.run_check_command", return_value=0) as mock_command, - ): - rc = main.run_commit_check() - self.assertEqual(rc, 0) - mock_pr.assert_not_called() - mock_command.assert_called_once() - - def test_message_disabled_uses_direct_command(self): - with ( - patch("main.MESSAGE_ENABLED", False), - patch("main.BRANCH_ENABLED", True), - patch("main.AUTHOR_NAME_ENABLED", False), - patch("main.AUTHOR_EMAIL_ENABLED", False), - patch("main.run_pr_message_checks") as mock_pr, - patch("main.run_check_command", return_value=0) as mock_command, + patch( + "main.check_scope", return_value=pass_scope("Commit message") + ) as mock_scope, + patch("main.run_other_checks", return_value=[]), ): - rc = main.run_commit_check() + rc, results = main.run_commit_check() self.assertEqual(rc, 0) mock_pr.assert_not_called() - mock_command.assert_called_once() - - def test_result_txt_is_created(self): - with ( - patch("main.MESSAGE_ENABLED", False), - patch("main.BRANCH_ENABLED", False), - patch("main.AUTHOR_NAME_ENABLED", False), - patch("main.AUTHOR_EMAIL_ENABLED", False), - patch("main.run_check_command", return_value=0), - ): - main.run_commit_check() - self.assertTrue(os.path.exists(main.get_result_path())) + mock_scope.assert_called_once_with("Commit message", ["--message"]) - def test_other_args_excludes_message(self): + def test_message_flag_removed_before_other_checks_in_pr(self): captured_args = [] - def fake_other_checks(args, result_file): + def fake_other_checks(args): captured_args.extend(args) - return 0 + return [] with ( patch("main.MESSAGE_ENABLED", True), @@ -545,7 +542,7 @@ def fake_other_checks(args, result_file): patch("main.AUTHOR_NAME_ENABLED", False), patch("main.AUTHOR_EMAIL_ENABLED", False), patch("main.get_pr_commit_messages", return_value=["fix: x"]), - patch("main.run_pr_message_checks", return_value=0), + patch("main.run_pr_message_checks", return_value=[pass_scope()]), patch("main.run_other_checks", side_effect=fake_other_checks), ): main.run_commit_check() @@ -553,34 +550,85 @@ def fake_other_checks(args, result_file): self.assertIn("--branch", captured_args) -class TestReadResultFile(unittest.TestCase): - def setUp(self): - import tempfile +class TestRenderStepLog(unittest.TestCase): + def _run(self, results): + buffer = io.StringIO() + with patch("sys.stdout", buffer): + main.render_step_log(results) + return buffer.getvalue() - self._orig_dir = os.getcwd() - self._tmpdir = tempfile.mkdtemp() - os.environ["RUNNER_TEMP"] = self._tmpdir - os.chdir(self._tmpdir) + def test_all_pass_prints_success_line(self): + output = self._run([pass_scope("Branch")]) + self.assertIn("✔ commit-check: all checks passed", output) - def tearDown(self): - os.chdir(self._orig_dir) - os.environ.pop("RUNNER_TEMP", None) + def test_failure_prints_group_and_error_annotation(self): + output = self._run([fail_scope("Commit 1/1")]) + self.assertIn("::group::Commit message", output) + self.assertIn("::endgroup::", output) + self.assertIn("✖ Commit 1/1 (1 failure)", output) + self.assertIn( + "::error title=CC001 message::The commit message should follow " + "Conventional Commits.", + output, + ) + self.assertIn("value: bad message", output) + self.assertIn("Suggest: Use (): ", output) + self.assertIn("Docs: https://commit-check.com/rules/#cc001", output) + + def test_groups_scopes_by_category(self): + results = [ + fail_scope("PR title"), + pass_scope("Commit 1/2"), + fail_scope("Branch"), + ] + output = self._run(results) + # One group for commit-message scopes, one for the branch scope. + self.assertEqual(output.count("::group::"), 2) + self.assertIn("::group::Commit message", output) + self.assertIn("::group::Branch", output) + + def test_raw_text_fallback_is_printed(self): + scope = main.ScopeResult(label="Branch", raw_text="unexpected output") + output = self._run([scope]) + self.assertIn("✖ Branch (0 failures)", output) + self.assertIn("unexpected output", output) + + +class TestRenderJobSummary(unittest.TestCase): + def test_all_pass(self): + body = main.render_job_summary([pass_scope("Branch")]) + self.assertIn("# Commit Check Policy Report", body) + self.assertIn("✅ **All checks passed** (1 scope)", body) + + def test_failure_renders_table_with_rule_links(self): + body = main.render_job_summary([fail_scope("Commit 1/1")]) + self.assertIn("**1 failure** across 1 scope", body) + self.assertIn("| Scope | Failed checks | Result |", body) + self.assertIn( + "| Commit 1/1 | [CC001 message](https://commit-check.com/rules/#cc001) | ❌ |", + body, + ) + self.assertIn("
", body) + self.assertIn("value: `bad message`", body) + self.assertIn("suggest: Use (): ", body) + self.assertIn("Rules reference: https://commit-check.com/rules/", body) - def _write_result(self, content: str): - with open(main.get_result_path(), "w", encoding="utf-8") as file_obj: - file_obj.write(content) + def test_pass_scope_renders_checkmark(self): + body = main.render_job_summary([pass_scope("Branch"), fail_scope("Commit 1/1")]) + self.assertIn("| Branch | — | ✅ |", body) - def test_empty_file_returns_none(self): - self._write_result("") - self.assertIsNone(main.read_result_file()) - def test_file_with_content(self): - self._write_result("some output\n") - self.assertEqual(main.read_result_file(), "some output") +class TestRenderPrComment(unittest.TestCase): + def test_all_pass_keeps_success_prefix(self): + body = main.render_pr_comment([pass_scope("Branch")]) + self.assertTrue(body.startswith(main.SUCCESS_TITLE)) + self.assertIn("All checks passed", body) - def test_ansi_codes_are_stripped(self): - self._write_result("\x1b[31mError\x1b[0m: bad commit") - self.assertEqual(main.read_result_file(), "Error: bad commit") + def test_failure_keeps_failure_prefix_with_count(self): + body = main.render_pr_comment([fail_scope("Commit 1/1")]) + self.assertTrue(body.startswith(main.FAILURE_TITLE)) + self.assertIn("1 failure", body) + self.assertIn("| Scope | Failed checks | Result |", body) class TestBuildResultBody(unittest.TestCase): @@ -594,71 +642,67 @@ def test_failure_body(self): class TestAddJobSummary(unittest.TestCase): - def setUp(self): - import tempfile - - self._orig_dir = os.getcwd() - self._tmpdir = tempfile.mkdtemp() - os.environ["RUNNER_TEMP"] = self._tmpdir - os.chdir(self._tmpdir) - with open(main.get_result_path(), "w", encoding="utf-8"): - pass - - def tearDown(self): - os.chdir(self._orig_dir) - os.environ.pop("RUNNER_TEMP", None) - def test_false_skips(self): with patch("main.JOB_SUMMARY_ENABLED", False): - rc = main.add_job_summary() + rc = main.add_job_summary([pass_scope()]) self.assertEqual(rc, 0) - def test_success_writes_success_title(self): - summary_path = os.path.join(self._tmpdir, "summary.txt") + def test_success_writes_policy_report(self): + summary_path = os.path.join(tempfile.mkdtemp(), "summary.txt") with ( patch("main.JOB_SUMMARY_ENABLED", True), patch("main.GITHUB_STEP_SUMMARY", summary_path), - patch("main.read_result_file", return_value=None), ): - rc = main.add_job_summary() + rc = main.add_job_summary([pass_scope("Branch")]) self.assertEqual(rc, 0) with open(summary_path, encoding="utf-8") as file_obj: content = file_obj.read() - self.assertIn(main.SUCCESS_TITLE, content) + self.assertIn("All checks passed", content) - def test_failure_writes_failure_title(self): - summary_path = os.path.join(self._tmpdir, "summary.txt") + def test_failure_returns_nonzero(self): + summary_path = os.path.join(tempfile.mkdtemp(), "summary.txt") with ( patch("main.JOB_SUMMARY_ENABLED", True), patch("main.GITHUB_STEP_SUMMARY", summary_path), - patch("main.read_result_file", return_value="bad commit message"), ): - rc = main.add_job_summary() + rc = main.add_job_summary([fail_scope()]) self.assertEqual(rc, 1) with open(summary_path, encoding="utf-8") as file_obj: content = file_obj.read() - self.assertIn(main.FAILURE_TITLE, content) - self.assertIn("bad commit message", content) + self.assertIn("| Scope | Failed checks | Result |", content) + self.assertIn("❌", content) -class TestAddPrComments(unittest.TestCase): - def setUp(self): - import tempfile +class TestSetResultOutput(unittest.TestCase): + def test_writes_heredoc_json(self): + output_path = os.path.join(tempfile.mkdtemp(), "output.txt") + with patch.dict(os.environ, {"GITHUB_OUTPUT": output_path}): + main.set_result_output([fail_scope("Commit 1/1"), pass_scope("Branch")]) + with open(output_path, encoding="utf-8") as file_obj: + content = file_obj.read() + self.assertIn("result< Date: Tue, 4 Aug 2026 21:22:45 +0300 Subject: [PATCH 02/18] fix: make stdout UTF-8 for Windows runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emoji and check marks (✔ ✖ ❌) cannot be encoded by the default cp1252 codec on Windows, crashing the action. Reconfigure stdout/stderr to UTF-8, matching commit-check core's _reconfigure_io. --- main.py | 9 +++++++++ main_test.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/main.py b/main.py index a0b9ec0..7f0b58f 100755 --- a/main.py +++ b/main.py @@ -40,6 +40,14 @@ def env_flag(name: str, default: str = "false") -> bool: return os.getenv(name, default).lower() == "true" +def _reconfigure_io() -> None: + """Reconfigure stdout/stderr to UTF-8 so emoji and check marks never + crash on runners with legacy encodings (e.g. cp1252 on Windows).""" + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + + MESSAGE_ENABLED = env_flag("MESSAGE") BRANCH_ENABLED = env_flag("BRANCH") AUTHOR_NAME_ENABLED = env_flag("AUTHOR_NAME") @@ -646,6 +654,7 @@ def log_error_and_exit(ret_code: int, results: list[ScopeResult]) -> None: def main(): """Main function to run commit-check and render all output surfaces.""" + _reconfigure_io() log_env_vars() ret_code, results = run_commit_check() diff --git a/main_test.py b/main_test.py index d0d3f91..6764c32 100644 --- a/main_test.py +++ b/main_test.py @@ -81,6 +81,40 @@ def test_missing_uses_default(self): self.assertTrue(main.env_flag("FEATURE_FLAG", default="true")) +class TestReconfigureIo(unittest.TestCase): + def test_reconfigures_streams_to_utf8(self): + class FakeStream: + def __init__(self): + self.reconfigured = None + + def reconfigure(self, **kwargs): + self.reconfigured = kwargs + + fake_out = FakeStream() + fake_err = FakeStream() + with ( + patch.object(sys, "stdout", fake_out), + patch.object(sys, "stderr", fake_err), + ): + main._reconfigure_io() + self.assertEqual( + fake_out.reconfigured, {"encoding": "utf-8", "errors": "replace"} + ) + self.assertEqual( + fake_err.reconfigured, {"encoding": "utf-8", "errors": "replace"} + ) + + def test_streams_without_reconfigure_are_ignored(self): + class NoopStream: + pass + + with ( + patch.object(sys, "stdout", NoopStream()), + patch.object(sys, "stderr", NoopStream()), + ): + main._reconfigure_io() # should not raise + + class TestBuildCheckArgs(unittest.TestCase): def test_all_true(self): with ( From bd5f63d0007d2abbbcb447cde0d8a46370867e81 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 4 Aug 2026 21:39:26 +0300 Subject: [PATCH 03/18] refactor: unify job summary and PR comment rendering Use a single report renderer for both surfaces with a plain '# Commit Check' title (dropping 'Policy Report') and identical content on success and failure. PR comment matching also accepts the old hyphenated title so existing comments are still updated rather than duplicated. --- main.py | 44 +++++++++++++++++++++----------------------- main_test.py | 29 +++++++++++++++++------------ 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/main.py b/main.py index 7f0b58f..372e61e 100755 --- a/main.py +++ b/main.py @@ -20,8 +20,8 @@ from typing import Any # Constants for message titles -SUCCESS_TITLE = "# Commit-Check ✔️" -FAILURE_TITLE = "# Commit-Check ❌" +SUCCESS_TITLE = "# Commit Check ✔️" +FAILURE_TITLE = "# Commit Check ❌" COMMIT_MESSAGE_DELIMITER = "\x00" RULES_URL = "https://commit-check.com/rules/" @@ -416,45 +416,41 @@ def _markdown_details(results: list[ScopeResult]) -> str: return "\n".join(sections) -def render_job_summary(results: list[ScopeResult]) -> str: - """Create the Markdown body for the GitHub job summary.""" - header = "# Commit Check Policy Report" +def render_report(results: list[ScopeResult], include_footer: bool = True) -> str: + """Render the Markdown report shared by the job summary and PR comment. + + All checks passing collapses to a single success line; failures render + a scope table with rule links plus collapsible failure details. + """ if all(scope.status == "pass" for scope in results): scopes = "scope" if len(results) == 1 else "scopes" - return f"{header}\n\n\u2705 **All checks passed** ({len(results)} {scopes})" + return f"{SUCCESS_TITLE} All checks passed ({len(results)} {scopes})" failures = _failure_count(results) unit = "failure" if failures == 1 else "failures" scopes = "scope" if len(results) == 1 else "scopes" lines = [ - header, + FAILURE_TITLE, "", f"**{failures} {unit}** across {len(results)} {scopes}", "", _markdown_table(results), "", _markdown_details(results), - "", - f"_Rules reference: {RULES_URL}_", ] + if include_footer: + lines.extend(["", f"_Rules reference: {RULES_URL}_"]) return "\n".join(lines) -def render_pr_comment(results: list[ScopeResult]) -> str: - """Create the Markdown body for the PR comment.""" - if all(scope.status == "pass" for scope in results): - return f"{SUCCESS_TITLE} All checks passed" +def render_job_summary(results: list[ScopeResult]) -> str: + """Create the Markdown body for the GitHub job summary.""" + return render_report(results, include_footer=True) - failures = _failure_count(results) - unit = "failure" if failures == 1 else "failures" - lines = [ - f"{FAILURE_TITLE} {failures} {unit}", - "", - _markdown_table(results), - "", - _markdown_details(results), - ] - return "\n".join(lines) + +def render_pr_comment(results: list[ScopeResult]) -> str: + """Create the Markdown body for the PR comment (same report as summary).""" + return render_report(results, include_footer=True) def build_result_body(result_text: str | None) -> str: @@ -610,6 +606,8 @@ def add_pr_comments(results: list[ScopeResult]) -> int: c for c in comments if c.body.startswith(SUCCESS_TITLE) or c.body.startswith(FAILURE_TITLE) + # Match comments from older versions that used a hyphenated title. + or c.body.startswith("# Commit-Check") ] if matching_comments: diff --git a/main_test.py b/main_test.py index 6764c32..219dc49 100644 --- a/main_test.py +++ b/main_test.py @@ -631,11 +631,12 @@ def test_raw_text_fallback_is_printed(self): class TestRenderJobSummary(unittest.TestCase): def test_all_pass(self): body = main.render_job_summary([pass_scope("Branch")]) - self.assertIn("# Commit Check Policy Report", body) - self.assertIn("✅ **All checks passed** (1 scope)", body) + self.assertTrue(body.startswith(main.SUCCESS_TITLE)) + self.assertIn("All checks passed (1 scope)", body) def test_failure_renders_table_with_rule_links(self): body = main.render_job_summary([fail_scope("Commit 1/1")]) + self.assertTrue(body.startswith(main.FAILURE_TITLE)) self.assertIn("**1 failure** across 1 scope", body) self.assertIn("| Scope | Failed checks | Result |", body) self.assertIn( @@ -653,16 +654,20 @@ def test_pass_scope_renders_checkmark(self): class TestRenderPrComment(unittest.TestCase): - def test_all_pass_keeps_success_prefix(self): - body = main.render_pr_comment([pass_scope("Branch")]) - self.assertTrue(body.startswith(main.SUCCESS_TITLE)) - self.assertIn("All checks passed", body) - - def test_failure_keeps_failure_prefix_with_count(self): - body = main.render_pr_comment([fail_scope("Commit 1/1")]) - self.assertTrue(body.startswith(main.FAILURE_TITLE)) - self.assertIn("1 failure", body) - self.assertIn("| Scope | Failed checks | Result |", body) + def test_all_pass_matches_job_summary(self): + comment = main.render_pr_comment([pass_scope("Branch")]) + summary = main.render_job_summary([pass_scope("Branch")]) + self.assertEqual(comment, summary) + self.assertTrue(comment.startswith(main.SUCCESS_TITLE)) + self.assertIn("All checks passed (1 scope)", comment) + + def test_failure_matches_job_summary(self): + comment = main.render_pr_comment([fail_scope("Commit 1/1")]) + summary = main.render_job_summary([fail_scope("Commit 1/1")]) + self.assertEqual(comment, summary) + self.assertTrue(comment.startswith(main.FAILURE_TITLE)) + self.assertIn("**1 failure** across 1 scope", comment) + self.assertIn("| Scope | Failed checks | Result |", comment) class TestBuildResultBody(unittest.TestCase): From 56a590d99180865e618862bc625442695303c6c1 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 4 Aug 2026 21:43:21 +0300 Subject: [PATCH 04/18] refactor: split report title and status into separate lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report now opens with a plain '# Commit Check' title line followed by the status line ('✅ All checks passed (N scopes)' or the failure count), keeping the PR comment and job summary identical. --- main.py | 23 ++++++++++++----------- main_test.py | 14 +++++++------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/main.py b/main.py index 372e61e..169fc68 100755 --- a/main.py +++ b/main.py @@ -19,9 +19,8 @@ from dataclasses import dataclass, field from typing import Any -# Constants for message titles -SUCCESS_TITLE = "# Commit Check ✔️" -FAILURE_TITLE = "# Commit Check ❌" +# Constant for the report title +REPORT_TITLE = "# Commit Check" COMMIT_MESSAGE_DELIMITER = "\x00" RULES_URL = "https://commit-check.com/rules/" @@ -419,20 +418,22 @@ def _markdown_details(results: list[ScopeResult]) -> str: def render_report(results: list[ScopeResult], include_footer: bool = True) -> str: """Render the Markdown report shared by the job summary and PR comment. - All checks passing collapses to a single success line; failures render - a scope table with rule links plus collapsible failure details. + The report opens with the plain title line followed by the status line: + ``✅ All checks passed (N scopes)`` on success, or the failure count on + failure, followed by a scope table with rule links and collapsible + failure details. """ if all(scope.status == "pass" for scope in results): scopes = "scope" if len(results) == 1 else "scopes" - return f"{SUCCESS_TITLE} All checks passed ({len(results)} {scopes})" + return f"{REPORT_TITLE}\n\n✅ All checks passed ({len(results)} {scopes})" failures = _failure_count(results) unit = "failure" if failures == 1 else "failures" scopes = "scope" if len(results) == 1 else "scopes" lines = [ - FAILURE_TITLE, + REPORT_TITLE, "", - f"**{failures} {unit}** across {len(results)} {scopes}", + f"❌ **{failures} {unit}** across {len(results)} {scopes}", "", _markdown_table(results), "", @@ -456,8 +457,8 @@ def render_pr_comment(results: list[ScopeResult]) -> str: def build_result_body(result_text: str | None) -> str: """Legacy helper kept for backward compatibility with existing callers.""" if result_text is None: - return SUCCESS_TITLE - return f"{FAILURE_TITLE}\n```\n{result_text}\n```" + return REPORT_TITLE + return f"{REPORT_TITLE}\n```\n{result_text}\n```" # --------------------------------------------------------------------------- @@ -605,7 +606,7 @@ def add_pr_comments(results: list[ScopeResult]) -> int: matching_comments = [ c for c in comments - if c.body.startswith(SUCCESS_TITLE) or c.body.startswith(FAILURE_TITLE) + if c.body.startswith(REPORT_TITLE) # Match comments from older versions that used a hyphenated title. or c.body.startswith("# Commit-Check") ] diff --git a/main_test.py b/main_test.py index 219dc49..15989e0 100644 --- a/main_test.py +++ b/main_test.py @@ -631,12 +631,12 @@ def test_raw_text_fallback_is_printed(self): class TestRenderJobSummary(unittest.TestCase): def test_all_pass(self): body = main.render_job_summary([pass_scope("Branch")]) - self.assertTrue(body.startswith(main.SUCCESS_TITLE)) + self.assertTrue(body.startswith(main.REPORT_TITLE)) self.assertIn("All checks passed (1 scope)", body) def test_failure_renders_table_with_rule_links(self): body = main.render_job_summary([fail_scope("Commit 1/1")]) - self.assertTrue(body.startswith(main.FAILURE_TITLE)) + self.assertTrue(body.startswith(main.REPORT_TITLE)) self.assertIn("**1 failure** across 1 scope", body) self.assertIn("| Scope | Failed checks | Result |", body) self.assertIn( @@ -658,25 +658,25 @@ def test_all_pass_matches_job_summary(self): comment = main.render_pr_comment([pass_scope("Branch")]) summary = main.render_job_summary([pass_scope("Branch")]) self.assertEqual(comment, summary) - self.assertTrue(comment.startswith(main.SUCCESS_TITLE)) + self.assertTrue(comment.startswith(main.REPORT_TITLE)) self.assertIn("All checks passed (1 scope)", comment) def test_failure_matches_job_summary(self): comment = main.render_pr_comment([fail_scope("Commit 1/1")]) summary = main.render_job_summary([fail_scope("Commit 1/1")]) self.assertEqual(comment, summary) - self.assertTrue(comment.startswith(main.FAILURE_TITLE)) + self.assertTrue(comment.startswith(main.REPORT_TITLE)) self.assertIn("**1 failure** across 1 scope", comment) self.assertIn("| Scope | Failed checks | Result |", comment) class TestBuildResultBody(unittest.TestCase): def test_success_body(self): - self.assertEqual(main.build_result_body(None), main.SUCCESS_TITLE) + self.assertEqual(main.build_result_body(None), main.REPORT_TITLE) def test_failure_body(self): result = main.build_result_body("bad commit") - self.assertIn(main.FAILURE_TITLE, result) + self.assertIn(main.REPORT_TITLE, result) self.assertIn("bad commit", result) @@ -800,7 +800,7 @@ def test_creates_comment_with_rendered_body(self): self.assertEqual(rc, 1) self.assertEqual(mock_pull_request.create_comment.call_count, 1) body = mock_pull_request.create_comment.call_args[1]["body"] - self.assertTrue(body.startswith(main.FAILURE_TITLE)) + self.assertTrue(body.startswith(main.REPORT_TITLE)) self.assertIn("| Scope | Failed checks | Result |", body) def test_updates_existing_comment_when_changed(self): From aeb58edf7e8d6e0d3828e327865f9f97960b3269 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 4 Aug 2026 21:48:52 +0300 Subject: [PATCH 05/18] feat: show passed checks in a collapsible section on success The success report now includes a '
' block listing every check that passed per scope, so users can confirm which rules were actually evaluated without leaving the summary or PR comment. --- main.py | 24 +++++++++++++++++++++++- main_test.py | 4 ++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 169fc68..d66d06d 100755 --- a/main.py +++ b/main.py @@ -415,6 +415,21 @@ def _markdown_details(results: list[ScopeResult]) -> str: return "\n".join(sections) +def _markdown_passed_details(results: list[ScopeResult]) -> str: + """Render the collapsible section listing which checks passed per scope.""" + rows = ["| Scope | Passed checks |", "|---|---|"] + for scope in results: + passed = [c for c in scope.checks if c["status"] == "pass"] + if passed: + links = ", ".join(_rule_markdown_link(c) for c in passed) + else: + links = "\u2014" + rows.append(f"| {scope.label} | {links} |") + return "\n".join( + ["
", "Show details", "", *rows, "", "
"] + ) + + def render_report(results: list[ScopeResult], include_footer: bool = True) -> str: """Render the Markdown report shared by the job summary and PR comment. @@ -425,7 +440,14 @@ def render_report(results: list[ScopeResult], include_footer: bool = True) -> st """ if all(scope.status == "pass" for scope in results): scopes = "scope" if len(results) == 1 else "scopes" - return f"{REPORT_TITLE}\n\n✅ All checks passed ({len(results)} {scopes})" + lines = [ + REPORT_TITLE, + "", + f"✅ All checks passed ({len(results)} {scopes})", + "", + _markdown_passed_details(results), + ] + return "\n".join(lines) failures = _failure_count(results) unit = "failure" if failures == 1 else "failures" diff --git a/main_test.py b/main_test.py index 15989e0..ed0f4ff 100644 --- a/main_test.py +++ b/main_test.py @@ -633,6 +633,10 @@ def test_all_pass(self): body = main.render_job_summary([pass_scope("Branch")]) self.assertTrue(body.startswith(main.REPORT_TITLE)) self.assertIn("All checks passed (1 scope)", body) + self.assertIn("
", body) + self.assertIn("Show details", body) + self.assertIn("| Scope | Passed checks |", body) + self.assertIn("| Branch | CC001 branch |", body) def test_failure_renders_table_with_rule_links(self): body = main.render_job_summary([fail_scope("Commit 1/1")]) From ccbfcd05f618c175e976b48333be6d4b86a5d055 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 4 Aug 2026 21:52:31 +0300 Subject: [PATCH 06/18] refactor: render passed checks like the step log in the details block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the passed-checks table with a fenced block mirroring the step log layout (group name followed by indented ✔ scope lines), so the success report reads the same as the action log. --- main.py | 24 ++++++++++++------------ main_test.py | 21 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/main.py b/main.py index d66d06d..975cc99 100755 --- a/main.py +++ b/main.py @@ -416,18 +416,18 @@ def _markdown_details(results: list[ScopeResult]) -> str: def _markdown_passed_details(results: list[ScopeResult]) -> str: - """Render the collapsible section listing which checks passed per scope.""" - rows = ["| Scope | Passed checks |", "|---|---|"] - for scope in results: - passed = [c for c in scope.checks if c["status"] == "pass"] - if passed: - links = ", ".join(_rule_markdown_link(c) for c in passed) - else: - links = "\u2014" - rows.append(f"| {scope.label} | {links} |") - return "\n".join( - ["
", "Show details", "", *rows, "", "
"] - ) + """Render the collapsible section listing passed checks per scope. + + Mirrors the step log layout (group name followed by indented ✔ scope + lines) inside a fenced block so it reads like the action log. + """ + lines = ["
", "Show details", "", "```text"] + for group_name, scopes in _grouped(results): + lines.append(group_name) + for scope in scopes: + lines.append(f" ✔ {scope.label}") + lines.extend(["```", "", "
"]) + return "\n".join(lines) def render_report(results: list[ScopeResult], include_footer: bool = True) -> str: diff --git a/main_test.py b/main_test.py index ed0f4ff..81f7e6d 100644 --- a/main_test.py +++ b/main_test.py @@ -635,8 +635,25 @@ def test_all_pass(self): self.assertIn("All checks passed (1 scope)", body) self.assertIn("
", body) self.assertIn("Show details", body) - self.assertIn("| Scope | Passed checks |", body) - self.assertIn("| Branch | CC001 branch |", body) + self.assertIn("```text", body) + self.assertIn("Branch", body) + self.assertIn(" ✔ Branch", body) + + def test_all_pass_groups_scopes_like_step_log(self): + results = [ + pass_scope("PR title"), + pass_scope("Commit 1/2"), + pass_scope("Commit 2/2"), + pass_scope("Branch"), + pass_scope("Author name"), + pass_scope("Author email"), + ] + body = main.render_job_summary(results) + # Group headers in the details block mirror the step log ordering. + self.assertLess(body.index("Commit message"), body.index("Branch")) + self.assertLess(body.index("Branch"), body.index("Author")) + for scope in results: + self.assertIn(f" ✔ {scope.label}", body) def test_failure_renders_table_with_rule_links(self): body = main.render_job_summary([fail_scope("Commit 1/1")]) From 775fc7f1ba4a844c0d2a88ec9603accf03ed9cb5 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 4 Aug 2026 22:03:40 +0300 Subject: [PATCH 07/18] feat: show checked values in the success details block Each scope line now shows the concrete value that was checked (PR title, commit subject, branch name, author name/email), truncated to one line, so the details block reads like the step log. --- main.py | 24 ++++++++++++++++++++++-- main_test.py | 35 +++++++++++++++++++++++------------ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/main.py b/main.py index 975cc99..f35874b 100755 --- a/main.py +++ b/main.py @@ -415,17 +415,37 @@ def _markdown_details(results: list[ScopeResult]) -> str: return "\n".join(sections) +def _scope_value(scope: ScopeResult, max_len: int = 80) -> str: + """First non-empty check value for a scope, trimmed to a single line. + + The value is the concrete thing that was checked (PR title, commit + subject, branch name, author name/email) and reads naturally next to + the scope label in the success details. + """ + for check in scope.checks: + value = check.get("value", "") + if value: + first_line = value.splitlines()[0].strip() + if len(first_line) > max_len: + return first_line[: max_len - 3] + "..." + return first_line + return "" + + def _markdown_passed_details(results: list[ScopeResult]) -> str: """Render the collapsible section listing passed checks per scope. Mirrors the step log layout (group name followed by indented ✔ scope - lines) inside a fenced block so it reads like the action log. + lines) inside a fenced block so it reads like the action log. Each + scope line also shows the concrete value that was checked. """ lines = ["
", "Show details", "", "```text"] for group_name, scopes in _grouped(results): lines.append(group_name) for scope in scopes: - lines.append(f" ✔ {scope.label}") + value = _scope_value(scope) + suffix = f" ({value})" if value else "" + lines.append(f" ✔ {scope.label}{suffix}") lines.extend(["```", "", "
"]) return "\n".join(lines) diff --git a/main_test.py b/main_test.py index 81f7e6d..6a8810d 100644 --- a/main_test.py +++ b/main_test.py @@ -46,8 +46,8 @@ def json_output(*checks) -> str: return json.dumps({"status": status, "checks": list(checks)}) -def pass_scope(label: str = "Branch") -> main.ScopeResult: - return main.ScopeResult(label=label, checks=[make_check("branch")]) +def pass_scope(label: str = "Branch", value: str = "") -> main.ScopeResult: + return main.ScopeResult(label=label, checks=[make_check("branch", value=value)]) def fail_scope(label: str = "Commit 1/1") -> main.ScopeResult: @@ -630,30 +630,41 @@ def test_raw_text_fallback_is_printed(self): class TestRenderJobSummary(unittest.TestCase): def test_all_pass(self): - body = main.render_job_summary([pass_scope("Branch")]) + body = main.render_job_summary([pass_scope("Branch", value="main")]) self.assertTrue(body.startswith(main.REPORT_TITLE)) self.assertIn("All checks passed (1 scope)", body) self.assertIn("
", body) self.assertIn("Show details", body) self.assertIn("```text", body) self.assertIn("Branch", body) - self.assertIn(" ✔ Branch", body) + self.assertIn(" ✔ Branch (main)", body) def test_all_pass_groups_scopes_like_step_log(self): results = [ - pass_scope("PR title"), - pass_scope("Commit 1/2"), - pass_scope("Commit 2/2"), - pass_scope("Branch"), - pass_scope("Author name"), - pass_scope("Author email"), + pass_scope("PR title", value="feat: add login page"), + pass_scope("Commit 1/2", value="feat: add user auth"), + pass_scope("Commit 2/2", value="fix: resolve timeout"), + pass_scope("Branch", value="feature/pr-12"), + pass_scope("Author name", value="Jane Doe"), + pass_scope("Author email", value="jane@example.com"), ] body = main.render_job_summary(results) # Group headers in the details block mirror the step log ordering. self.assertLess(body.index("Commit message"), body.index("Branch")) self.assertLess(body.index("Branch"), body.index("Author")) - for scope in results: - self.assertIn(f" ✔ {scope.label}", body) + self.assertIn(" ✔ PR title (feat: add login page)", body) + self.assertIn(" ✔ Branch (feature/pr-12)", body) + self.assertIn(" ✔ Author email (jane@example.com)", body) + + def test_all_pass_truncates_long_values(self): + long_value = "x" * 200 + body = main.render_job_summary([pass_scope("Commit 1/1", value=long_value)]) + self.assertIn(f" ✔ Commit 1/1 ({'x' * 77}...)", body) + + def test_all_pass_without_value_shows_plain_label(self): + body = main.render_job_summary([pass_scope("Branch")]) + self.assertIn(" ✔ Branch", body) + self.assertNotIn(" ✔ Branch (", body) def test_failure_renders_table_with_rule_links(self): body = main.render_job_summary([fail_scope("Commit 1/1")]) From 3f3afb1b62b0629eac67b83fcf433582369631f9 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 5 Aug 2026 02:52:14 +0300 Subject: [PATCH 08/18] refactor: truncate checked values at 60 chars in the details block Long commit subjects wrapped inside the fenced details block, misaligning the scope lines. Trimming the value at 60 characters keeps the full line (prefix + value + parentheses) short enough to stay on one line. --- main.py | 6 ++++-- main_test.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index f35874b..459889e 100755 --- a/main.py +++ b/main.py @@ -415,12 +415,14 @@ def _markdown_details(results: list[ScopeResult]) -> str: return "\n".join(sections) -def _scope_value(scope: ScopeResult, max_len: int = 80) -> str: +def _scope_value(scope: ScopeResult, max_len: int = 60) -> str: """First non-empty check value for a scope, trimmed to a single line. The value is the concrete thing that was checked (PR title, commit subject, branch name, author name/email) and reads naturally next to - the scope label in the success details. + the scope label in the success details. The 60-character cap keeps the + full line (prefix + value + parentheses) short enough to avoid wrapping + in the fenced details block. """ for check in scope.checks: value = check.get("value", "") diff --git a/main_test.py b/main_test.py index 6a8810d..d352660 100644 --- a/main_test.py +++ b/main_test.py @@ -659,7 +659,7 @@ def test_all_pass_groups_scopes_like_step_log(self): def test_all_pass_truncates_long_values(self): long_value = "x" * 200 body = main.render_job_summary([pass_scope("Commit 1/1", value=long_value)]) - self.assertIn(f" ✔ Commit 1/1 ({'x' * 77}...)", body) + self.assertIn(f" ✔ Commit 1/1 ({'x' * 57}...)", body) def test_all_pass_without_value_shows_plain_label(self): body = main.render_job_summary([pass_scope("Branch")]) From 532e52725607a7bfbf4dc38d403371e4f4b33527 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 5 Aug 2026 02:53:15 +0300 Subject: [PATCH 09/18] feat: show checked values in the failure table The scope table now carries a 'Checked value' column (mirroring the success details), so a failing report answers what exactly was checked without expanding the failure details. --- main.py | 17 +++++++++++++---- main_test.py | 13 +++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index 459889e..01b13c9 100755 --- a/main.py +++ b/main.py @@ -382,16 +382,25 @@ def _failure_count(results: list[ScopeResult]) -> int: def _markdown_table(results: list[ScopeResult]) -> str: - """Render the scope/result table shared by summary and PR comment.""" - rows = ["| Scope | Failed checks | Result |", "|---|---|---|"] + """Render the scope/result table shared by summary and PR comment. + + The checked-value column mirrors what the success details show per + scope, so a failing table still answers "what exactly was checked". + """ + rows = [ + "| Scope | Checked value | Failed checks | Result |", + "|---|---|---|---|", + ] for scope in results: + value = _scope_value(scope) + value_display = f"`{value}`" if value else "\u2014" if scope.status == "pass": - rows.append(f"| {scope.label} | \u2014 | \u2705 |") + rows.append(f"| {scope.label} | {value_display} | \u2014 | \u2705 |") else: links = " \u00b7 ".join( _rule_markdown_link(check) for check in scope.failures ) - rows.append(f"| {scope.label} | {links} | \u274c |") + rows.append(f"| {scope.label} | {value_display} | {links} | \u274c |") return "\n".join(rows) diff --git a/main_test.py b/main_test.py index d352660..88fd09d 100644 --- a/main_test.py +++ b/main_test.py @@ -670,9 +670,10 @@ def test_failure_renders_table_with_rule_links(self): body = main.render_job_summary([fail_scope("Commit 1/1")]) self.assertTrue(body.startswith(main.REPORT_TITLE)) self.assertIn("**1 failure** across 1 scope", body) - self.assertIn("| Scope | Failed checks | Result |", body) + self.assertIn("| Scope | Checked value | Failed checks | Result |", body) self.assertIn( - "| Commit 1/1 | [CC001 message](https://commit-check.com/rules/#cc001) | ❌ |", + "| Commit 1/1 | `bad message` | " + "[CC001 message](https://commit-check.com/rules/#cc001) | ❌ |", body, ) self.assertIn("
", body) @@ -682,7 +683,7 @@ def test_failure_renders_table_with_rule_links(self): def test_pass_scope_renders_checkmark(self): body = main.render_job_summary([pass_scope("Branch"), fail_scope("Commit 1/1")]) - self.assertIn("| Branch | — | ✅ |", body) + self.assertIn("| Branch | — | — | ✅ |", body) class TestRenderPrComment(unittest.TestCase): @@ -699,7 +700,7 @@ def test_failure_matches_job_summary(self): self.assertEqual(comment, summary) self.assertTrue(comment.startswith(main.REPORT_TITLE)) self.assertIn("**1 failure** across 1 scope", comment) - self.assertIn("| Scope | Failed checks | Result |", comment) + self.assertIn("| Scope | Checked value | Failed checks | Result |", comment) class TestBuildResultBody(unittest.TestCase): @@ -740,7 +741,7 @@ def test_failure_returns_nonzero(self): self.assertEqual(rc, 1) with open(summary_path, encoding="utf-8") as file_obj: content = file_obj.read() - self.assertIn("| Scope | Failed checks | Result |", content) + self.assertIn("| Scope | Checked value | Failed checks | Result |", content) self.assertIn("❌", content) @@ -833,7 +834,7 @@ def test_creates_comment_with_rendered_body(self): self.assertEqual(mock_pull_request.create_comment.call_count, 1) body = mock_pull_request.create_comment.call_args[1]["body"] self.assertTrue(body.startswith(main.REPORT_TITLE)) - self.assertIn("| Scope | Failed checks | Result |", body) + self.assertIn("| Scope | Checked value | Failed checks | Result |", body) def test_updates_existing_comment_when_changed(self): old_comment = MagicMock(body="# Commit-Check ❌ 0 failures") From 257ae28ec9f38a2280f630c9f1b5f78a6fdf432b Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 5 Aug 2026 02:58:45 +0300 Subject: [PATCH 10/18] feat: show only failed values in the table and all checks in details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report table now fills the checked-value column only for failed scopes so failures stand out, and the collapsible details block lists every scope's value in step-log style (✔/✖) with the failure reason and suggestion under each failing rule, unifying the pass and fail layouts. --- main.py | 55 +++++++++++++++++++++++++++++++--------------------- main_test.py | 18 ++++++++++++++--- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/main.py b/main.py index 01b13c9..6233938 100755 --- a/main.py +++ b/main.py @@ -384,19 +384,20 @@ def _failure_count(results: list[ScopeResult]) -> int: def _markdown_table(results: list[ScopeResult]) -> str: """Render the scope/result table shared by summary and PR comment. - The checked-value column mirrors what the success details show per - scope, so a failing table still answers "what exactly was checked". + The checked-value column only fills failed scopes: pass scopes keep a + dash so the failure stands out, and the full per-scope values live in + the collapsible details block. """ rows = [ "| Scope | Checked value | Failed checks | Result |", "|---|---|---|---|", ] for scope in results: - value = _scope_value(scope) - value_display = f"`{value}`" if value else "\u2014" if scope.status == "pass": - rows.append(f"| {scope.label} | {value_display} | \u2014 | \u2705 |") + rows.append(f"| {scope.label} | \u2014 | \u2014 | \u2705 |") else: + value = _scope_value(scope) + value_display = f"`{value}`" if value else "\u2014" links = " \u00b7 ".join( _rule_markdown_link(check) for check in scope.failures ) @@ -405,23 +406,33 @@ def _markdown_table(results: list[ScopeResult]) -> str: def _markdown_details(results: list[ScopeResult]) -> str: - """Render the collapsible failure details section.""" - sections: list[str] = ["
", "Failure details", ""] - for scope in results: - if scope.status == "pass": - continue - sections.append(f"**{scope.label}**") - sections.append("") - for check in scope.failures: - error = check.get("error", "") - sections.append(f"- **{_rule_markdown_link(check)}** \u2014 {error}") - if check.get("value"): - sections.append(f" - value: `{check['value']}`") - if check.get("suggest"): - sections.append(f" - suggest: {check['suggest']}") - sections.append("") - sections.append("
") - return "\n".join(sections) + """Render the collapsible details block with every scope's checked value. + + Mirrors the step log layout (group name, ✔/✖ scope lines with the + checked value) and adds the failure reason and suggestion under each + failing rule, so one expand answers both "what was checked" and + "what failed and why". + """ + lines = ["
", "Show details", "", "```text"] + for group_name, scopes in _grouped(results): + lines.append(group_name) + for scope in scopes: + value = _scope_value(scope) + suffix = f" ({value})" if value else "" + if scope.status == "pass": + lines.append(f" ✔ {scope.label}{suffix}") + continue + failures = scope.failures + count = f" ({len(failures)} failure{'s' if len(failures) != 1 else ''})" + lines.append(f" ✖ {scope.label}{count}") + for check in failures: + error = check.get("error", "") + first_line = error.splitlines()[0] if error else "check failed" + lines.append(f" {_rule_label(check)}: {first_line}") + if check.get("suggest"): + lines.append(f" Suggest: {check['suggest']}") + lines.extend(["```", "", "
"]) + return "\n".join(lines) def _scope_value(scope: ScopeResult, max_len: int = 60) -> str: diff --git a/main_test.py b/main_test.py index 88fd09d..4434ca0 100644 --- a/main_test.py +++ b/main_test.py @@ -677,11 +677,23 @@ def test_failure_renders_table_with_rule_links(self): body, ) self.assertIn("
", body) - self.assertIn("value: `bad message`", body) - self.assertIn("suggest: Use (): ", body) + self.assertIn("Show details", body) + self.assertIn("```text", body) + self.assertIn("✖ Commit 1/1 (1 failure)", body) + self.assertIn("CC001 message: The commit message should follow ", body) + self.assertIn("Suggest: Use (): ", body) self.assertIn("Rules reference: https://commit-check.com/rules/", body) - def test_pass_scope_renders_checkmark(self): + def test_failure_details_show_all_scopes_and_values(self): + results = [ + fail_scope("Commit 1/2"), + pass_scope("Commit 2/2", value="fix: resolve timeout"), + ] + body = main.render_job_summary(results) + self.assertIn("✔ Commit 2/2 (fix: resolve timeout)", body) + self.assertIn("✖ Commit 1/2 (1 failure)", body) + + def test_pass_scope_renders_checkmark_without_value(self): body = main.render_job_summary([pass_scope("Branch"), fail_scope("Commit 1/1")]) self.assertIn("| Branch | — | — | ✅ |", body) From bbba5cdcfd9bcba960359efcf9bbd8a3af267ca8 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 5 Aug 2026 03:03:26 +0300 Subject: [PATCH 11/18] feat: add CodSpeed-style pass/fail stats to the report header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status line now reads '❌ 2 failures · ✅ 4 passed (6 scopes)' on failure and '✅ 11 passed (11 scopes)' on success, mirroring the compact emoji-plus-count style of CodSpeed reports so the pass ratio is visible without scanning the table. --- main.py | 14 ++++++++------ main_test.py | 10 +++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/main.py b/main.py index 6233938..f4298fc 100755 --- a/main.py +++ b/main.py @@ -475,17 +475,17 @@ def _markdown_passed_details(results: list[ScopeResult]) -> str: def render_report(results: list[ScopeResult], include_footer: bool = True) -> str: """Render the Markdown report shared by the job summary and PR comment. - The report opens with the plain title line followed by the status line: - ``✅ All checks passed (N scopes)`` on success, or the failure count on - failure, followed by a scope table with rule links and collapsible - failure details. + The report opens with the plain title line followed by a CodSpeed-style + status line: ``✅ **N passed** (N scopes)`` on success, or + ``❌ **N failures** · ✅ **M passed** (N scopes)`` on failure, followed + by a scope table with rule links and collapsible details. """ if all(scope.status == "pass" for scope in results): scopes = "scope" if len(results) == 1 else "scopes" lines = [ REPORT_TITLE, "", - f"✅ All checks passed ({len(results)} {scopes})", + f"✅ **{len(results)} passed** ({len(results)} {scopes})", "", _markdown_passed_details(results), ] @@ -493,11 +493,13 @@ def render_report(results: list[ScopeResult], include_footer: bool = True) -> st failures = _failure_count(results) unit = "failure" if failures == 1 else "failures" + passed = sum(1 for scope in results if scope.status == "pass") scopes = "scope" if len(results) == 1 else "scopes" lines = [ REPORT_TITLE, "", - f"❌ **{failures} {unit}** across {len(results)} {scopes}", + f"❌ **{failures} {unit}** · ✅ **{passed} passed** " + f"({len(results)} {scopes})", "", _markdown_table(results), "", diff --git a/main_test.py b/main_test.py index 4434ca0..4564073 100644 --- a/main_test.py +++ b/main_test.py @@ -632,7 +632,7 @@ class TestRenderJobSummary(unittest.TestCase): def test_all_pass(self): body = main.render_job_summary([pass_scope("Branch", value="main")]) self.assertTrue(body.startswith(main.REPORT_TITLE)) - self.assertIn("All checks passed (1 scope)", body) + self.assertIn("✅ **1 passed** (1 scope)", body) self.assertIn("
", body) self.assertIn("Show details", body) self.assertIn("```text", body) @@ -669,7 +669,7 @@ def test_all_pass_without_value_shows_plain_label(self): def test_failure_renders_table_with_rule_links(self): body = main.render_job_summary([fail_scope("Commit 1/1")]) self.assertTrue(body.startswith(main.REPORT_TITLE)) - self.assertIn("**1 failure** across 1 scope", body) + self.assertIn("❌ **1 failure** · ✅ **0 passed** (1 scope)", body) self.assertIn("| Scope | Checked value | Failed checks | Result |", body) self.assertIn( "| Commit 1/1 | `bad message` | " @@ -704,14 +704,14 @@ def test_all_pass_matches_job_summary(self): summary = main.render_job_summary([pass_scope("Branch")]) self.assertEqual(comment, summary) self.assertTrue(comment.startswith(main.REPORT_TITLE)) - self.assertIn("All checks passed (1 scope)", comment) + self.assertIn("✅ **1 passed** (1 scope)", comment) def test_failure_matches_job_summary(self): comment = main.render_pr_comment([fail_scope("Commit 1/1")]) summary = main.render_job_summary([fail_scope("Commit 1/1")]) self.assertEqual(comment, summary) self.assertTrue(comment.startswith(main.REPORT_TITLE)) - self.assertIn("**1 failure** across 1 scope", comment) + self.assertIn("❌ **1 failure** · ✅ **0 passed** (1 scope)", comment) self.assertIn("| Scope | Checked value | Failed checks | Result |", comment) @@ -741,7 +741,7 @@ def test_success_writes_policy_report(self): self.assertEqual(rc, 0) with open(summary_path, encoding="utf-8") as file_obj: content = file_obj.read() - self.assertIn("All checks passed", content) + self.assertIn("✅ **1 passed** (1 scope)", content) def test_failure_returns_nonzero(self): summary_path = os.path.join(tempfile.mkdtemp(), "summary.txt") From 681bf183eaaf1131c821a6096e62bc4dba08f0b5 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 5 Aug 2026 03:07:10 +0300 Subject: [PATCH 12/18] feat: reduce the report table to failed scopes Passing scopes no longer pad the failure table with dash rows; the table lists only the failed scopes with their checked value and rule links, while the collapsible details block keeps the full pass/fail picture in step-log style. --- main.py | 20 ++++++++------------ main_test.py | 6 +++++- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/main.py b/main.py index f4298fc..9843c1a 100755 --- a/main.py +++ b/main.py @@ -382,11 +382,10 @@ def _failure_count(results: list[ScopeResult]) -> int: def _markdown_table(results: list[ScopeResult]) -> str: - """Render the scope/result table shared by summary and PR comment. + """Render the failure table shared by summary and PR comment. - The checked-value column only fills failed scopes: pass scopes keep a - dash so the failure stands out, and the full per-scope values live in - the collapsible details block. + Only failed scopes appear in the table so the failure stands out; the + full pass/fail picture lives in the collapsible details block. """ rows = [ "| Scope | Checked value | Failed checks | Result |", @@ -394,14 +393,11 @@ def _markdown_table(results: list[ScopeResult]) -> str: ] for scope in results: if scope.status == "pass": - rows.append(f"| {scope.label} | \u2014 | \u2014 | \u2705 |") - else: - value = _scope_value(scope) - value_display = f"`{value}`" if value else "\u2014" - links = " \u00b7 ".join( - _rule_markdown_link(check) for check in scope.failures - ) - rows.append(f"| {scope.label} | {value_display} | {links} | \u274c |") + continue + value = _scope_value(scope) + value_display = f"`{value}`" if value else "\u2014" + links = " \u00b7 ".join(_rule_markdown_link(check) for check in scope.failures) + rows.append(f"| {scope.label} | {value_display} | {links} | \u274c |") return "\n".join(rows) diff --git a/main_test.py b/main_test.py index 4564073..930eb33 100644 --- a/main_test.py +++ b/main_test.py @@ -695,7 +695,11 @@ def test_failure_details_show_all_scopes_and_values(self): def test_pass_scope_renders_checkmark_without_value(self): body = main.render_job_summary([pass_scope("Branch"), fail_scope("Commit 1/1")]) - self.assertIn("| Branch | — | — | ✅ |", body) + # Pass scopes stay out of the table; the details block carries them. + table = body.split("
")[0] + self.assertNotIn("| Branch |", table) + self.assertIn("| Commit 1/1 | `bad message` |", table) + self.assertIn("✔ Branch", body) class TestRenderPrComment(unittest.TestCase): From c63ebfd27da79c8d2b572114a1a76dda94570a55 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 5 Aug 2026 03:10:14 +0300 Subject: [PATCH 13/18] docs: document the report output spec and pin it with golden tests Add an output specification block above render_report showing the exact success and failure layouts, and golden tests that assert the full rendered report byte-for-byte so the spec stays accurate as the output evolves. --- main.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ main_test.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/main.py b/main.py index 9843c1a..a9f1d67 100755 --- a/main.py +++ b/main.py @@ -468,6 +468,69 @@ def _markdown_passed_details(results: list[ScopeResult]) -> str: return "\n".join(lines) +# --------------------------------------------------------------------------- +# Output specification +# +# The Markdown report shared by the job summary and the PR comment renders +# as follows (values are filled from ScopeResult data): +# +# Success: +# +# # Commit Check +# +# ✅ **11 passed** (11 scopes) +# +#
+# Show details +# +# ```text +# Commit message +# ✔ PR title (feat: add login page) +# ✔ Commit 1/11 (feat: add user auth) +# Branch +# ✔ Branch (feature/add-login) +# Author +# ✔ Author name (Jane Doe) +# ✔ Author email (jane@example.com) +# ``` +# +#
+# +# Failure: +# +# # Commit Check +# +# ❌ **2 failures** · ✅ **9 passed** (11 scopes) +# +# | Scope | Checked value | Failed checks | Result | +# |---|---|---|---| +# | Commit 2/11 | `bad msg` | [CC001 message](https://commit-check.com/rules/#cc001) | ❌ | +# +#
+# Show details +# +# ```text +# Commit message +# ✔ PR title (feat: add login page) +# ✖ Commit 2/11 (1 failure) +# CC001 message: The commit message should follow Conventional Commits. +# Suggest: Use (): +# Branch +# ✔ Branch (feature/add-login) +# ``` +# +#
+# +# _Rules reference: https://commit-check.com/rules/_ +# +# Notes: +# - The table lists only failed scopes; passing scopes live in the details. +# - Values are capped at 60 chars (… suffix) and shown for every scope in +# the details block, plus for failed scopes in the table. +# - The step log output is a separate plain-text rendering (render_step_log). +# --------------------------------------------------------------------------- + + def render_report(results: list[ScopeResult], include_footer: bool = True) -> str: """Render the Markdown report shared by the job summary and PR comment. diff --git a/main_test.py b/main_test.py index 930eb33..c11b542 100644 --- a/main_test.py +++ b/main_test.py @@ -629,6 +629,74 @@ def test_raw_text_fallback_is_printed(self): class TestRenderJobSummary(unittest.TestCase): + def test_success_golden_output(self): + """Pin the full success report so the spec stays visible and exact.""" + results = [ + pass_scope("PR title", value="feat: add login page"), + pass_scope("Commit 1/2", value="feat: add user auth"), + pass_scope("Commit 2/2", value="fix: resolve timeout"), + pass_scope("Branch", value="feature/add-login"), + ] + body = main.render_report(results) + self.assertEqual( + body, + "# Commit Check\n" + "\n" + "✅ **4 passed** (4 scopes)\n" + "\n" + "
\n" + "Show details\n" + "\n" + "```text\n" + "Commit message\n" + " ✔ PR title (feat: add login page)\n" + " ✔ Commit 1/2 (feat: add user auth)\n" + " ✔ Commit 2/2 (fix: resolve timeout)\n" + "Branch\n" + " ✔ Branch (feature/add-login)\n" + "```\n" + "\n" + "
", + ) + + def test_failure_golden_output(self): + """Pin the full failure report: failed row in the table, all in details.""" + results = [ + pass_scope("PR title", value="feat: add login page"), + fail_scope("Commit 2/2"), + pass_scope("Branch", value="feature/add-login"), + ] + body = main.render_report(results) + self.assertEqual( + body, + "# Commit Check\n" + "\n" + "❌ **1 failure** · ✅ **2 passed** (3 scopes)\n" + "\n" + "| Scope | Checked value | Failed checks | Result |\n" + "|---|---|---|---|\n" + "| Commit 2/2 | `bad message` | " + "[CC001 message](https://commit-check.com/rules/#cc001) | ❌ |\n" + "\n" + "
\n" + "Show details\n" + "\n" + "```text\n" + "Commit message\n" + " ✔ PR title (feat: add login page)\n" + " ✖ Commit 2/2 (1 failure)\n" + " CC001 message: The commit message should follow " + "Conventional Commits.\n" + " Suggest: Use (): \n" + "Branch\n" + " ✔ Branch (feature/add-login)\n" + "```\n" + "\n" + "
\n" + "\n" + "_Rules reference: https://commit-check.com/rules/_", + ) + def test_all_pass(self): body = main.render_job_summary([pass_scope("Branch", value="main")]) self.assertTrue(body.startswith(main.REPORT_TITLE)) From 886cd8e55d8d15371110f5fd4619d7dd1a62fb96 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 5 Aug 2026 05:49:13 +0000 Subject: [PATCH 14/18] fix: wire the result output through action.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The output was declared with only a description, and the step that produces it had no id. Composite actions do not forward step outputs automatically, so steps..outputs.result resolved to the empty string and the fromJSON call in the README documentation would have failed the calling workflow. The Python side was already writing the payload to $GITHUB_OUTPUT correctly; the mapping above it was missing. The unit tests could not catch this — they exercise set_result_output, not the action.yml plumbing around it. Also notes in the README that a downstream step reading the result needs dry-run or continue-on-error, since a failing check otherwise ends the job before that step runs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U9zFxq8V4qxG4aMzJhGBFn --- README.md | 6 ++++++ action.yml | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index 3edb9d5..d2cd37e 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,8 @@ Structured check results as JSON, available to downstream steps via ```yaml - uses: commit-check/commit-check-action@v2 id: commit-check + with: + dry-run: true # (1) - name: Inspect results run: | @@ -218,6 +220,10 @@ Structured check results as JSON, available to downstream steps via echo "Scopes: ${{ toJSON(fromJSON(steps.commit-check.outputs.result).scopes) }}" ``` +1. Without `dry-run`, a failing check ends the job before any later step runs. + Use `dry-run` (or `continue-on-error`) when a downstream step is meant to + read the result and decide for itself. + Each scope carries the check outcomes (`rule_id`, `check`, `status`, `value`, `error`, `suggest`, `docs_url`) exactly as produced by `commit-check --format json`, so downstream jobs can build their own reports diff --git a/action.yml b/action.yml index 4e19145..d02f20a 100644 --- a/action.yml +++ b/action.yml @@ -40,11 +40,16 @@ inputs: outputs: result: description: Structured check results as JSON (status + per-scope checks). Consume with fromJSON(steps..outputs.result). + # Composite actions do not forward step outputs automatically: without this + # mapping (and the step id it refers to) the output is always the empty + # string, and fromJSON('') fails the calling workflow. + value: ${{ steps.commit-check.outputs.result }} runs: using: "composite" steps: - name: Install dependencies and run commit-check + id: commit-check shell: bash run: | # Platform-specific settings From 9d12bd98e68fb8509e6f9c51f6220adb05ac55ab Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 5 Aug 2026 08:19:27 +0000 Subject: [PATCH 15/18] feat: rework the report format and identify comments by a hidden marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment ownership was decided by `body.startswith("# Commit Check")`, and every match but the newest was deleted. A person opening a comment with that heading would have had it removed with no trace. Comments now carry a hidden `` marker, the way Codecov, SonarQube and CodSpeed identify theirs, and deletion is restricted to comments carrying it. A report from an earlier version has no marker, so it is adopted in place — but only when a bot posted it, since the only other signal is the title a person can type by accident. The header counted two different things at once: failures were counted in checks while "passed" and the total counted scopes, so one commit failing two rules rendered "2 failures · 2 passed (3 scopes)" and the numbers did not reconcile. Counts are now checks throughout — "2 of 4 checks failed" — and the details summary repeats the same total so the two can be checked against each other. The table dropped its Result column: only failed scopes reach the table, so it read ❌ on every row. The title moved to h2 with the project logo beside it, an h1 being louder than anything else in a PR comment, and the footer now names the commit-check version that produced the result, which is the first thing worth knowing when an outcome looks wrong. Smaller things found while reading: - an ImportError from PyGithub made the `except GithubException` clause raise NameError, which propagates past the `except Exception` under it and would have killed a step designed to never be fatal - GITHUB_STEP_SUMMARY was read with os.environ[] at import time, so main.py could not be imported outside Actions - the two details renderers differed only by a branch; the unparsable-output fallback now shows in the report instead of rendering an empty scope - build_result_body had no caller but its own test, and include_footer was never passed False - re and tempfile were imported but unused - GITHUB_REPOSITORY was passed to get_repo() unchecked, which mypy flags once PyGithub types are available The logo ships as PNG in this repository rather than as the SVG on commit-check.com: GitHub proxies comment images through camo, which handles SVG unreliably, and a local asset keeps the report free of a cross-repository dependency. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U9zFxq8V4qxG4aMzJhGBFn --- assets/logo.png | Bin 0 -> 4994 bytes main.py | 296 +++++++++++++++++++++++++++++++----------------- main_test.py | 160 +++++++++++++++++++------- 3 files changed, 308 insertions(+), 148 deletions(-) create mode 100644 assets/logo.png diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..240abb178a49db7fe39cdc830ba82ca30b5d40a6 GIT binary patch literal 4994 zcmZWtWmHt(+Z{S2hmsHk1_X(rK^RhM1PN&nBm^V|L1GB$P^6?07#J~zAKeX-(%n)6 zLw86F`Ht_`|GDej^Wm&@&t3a@_I~zrqV;q%D9KsK0RR9cR8#c@ZVmZ2Nr`dq4h5Tj z+(K-trJ)MA`}ZK5QON)RoitQc+0Zw858>x!(bXLCt3FB4EbV-u93xYX=z@~Y_WTA? zMI?<$fL$ZYs;ZRkIhmu&BQ(fURQ|Nol>R8aS4w35=bbrH>CHn4S0PTBl<%&yux_7h znfUY`A2z?lA6K_}f?73#mQa(Jy(>hrr>&EN#Mt%zz}qb5k^dd{uSg|XlkLq&1c}lJ zDnp@4nVGqwL+<#nR%Po)Woo3O0Fi@eONU0#`jZHVSyH4vahAa&6Edw8jeydEO@m9k z0@4t{{@GT4G%r#qPD(P0JN()Kc2)A6gKf*@$$}z>$R*!Y`~bM|FCt+g(B4lMDRpY& zt(kV(`k)mK(2{{U#l{*$2}wTkyBz1$hwYisAbnZFu7u)KxX@8bun!P|o{E>G*F>g3 zXI7Tb*@cNNYDJm87GsMtqkuT7CwAw>CpX3vPQ;(>{bUc}NWPRymQbginiJDC{hCSh zr?B=-&5<>L_boY*5THUZ9q=}<_>|VpqB!-Sc`2kCrC8*1Y2V1L_XSC)lx9zN$n^)U z5plK{#2)x7bej6Z^+bT}oagJda;3;H1YQtefUH=N<#uM;88cK%?YWy`dv6FVwqnl? z98y}NzlQ^&GIXwEJ2gtzN@k?bE|qcyQDOdEN@h8Thba6Do)mt=$mO@?dsz#;PY)(5 zjS&1CA+f6RwL`sgWi?#2R!~1R2c%#>Q7f$ko?hHHls|Tz^}~NPK7~_1d6c^i1o|^_ zoJ#0b_{4^JB4MxMq%I1|8~=-OdG#cJAI<6oIjVp0(=ii|vA+WH6e|A%-`%4}e*AEy z>nUu$srs=i-EeYfTP^~ziaj=Zw*A-=KQnZf=JKZpF^WPFdwBoX-EKs|o;lD9;sNEa z^h}CsIgO^_LUu9g0bZNwkmy9sC{iMaIV(K^9+Xbzqjp1=+DJA@1XVs-1;hBt@x@fO zvZG>#tVnj2ifrHp>Hj|UUgs)jGZfNVDv4ADXe*yR$56ys2ENA}OtQIA-%q6^Ckj6h zX2}l$;^|2`y>8Nj?X9Ox7dn;C8o{Ko1L$FL$OY1Rx4n;Kz{{uWb5#>a;v6C#V z{*27TeCI|aS%|Z%yzAO+@YHCjl@f_N_AEHSiP?T@n_7YIoY47VIdwbP^Q;Z__DZ1gUA^Zr;jbI{k*UGy8sqw#>uHl!_u)JhK*^u?AyGOe7Ynu{N870k zuR)i0(FZ}OH2AjM{9;ER>rgvxW3QI#WckPdP*mJw3Ku-1&iJPO}i9aeF|)U49|Uk-yN08q+_-otN{59Hr8f7Tf`w_z^a5$v@=;M}ivmC$Hk7_u*gP$LLPLf-MZT^d4>2y5&RIM|ChFqTs;G(OTRY;t> zX!L`5UP2Dud7MNuFZ(D#$iz@D+jf;YAag-E9TXm)Ybg=QcG6RGGT}3 z8mNS%k?k5nD4UDeaB*Wy}oX4YLo_DA*Ev1UBI!@V=0fT z_U`NS^6GlAkwm~IE$-|@oQiDOrl--K>Yn|LC(2SxOw2z&-j|$9IryPCEC0p=^r-F< zT~AIeC<-6Q4dJo50BEVW-z9NHXv}`~B~P&e(xX_+ED|x5yBoYHPaWmsFEtFol#|g7 z0AF4iwhAATREX0A^mPEF_wKMfEkSjyT=_X<`ooxvNIrA4e~ZGSw=-AM13f+)xElAE z${zi-<#ZQP0c=(T_aZThOpFb8Qk%a=fo0!Ch@f>X?k2h69Kpze8ukpKH2c280qLGSh`4gzaL z+3sd!!uCx?+IC28W=quf{jWqcqTX!as;cX})o@hslm{-KOIwW99P)Bp94JZ(aoXHc zg#{dNocPpuoylJG-!hggy&LI&R|nT$7BMLYudkb#ic7}WNAef(ksVZ7;6chdc~q%| zvt;t9Xz$sskPB-|RcwVp=dn{ZwYt_8Fu6JBIsWI;iK%ap)pJA3XI9|BOH&iDhHOB+ z%QE4%tp7ZF4IOH4{?cD^)`7qwJfdSuHq?%pwY*7JV|<=~;+U z6PncG7p+tj&dBHkgZUr)d}w45Ff;4>$=M}g8m$WzKl;%B^PNu#Q8?%V+htJapk6a= zIA>dFurw;?dPsK)WKGvu-62Ql5Zw1pdIh#C>zqSBjqPZQAy1lpkEIAYpCywrsrH-g zL66l84^@{nK$26F$D32Y;h?%;58A?K;@I&PhsAstM-BI*$$Up6C*tYBfum1!ZzzD$ z{5CqXk}!^g?We?I+Wwym;yz8exZ4v`@P@$?t|tA;y{Lg3&_s%er;7?*yWIwtE2WRMRJ>CiFM_m&Kxt* zOwqJ{wybLuSq6|%8XrkZtNuy{nW_319?YlMP!^$OI0TGmRK6Et5S5VCcyZC`1b6;N z79Q3wNp5+d!BzW}u1|wTFbHjA+b>)5sr(1;NnZebq`i+89%x*%`*D%A;qKxjY)eg{Rlgp+JwKm}8ZlSsYPTOZ#H6zUtA|S%swg ze`)Vyhy~Rm5FDd)a(QuF(pekC5t`+-lfP>hAp>jRJESzBr+n}mauY(KY z+%@*NC{a8^gZmREw}lTxu?Hy&1P5h||}7wljm* zT^(NyzpO|kvhkbift1(TF=FrZ{c>cWYl$Y((njT`Nd5FJa6$ZeqLs(K8O@7pQCLsDsMEv=Npqc56c$e|yX;}LJZezNr3i*|yAVo=mI6nC8-2BQm{%$y~e#A9Wn1JwPJG(_^ZXD>>5=<3U4nk*&C9bjrc zh6&Dbk_F@;AoPd+NBqqreEXWxTip{ceiBXwW~n1OhlMkXWV#h{>R@gy!r48{Ut*Ps;>=ww{AC=xcvYcJXW-<;-#D z=OrzFcAhx-ww8<$PBsRHd3$!N`@{29g}Uq=YQCJFU|b3;Ez^{t5vBGq@Vrcn7p7>w z4q76z{av)Oo|`T2F#z9pb$-RByS+0E=jsv_f7{(W`{I9OCRikk?HxX|7?J;^?N$b z)h6fV82Apx?uJPF#XYiZ7`M@J1G;>xqtB>91>YB4+O7x+3UziQBwX3v`ZKPWl5sBt z3O?$oPh7II9@Bd7mv&Hj)h&)|0zxW1gd zv{8yzqn4OFxB-me@c~R~ysYOh67KOxHtBN#^yly!@<5iSE)#^YF%J~DY20^HNJH){ z7>yh{R=AALmvb#v10B&a+~^s8?2oLFo}7kFYyP?oqU#Untm*?OQgD3L!-H`PR#;7a0gL@@CKsvOQ{?wYzrx4$Kpo zLf(2};{^f@@oQ4;(C@QV+DyDPxR)%QLjM8;Q2E%bWN*4a7TP$>PhrN{w^fJ}}MrX2~pzPZc{*82fcvtpwCA=#}9)SY};lSwLpF6eQ@Olg(79{$4! z8S)~$Jh&!D$($8ekk#CJGmGzA(a@WcYC00I#-LT$zrvs=Ou7j*8- zIlxdRcGdx=>%M(`l2x}Fz_LSM<>v~AWZu(-?Q&;(2 zSiIyKrtj}gYc+kaa&$i&)K1k%#u-n>X-vQbD8>`?whj*L);k z3HcBLUn5r+rHNC&V93hlZgEI8{|S#3=t~#FhWPBaqVC_`ITFM~S;s13=YuR7+H> G!~PHT557