diff --git a/README.md b/README.md
index b15c89d..92bbe03 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ The GitHub Action for [Commit Check](https://github.com/commit-check/commit-chec
commit policy as code for teams and AI agents. It checks every commit of a pull
request — message, branch, author, and optionally the PR title and the AI
attribution in its description — against your `cchk.toml`, and reports the
-result in the job summary, as annotations on the diff and, if you want, as a PR
+result in the job summary, as annotations on the run and, if you want, as a PR
comment. It can also label the pull requests that AI tools wrote.
## Usage
@@ -113,34 +113,72 @@ the run. Every key: [configuration reference](https://commit-check.com/configura
## What it looks like
-A failing run opens with a count and a table of only what failed — each rule ID
-links to its documentation, each commit to itself — with the full tree one
-click away:
+A failing run says so in its heading, gives each group one line, and shows what
+failed as a diff: red is what was found, green is what to write instead. Each
+commit and rule is linked under it, and two folds hold the rest: how to fix it
+(a prompt for an AI coding agent, and the git commands) and the full list.
-> **Commit Check**
+>
**Commit Check failed**
>
-> ❌ **2 of 4 checks failed**
+>
Pull request: passed
+>
Commits: 1 of 2 failed
+>
Branch: failed
>
-> | Scope | Checked value | Failed checks |
-> |---|---|---|
-> | [Commit 2/2 (5584f46)](https://github.com/acme/widgets/commit/5584f462cc3c947b2ba8d3d1a5735571803ee159) | `bad msg` | [CC001 message](https://commit-check.com/rules/#cc001) |
-> | Branch | `Feature/Add-Login` | [CC201 branch](https://commit-check.com/rules/#cc201) |
+> ```diff
+> @@ 5584f46 @@
+> - bad msg
+> # Use ():
+> @@ Branch @@
+> - Feature/Add-Login
+> + feature/Add-Login
+> ```
+>
+> Commits: [`5584f46`](https://github.com/acme/widgets/commit/5584f462cc3c947b2ba8d3d1a5735571803ee159) | Rules: [CC001](https://commit-check.com/rules/#cc001) · [CC201](https://commit-check.com/rules/#cc201)
>
>
-> Show all 4 checks
+> How to fix
+>
+> **With an AI coding agent.** Paste this prompt:
+>
+> ```text
+> Fix these Commit Check findings without changing any code:
+> - Commit 5584f46 "bad msg": Use ():
+> - Branch: change "Feature/Add-Login" to "feature/Add-Login"
+> ```
+>
+> **Commit messages.** Reword it, then force-push:
+>
+> ```bash
+> git commit --amend
+> git push --force-with-lease
+> ```
+>
+> **Branch name.** Rename the branch and push it. A pull request cannot change its branch, so open a new one from the renamed branch:
+>
+> ```bash
+> git branch -m feature/Add-Login
+> git push -u origin HEAD
+> ```
+>
+> **Next time.** The [pre-commit hook](https://commit-check.com/guides/integrations/#as-a-pre-commit-hook) runs the same rules before you push.
+>
+>
+>
+>
+> All 4 checks
>
> ```text
> Pull request
-> ✔ PR title (feat: add login page)
-> Commit message
-> ✔ Commit 1/2 (d87faca) (feat: add login page)
-> ✖ Commit 2/2 (5584f46) (1 failure)
+> ✔ title feat: add login page
+> Commits
+> ✔ d87faca feat: add login page
+> ✖ 5584f46 bad msg
> CC001 message
> value: bad msg
> The commit message should follow Conventional Commits.
> Suggest: Use ():
> Branch
-> ✖ Branch (1 failure)
+> ✖ branch Feature/Add-Login
> CC201 branch
> value: Feature/Add-Login
> The branch should follow Conventional Branch.
@@ -150,36 +188,28 @@ click away:
>
>
>
-> _commit-check <version> · [Rules reference](https://commit-check.com/rules/)_
+> Checked by [Commit Check](https://commit-check.com) <version> · [Rules](https://commit-check.com/rules/) · [Check before you push](https://commit-check.com/guides/integrations/#as-a-pre-commit-hook)
The rest follow the same layout:
-- **Passed:** one line, `✅ All 3 checks passed`, with the tree folded away.
-- **Warned:** a rule under `warn` gets its own ⚠ row and counts as passed.
+- **Passed:** the heading reads "Commit Check passed", with the list folded away.
+- **Warned:** a rule under `warn` gets its own diff under "Warnings", a ⚠ in
+ the list, and counts as passed.
- **Skipped:** a run where nothing was validated — typically a bot listed in
- `ignore_authors` — reads `⊘`, never `✔`.
+ `ignore_authors` — reads "Commit Check skipped", never passed.
-The step log prints the same tree, plus one annotation per finding on the
-Files changed tab. With `pr-comments: true` the same report is posted to the
-pull request, and later runs edit that one comment instead of adding more.
+The step log prints the same list, each group folded under its own result, plus
+one annotation per finding on the run page. With `pr-comments: true` the same
+report is posted to the pull request, and later runs edit that one comment
+instead of adding more.
With `pr-body-ai-attribution: true` and `ai_attribution = "forbid"`, a
-description that credits an AI co-author fails the same way, and the report
-names the line to remove:
+description that credits an AI co-author fails the same way, and the diff is
+the line to remove:
-> ❌ **1 of 2 checks failed**
->
-> | Scope | Checked value | Failed checks |
-> |---|---|---|
-> | PR description | `Claude Code` | [CC013 ai-attribution](https://commit-check.com/rules/#cc013) |
->
-> ```text
-> Pull request
-> ✖ PR description (1 failure)
-> CC013 ai-attribution
-> value: Claude Code
-> AI attribution is forbidden in this project — detected: Claude Code
-> Suggest: In the description, remove "Co-Authored-By: Claude "
+> ```diff
+> @@ PR description @@
+> - Co-Authored-By: Claude
> ```
With `ai-label: true`, a pull request an AI tool wrote also gets the
diff --git a/docs/fork-pr-comments.md b/docs/fork-pr-comments.md
index 8c0bb4c..6582725 100644
--- a/docs/fork-pr-comments.md
+++ b/docs/fork-pr-comments.md
@@ -11,18 +11,18 @@ Everything except the comment. The action does not degrade on a fork PR:
| Surface | Fork PR | Why |
|---|---|---|
| The check's pass/fail status | ✅ works | the job's own conclusion |
-| `::error` annotations on the **Files changed** tab | ✅ works | workflow commands are written by the runner, not the API |
-| The **job summary** — the full report table and details | ✅ works | `$GITHUB_STEP_SUMMARY` is a file on the runner |
+| `::error` annotations on the run page | ✅ works | workflow commands are written by the runner, not the API |
+| The **job summary** — the full report | ✅ works | `$GITHUB_STEP_SUMMARY` is a file on the runner |
| The `result` output for later steps | ✅ works | `$GITHUB_OUTPUT` is a file on the runner |
| A **PR comment** | ❌ skipped | writing a comment needs the API, and the token is read-only |
So a contributor pushing to a fork already gets the red check, the per-finding annotations
-on their diff, and the whole report in the job summary. The action says so in the log:
+on the run page, and the whole report in the job summary. The action says so in the log:
```text
::warning::Skipping PR comment: pull requests from forked repositories cannot write
comments via the pull_request event (GITHUB_TOKEN is read-only for forks). The findings
-are in this job's summary and in the annotations on the Files changed tab.
+are in this job's summary and in the annotations on the run page.
```
(with `job-summary: false` the message names the annotations alone, since there is no
diff --git a/main.py b/main.py
index a12e051..6fdc056 100755
--- a/main.py
+++ b/main.py
@@ -6,8 +6,8 @@
them to three output surfaces:
* **step log** — grouped sections, then one ``::error`` annotation per finding
-* **job summary** — a Markdown policy report table
-* **PR comment** — a compact Markdown summary (idempotently updated)
+* **job summary** — a Markdown report: the verdict, then a diff of what failed
+* **PR comment** — the same report (idempotently updated)
and exposes the check data as JSON in the ``result`` action output.
"""
@@ -34,24 +34,6 @@
# CodSpeed all use for the same purpose.
COMMENT_MARKER = ""
-#: Report heading. h2 rather than h1: this renders inside a PR comment, where an
-#: h1 is louder than anything else on the page.
-#
-# Deliberately carries no logo. The org mark is a check inside a rounded tile,
-# which is the same object GitHub's ✅ is — same shape, same silhouette, only
-# the hue differs. Putting it immediately above the verdict line meant a failing
-# report opened with a tick and then said "❌ 2 of 4 checks failed": the first
-# symbol the eye lands on contradicted the second, and it did so precisely when
-# the reader most needs to read the result quickly.
-#
-# The verdict line is the one status signal in this report, and one is the right
-# number. "Commit Check" in words is unambiguous branding; a checkmark next to a
-# failure is not.
-#
-# Do not "fix" this by showing the logo only on success — that makes the logo's
-# presence itself a status signal, which is the same defect wearing a hat.
-REPORT_TITLE = "## Commit Check"
-
#: Prefixes of report bodies written by earlier versions, kept so the first run
#: after upgrading adopts the existing comment instead of posting a second one.
#: Drop these once a release has been out long enough.
@@ -163,6 +145,13 @@ class ScopeResult:
sha: str = ""
#: The AI tools the CLI found in what this scope checked (``ai_tools``).
ai_tools: list[str] = field(default_factory=list)
+ #: The text this scope checked (a commit message, the PR title), when the
+ #: action piped it in. The report compares it with a rule's ``fix`` to name
+ #: the lines to change. Never part of the ``result`` output.
+ text: str = ""
+ #: ``(removed, added)`` lines per rule, for a text too long to carry as a
+ #: ``fix`` (the PR description). Keyed by ``_rule_key``.
+ edits: dict[str, tuple[list[str], list[str]]] = field(default_factory=dict)
@property
def display_label(self) -> str:
@@ -482,6 +471,18 @@ def skip_when_no_ai_rule_ran(scope: ScopeResult) -> ScopeResult:
return scope
+def _message_value(scope: ScopeResult) -> str:
+ """The whole commit message, as the CLI's ``message`` rule reports it."""
+ return next(
+ (
+ str(c.get("value") or "")
+ for c in scope.checks
+ if c.get("check") == "message"
+ ),
+ "",
+ )
+
+
def keep_ai_rules(scope: ScopeResult) -> ScopeResult:
"""Narrow a commit message scope to the AI rules (CC013 to CC017).
@@ -494,6 +495,9 @@ def keep_ai_rules(scope: ScopeResult) -> ScopeResult:
"""
if not MESSAGE_AI_ATTRIBUTION_ENABLED or scope.raw_text:
return scope
+ # The message rule's value is the whole message: keep it for the report
+ # before the rule itself is dropped (outside a PR nothing was piped in).
+ scope.text = scope.text or _message_value(scope)
scope.checks = [c for c in scope.checks if c.get("rule_id") in AI_RULE_IDS]
return skip_when_no_ai_rule_ran(scope)
@@ -529,6 +533,11 @@ def get_pr_opener_tool() -> str | None:
return AI_AGENT_BOTS.get(str(login).lower())
+def _rule_key(check: dict[str, str]) -> str:
+ """What names a rule within one scope: its ID, or its name without one."""
+ return check.get("rule_id") or check.get("check", "")
+
+
def lines_removed(text: str, fixed: str) -> list[str]:
"""The non-blank lines of ``text`` that ``fixed`` no longer has, in order.
@@ -560,6 +569,7 @@ def name_the_line_changes(scope: ScopeResult, body: str) -> None:
continue
removed = lines_removed(body, fixed)
added = lines_removed(fixed, body)
+ scope.edits[_rule_key(check)] = (removed, added)
parts = []
if removed:
parts.append("remove " + "; ".join(f'"{line}"' for line in removed))
@@ -831,6 +841,7 @@ def check_scope(
checks=data.get("checks", []),
sha=sha,
ai_tools=[str(t) for t in tools] if isinstance(tools, list) else [],
+ text=input_text or "",
)
return ScopeResult(label=label, raw_text=raw, sha=sha)
@@ -1034,6 +1045,35 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
# Rendering
# ---------------------------------------------------------------------------
+#: Home of the status marks the report shows: static PNGs in the organization's
+#: ``.github`` repository, so nothing has to be running for them to load.
+STATUS_MARK_URL = (
+ "https://raw.githubusercontent.com/commit-check/.github/main/branding/status"
+)
+SITE_URL = "https://commit-check.com"
+#: Where a reader sets the same rules up locally, so the next push passes.
+LOCAL_HOOK_URL = "https://commit-check.com/guides/integrations/#as-a-pre-commit-hook"
+
+#: One glyph per status. The step log prints it, and the report's images carry
+#: it as their alt text, so a report whose images cannot load still reads.
+STATUS_GLYPHS = {"pass": "✔", "fail": "✖", "warn": "⚠", "skip": "⊘"}
+#: File name of each status mark, and the word the heading uses for it.
+STATUS_WORDS = {
+ "pass": "passed",
+ "fail": "failed",
+ "warn": "warned",
+ "skip": "skipped",
+}
+#: Step log colours. GitHub renders these; bright black is its grey.
+ANSI = {"pass": "\033[32m", "fail": "\033[31m", "warn": "\033[33m", "skip": "\033[90m"}
+ANSI_BOLD, ANSI_GREY, ANSI_RESET = "\033[1m", "\033[90m", "\033[0m"
+
+#: Failing items the report shows before folding the rest away. A hunk is two
+#: or three lines, and folding one or two leftovers would cost a click and
+#: save nothing, so up to ``MAX_HUNKS + HUNK_SLACK`` are shown unfolded.
+MAX_HUNKS = 5
+HUNK_SLACK = 2
+
def _rule_label(check: dict[str, str]) -> str:
"""Human-readable label for a check: ``CC001 message`` (kebab-case)."""
@@ -1042,9 +1082,9 @@ def _rule_label(check: dict[str, str]) -> str:
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)
+def _rule_ref(check: dict[str, str]) -> str:
+ """Short Markdown link for a rule: ``[CC001](docs_url)``."""
+ label = check.get("rule_id") or check.get("check", "").replace("_", "-")
docs_url = check.get("docs_url", "")
return f"[{label}]({docs_url})" if docs_url else label
@@ -1054,7 +1094,7 @@ def _scope_group(label: str) -> str:
if label in ("PR title", "PR description"):
return "Pull request"
if label.startswith("Commit"):
- return "Commit message"
+ return "Commits"
if label.startswith("Author"):
return "Author"
return label
@@ -1072,14 +1112,157 @@ def _grouped(results: list[ScopeResult]) -> list[tuple[str, list[ScopeResult]]]:
return groups
-def _finding_lines(check: dict[str, str], include_error: bool) -> list[str]:
+def _plural(count: int, word: str) -> str:
+ """``1 warning``, ``2 warnings``."""
+ return f"{count} {word}{'' if count == 1 else 's'}"
+
+
+def _group_status(scopes: list[ScopeResult]) -> tuple[str, str]:
+ """A group's worst outcome and the few words that say it: ``3 of 4 failed``.
+
+ The same words head the group in the step log and its line in the
+ report, so a folded log and a skimmed comment both say where it went
+ wrong. A skipped scope never counts as passed, and a group is ``skip``
+ only when nothing in it ran.
+ """
+ total = len(scopes)
+ failed = sum(1 for scope in scopes if scope.status == "fail")
+ skipped = sum(1 for scope in scopes if scope.status == "skip")
+ warned = sum(1 for scope in scopes if scope.warnings)
+ if failed:
+ return "fail", f"{failed} of {total} failed" if total > 1 else "failed"
+ if skipped == total:
+ return "skip", "skipped"
+ if warned:
+ return "warn", _plural(warned, "warning")
+ text = f"{total - skipped} passed" if total > 1 else "passed"
+ return "pass", f"{text}, {skipped} skipped" if skipped else text
+
+
+def _first_value(scope: ScopeResult) -> str:
+ """First line of what the scope checked: a subject, a title, a branch.
+
+ The text the action piped in when it has it, then the whole message the
+ ``message`` rule reports, then whichever rule names a value first. A PR
+ description has no such line: its first line does not identify it.
+ """
+ if scope.label == "PR description":
+ return ""
+ values = [scope.text, _message_value(scope)]
+ values.extend(str(check.get("value") or "") for check in scope.checks)
+ for value in values:
+ if value.strip():
+ return value.strip().splitlines()[0].strip()
+ return ""
+
+
+def _scope_value(scope: ScopeResult, max_len: int = 60) -> str:
+ """``_first_value`` capped for a row of the listing.
+
+ The value reads naturally next to the scope's label, and the cap keeps a
+ passing row on one line of the fenced block.
+ """
+ value = _first_value(scope)
+ return value[: max_len - 3] + "..." if len(value) > max_len else value
+
+
+def _line_edits(
+ scope: ScopeResult, check: dict[str, str]
+) -> tuple[list[str], list[str]]:
+ """The lines a rule's fix removes and adds, as ``(removed, added)``.
+
+ The CLI's ``fix`` is corrected text, but of what differs by rule: the
+ whole message for most, the subject alone for the subject rules. So the
+ fix is compared with the rule's value when that is one line of the text
+ and the fix is one line too, and with the whole text otherwise. An AI
+ rule's value is the tool or the line it found, never the text it fixes.
+
+ Both lists are empty when the rule offers no fix, or nothing is known to
+ compare it with; the caller then falls back to the rule's advice.
+ """
+ recorded = scope.edits.get(_rule_key(check))
+ if recorded:
+ return recorded
+ fix = (check.get("fix") or "").strip()
+ if not fix:
+ return [], []
+ value = str(check.get("value") or "").strip()
+ text = (scope.text or _message_value(scope)).strip()
+ one_line = "\n" not in value and "\n" not in fix
+ if check.get("rule_id") in AI_RULE_IDS:
+ original = text
+ elif one_line and value in text.splitlines():
+ original = value
+ else:
+ original = text or value
+ if not original:
+ return [], []
+ return lines_removed(original, fix), lines_removed(fix, original)
+
+
+def _plain_error(check: dict[str, str]) -> str:
+ """First line of the rule's error, without a trailing ``See ``."""
+ error = (check.get("error") or "").splitlines()
+ fallback = "check warning" if check.get("status") == "warn" else "check failed"
+ first = error[0] if error else fallback
+ return re.sub(r"\s*See https?://\S+\s*$", "", first)
+
+
+def _advice(check: dict[str, str]) -> str:
+ """What to do about a finding that has no ready-made fix, in one line.
+
+ The CLI's suggestion, without the tails that only matter once the reader
+ opens the rule (the list of allowed types, the config key that would
+ exempt the branch): those are in the listing and behind the rule link.
+ """
+ suggest = (check.get("suggest") or "").splitlines()
+ if not suggest:
+ return _plain_error(check)
+ advice = re.sub(r"^In the description, ", "", suggest[0])
+ return re.sub(
+ r"(, where is one of: .*|, or add the branch to .*)$", "", advice
+ )
+
+
+def _quoted(lines: list[str]) -> str:
+ """``"first"; "second"``."""
+ return "; ".join(f'"{line}"' for line in lines)
+
+
+def _instruction(scope: ScopeResult, check: dict[str, str]) -> str:
+ """One finding as an instruction: ``change "a" to "b"``, ``remove "x"``."""
+ removed, added = _line_edits(scope, check)
+ if len(removed) == 1 and len(added) == 1:
+ return f'change "{removed[0]}" to "{added[0]}"'
+ parts = []
+ if removed:
+ parts.append(f"remove {_quoted(removed)}")
+ if added:
+ parts.append(f"add {_quoted(added)}")
+ return ", and ".join(parts) or _advice(check)
+
+
+def _target(scope: ScopeResult, instructions: list[str], label: str) -> str:
+ """Name what an instruction is about: ``Commit 2/3 (5584f46) "bad msg"``.
+
+ The quoted value is left out when an instruction already quotes it.
+ """
+ value = _first_value(scope)
+ if value and not any(f'"{value}"' in text for text in instructions):
+ return f'{label} "{value}"'
+ return label
+
+
+def _findings(scope: ScopeResult, status: str) -> list[dict[str, str]]:
+ """A scope's failures or warnings."""
+ return scope.failures if status == "fail" else scope.warnings
+
+
+def _finding_lines(check: dict[str, str]) -> list[str]:
"""The detail a reader needs to act on one finding, one item per line.
- ``value:`` (what was checked), the error (when ``include_error``),
- ``Suggest:`` and ``Fix:`` — each only when the CLI filled it in. This is
- the single source for both the tree and the annotation payload, so the
- two cannot drift; the error is optional because the annotation carries
- its first line in the headline instead.
+ ``value:`` (what was checked), the error, ``Suggest:`` and ``Fix:`` —
+ each only when the CLI filled it in.
``Fix:`` is the corrected text itself, ready to paste. When a rule has a
fix but no bespoke advice the CLI sets ``suggest`` to ``Use ""``,
@@ -1095,8 +1278,7 @@ def _finding_lines(check: dict[str, str], include_error: bool) -> list[str]:
first, *rest = str(check["value"]).splitlines()
lines.append(f"value: {first}")
lines.extend(rest)
- if include_error:
- lines.extend(check.get("error", "").splitlines())
+ lines.extend(check.get("error", "").splitlines())
fix = check.get("fix", "")
suggest = check.get("suggest", "")
if suggest and suggest != f'Use "{fix}"':
@@ -1119,59 +1301,68 @@ def _render_findings(checks: list[dict[str, str]], include_docs: bool) -> list[s
"""
lines: list[str] = []
for check in checks:
- lines.append(f" {_rule_label(check)}")
- detail = _finding_lines(check, include_error=True)
- lines.extend(f" {line}" for line in detail)
+ # A scope can hold a failure and a warning at once (see below), so a
+ # warning says so on its own line rather than borrow the row's mark.
+ kind = " (warning)" if check.get("status") == "warn" else ""
+ lines.append(f" {_rule_label(check)}{kind}")
+ lines.extend(f" {line}" for line in _finding_lines(check))
if include_docs and check.get("docs_url"):
lines.append(f" Docs: {check['docs_url']}")
return lines
+def _row_label(scope: ScopeResult) -> str:
+ """A scope's name within its group: the short hash, ``title``, ``email``.
+
+ The group header already says "Commits" or "Author", so the row only
+ needs what tells it apart from its neighbours.
+ """
+ if scope.sha and scope.label.startswith("Commit"):
+ return scope.sha[:7]
+ _group, _, name = scope.label.partition(" ")
+ return name or scope.label.lower()
+
+
+def _row(mark: str, scope: ScopeResult, value: str) -> str:
+ """One aligned row of the listing: mark, label, value."""
+ return f" {mark} {_row_label(scope):<12} {value}".rstrip()
+
+
def _render_scopes(scopes: list[ScopeResult], include_docs: bool) -> list[str]:
"""Render the indented listing for one group of scopes, without its header.
Shared by both output surfaces so they cannot drift: the step log and the
- Markdown details block are the same tree, and the only difference is the
- docs link, which the Markdown report already carries on the rule ID in the
- table above it.
+ Markdown listing are the same rows, and the only difference is the docs
+ link, which the Markdown report already carries under the diff.
- A failing or warned scope shows its value in full rather than truncated.
- It is the one value the reader has to act on, and the table's 60-character
- cap can cut off the part that explains it.
+ A failing or warned scope shows its value in full rather than capped. It
+ is the one value the reader has to act on, and the 60-character cap can
+ cut off the part that explains it.
"""
lines: list[str] = []
for scope in scopes:
- label = scope.display_label
if scope.status == "skip":
# Deliberately not a ✔. Nothing was validated here, and a tick
# claiming otherwise is what made a bypassed policy look enforced.
reason = scope.skip_reason
- lines.append(f" ⊘ {label} (skipped{f': {reason}' if reason else ''})")
+ lines.append(_row("⊘", scope, f"skipped{f': {reason}' if reason else ''}"))
continue
if scope.status == "pass":
- value = _scope_value(scope)
- lines.append(f" ✔ {label}{f' ({value})' if value else ''}")
+ lines.append(_row("✔", scope, _scope_value(scope)))
continue
if scope.raw_text and not scope.checks:
# Defensive fallback: commit-check produced unexpected output.
- lines.append(f" ✖ {label}")
+ lines.append(_row("✖", scope, ""))
lines.extend(f" {ln}" for ln in scope.raw_text.strip().splitlines())
continue
# A scope's status names its worst outcome (fail beats warn), but the
# two are not exclusive: CC2xx covers both branch and merge_base, so
- # one can fail while the other only warns. Render whichever of the
- # two lists is non-empty, rather than only the one the status names —
- # a warning on an otherwise-failing scope is still a finding to fix.
- failures = scope.failures
- if failures:
- count = f" ({len(failures)} failure{'s' if len(failures) != 1 else ''})"
- lines.append(f" ✖ {label}{count}")
- lines.extend(_render_findings(failures, include_docs))
- warnings = scope.warnings
- if warnings:
- count = f" ({len(warnings)} warning{'s' if len(warnings) != 1 else ''})"
- lines.append(f" ⚠ {label}{count}")
- lines.extend(_render_findings(warnings, include_docs))
+ # one can fail while the other only warns. The row carries the worst
+ # mark, and both lists are rendered under it — a warning on an
+ # otherwise-failing scope is still a finding to fix.
+ lines.append(_row(STATUS_GLYPHS[scope.status], scope, _first_value(scope)))
+ lines.extend(_render_findings(scope.failures, include_docs))
+ lines.extend(_render_findings(scope.warnings, include_docs))
return lines
@@ -1214,7 +1405,8 @@ def _log_safe(text: str) -> str:
output but not the JSON this action renders itself.
Only the step log needs it: the Markdown report and the annotation
- payloads are not read as commands.
+ payloads are not read as commands. It runs before the log's own colours
+ are added, so an escape sequence in the log is always the action's.
"""
if text.isprintable() and "::" not in text and "##[" not in text:
return text # nothing to escape: the common case
@@ -1226,37 +1418,55 @@ def _log_safe(text: str) -> str:
)
+#: A row of the listing: two spaces, the status glyph, a space.
+_ROW_MARK = re.compile(r"^ ([✔✖⚠⊘]) ")
+_GLYPH_STATUS = {glyph: status for status, glyph in STATUS_GLYPHS.items()}
+
+
+def _coloured(line: str) -> str:
+ """Colour a row's status glyph for the step log.
+
+ Only rows start with a glyph at column two; the detail under a finding
+ sits deeper, so user text can never be taken for a row.
+ """
+ match = _ROW_MARK.match(line)
+ if not match:
+ return line
+ glyph = match.group(1)
+ return f" {ANSI[_GLYPH_STATUS[glyph]]}{glyph}{ANSI_RESET} {line[4:]}"
+
+
def _annotation_body(scope: ScopeResult, check: dict[str, str]) -> str:
"""The message of one finding's annotation, before escaping.
- First line: which commit and what rule said, ``Commit 2/3 (5584f46):
- Subject must start with a capital letter``. Then the same value /
- Suggest / Fix rows the tree prints, so the annotation on the Files
- changed tab is enough to act on without opening the step log. GitHub
- renders the escaped newlines as line breaks.
+ The run page shows only the first line of an annotation, so that line
+ says where and what to change: ``Commit 2/3 (5584f46): change "bad msg"
+ to "fix: bad msg"``. The rule's own sentence follows on the second line
+ for whoever expands it.
"""
- error = check.get("error", "")
- fallback = "check warning" if check.get("status") == "warn" else "check failed"
- first_line = error.splitlines()[0] if error else fallback
- lines = [f"{scope.display_label}: {first_line}"]
- lines.extend(_finding_lines(check, include_error=False))
- return "\n".join(lines)
+ instruction = _instruction(scope, check)
+ headline = f"{_target(scope, [instruction], scope.display_label)}: {instruction}"
+ error = _plain_error(check)
+ return headline if error == instruction else f"{headline}\n{error}"
def render_step_log(results: list[ScopeResult]) -> None:
"""Print results to the step log, then emit one annotation per finding.
+ Each group is folded under a title that carries its own result
+ (``✖ Commits: 3 of 4 failed``): GitHub shows a group collapsed, and a
+ bare "Commits" said nothing about where to look.
+
The tree and the annotations are separated deliberately. An ``::error`` or
``::warning`` command renders as a line of its own wherever it is printed,
so emitting one inside the indented listing broke the tree apart, and its
- ``title=`` \u2014 which is what carries the rule ID \u2014 is only shown in the
+ ``title=`` — which is what carries the rule ID — is only shown in the
annotations UI, never inline. Printing the listing first and the
annotations after all the groups keeps the log readable and still
- surfaces findings in the run summary and on the Files changed tab — where
- the annotation is all a reader has, so its message repeats the value,
- suggestion and fix from the tree (``_annotation_body``), one per line.
+ surfaces findings on the run page, where the annotation's first line is
+ all a reader sees (``_annotation_body``).
- A failure becomes an ``::error``, a warning a ``::warning`` \u2014 GitHub
+ A failure becomes an ``::error``, a warning a ``::warning`` — GitHub
renders the two differently, and only the errors count toward the
friendly one-line verdict claiming nothing failed.
@@ -1268,9 +1478,10 @@ def render_step_log(results: list[ScopeResult]) -> None:
# The tree is grouped, so it is printed group by group rather than in one
# block: ::group:: and ::endgroup:: have to bracket each section's lines.
for group_name, scopes in _grouped(results):
- print(f"::group::{group_name}")
+ status, text = _group_status(scopes)
+ print(f"::group::{STATUS_GLYPHS[status]} {group_name}: {text}")
for line in _render_scopes(scopes, include_docs=True):
- print(_log_safe(line))
+ print(_coloured(_log_safe(line)))
print("::endgroup::")
errors: list[tuple[str, str]] = []
@@ -1280,7 +1491,7 @@ def render_step_log(results: list[ScopeResult]) -> None:
continue
if scope.raw_text and not scope.checks:
errors.append(
- (f"commit-check: {scope.display_label}", "output could not be parsed")
+ (f"Commit Check: {scope.display_label}", "output could not be parsed")
)
continue
for check in scope.failures:
@@ -1307,15 +1518,20 @@ def render_step_log(results: list[ScopeResult]) -> None:
failed, total = _check_counts(results)
if DRY_RUN_ENABLED:
print(
- f"commit-check (dry-run): {failed} of {total} checks failed; "
+ f"Commit Check (dry-run): {failed} of {total} checks failed; "
"not failing the job"
)
else:
- verdict = f"✖ commit-check: {failed} of {total} checks failed"
+ verdict = f"✖ Commit Check: {failed} of {total} checks failed"
warned = _warn_count(results)
if warned:
- verdict += f", {warned} warning{'s' if warned != 1 else ''}"
- print(verdict)
+ verdict += f", {_plural(warned, 'warning')}"
+ print(f"{ANSI['fail']}{ANSI_BOLD}{verdict}{ANSI_RESET}")
+ where = "the job summary" if _summary_written() else "the findings above"
+ print(
+ f"{ANSI_GREY}How to fix: see {where}. "
+ f"Check before you push: {LOCAL_HOOK_URL}{ANSI_RESET}"
+ )
if not errors:
skipped, warned, total = (
_skip_count(results),
@@ -1323,40 +1539,46 @@ def render_step_log(results: list[ScopeResult]) -> None:
len(results),
)
if total and skipped == total:
- print("\u2298 commit-check: all checks skipped, nothing was validated")
+ print("⊘ Commit Check: all checks skipped, nothing was validated")
if _skip_reasons(results):
print(_log_safe(_skip_reasons(results)))
elif warned or skipped:
passed = total - skipped - warned
tail = []
if warned:
- tail.append(f"{warned} warning{'s' if warned != 1 else ''}")
+ tail.append(_plural(warned, "warning"))
if skipped:
tail.append(f"{skipped} skipped")
print(
- f"\u2714 commit-check: {passed} of {total} checks passed, {', '.join(tail)}"
+ f"{ANSI['pass']}✔ Commit Check: {passed} of {total} checks "
+ f"passed, {', '.join(tail)}{ANSI_RESET}"
)
if _skip_reasons(results):
print(_log_safe(_skip_reasons(results)))
else:
- print("\u2714 commit-check: all checks passed")
+ print(f"{ANSI['pass']}✔ Commit Check: all checks passed{ANSI_RESET}")
+
+
+def _summary_written() -> bool:
+ """Whether this run writes a job summary a log line can point to."""
+ return bool(JOB_SUMMARY_ENABLED and GITHUB_STEP_SUMMARY)
def _check_counts(results: list[ScopeResult]) -> tuple[int, int]:
"""Return ``(failed, total)`` where one check is one thing that was checked.
- A "check" here is a scope \u2014 one commit message, the branch, the author name
- \u2014 not one rule evaluation. Counting rule evaluations produced a number that
+ A "check" here is a scope — one commit message, the branch, the author name
+ — not one rule evaluation. Counting rule evaluations produced a number that
grew with the size of the pull request rather than with the strictness of
the policy: sixteen commit messages against six enabled rules reported
"1 of 100 checks failed", where 96 of the 100 were the same six rules run
again per commit. The large denominator also made a real failure look
- negligible \u2014 one bad commit out of fifteen reads very differently from
+ negligible — one bad commit out of fifteen reads very differently from
1 of 100.
- This number matches what the reader can count: the rows in the table plus
- the \u2714/\u2716 lines in the details block. Which rules failed is not lost, it is
- just reported where it belongs \u2014 in the table and the details.
+ This number matches what the reader can count: the ✔/✖ rows of the
+ listing. Which rules failed is not lost, it is just reported where it
+ belongs — under the diff and in the listing.
"""
failed = sum(1 for scope in results if scope.status == "fail")
return failed, len(results)
@@ -1374,7 +1596,7 @@ def _skip_count(results: list[ScopeResult]) -> int:
def _skip_reasons(results: list[ScopeResult]) -> str:
"""One sentence naming why checks were skipped, or ``""`` if unknown.
- The verdict line is all most readers see, so it stays short: the config
+ The verdict is all most readers see, so it stays short: the config
section is dropped ("[commit].ignore_authors" and "[branch].ignore_authors"
both read "ignore_authors"), which folds the usual bot case into one
reason. The per-scope lines in the collapsed block keep the section.
@@ -1393,48 +1615,12 @@ def _warn_count(results: list[ScopeResult]) -> int:
Counts by ``scope.warnings``, not ``scope.status == "warn"``: a scope
whose overall status is "fail" (CC2xx covers both ``branch`` and
``merge_base``, so one can fail while the other only warns) still has
- warnings to report, and this is the count the verdict and the table use
- to decide whether to show them.
+ warnings to report, and this is the count the verdict uses to decide
+ whether to show them.
"""
return sum(1 for scope in results if scope.warnings)
-def _markdown_table(
- results: list[ScopeResult], status: str = "fail", header: str = "Failed checks"
-) -> str:
- """Render the failure or warning table shared by summary and PR comment.
-
- A scope appears when it has an entry of the requested kind \u2014 checked via
- ``scope.failures`` / ``scope.warnings``, not ``scope.status`` \u2014 so a scope
- that both failed and warned gets a row in both tables. Filtering on the
- scope's single overall status would silently drop its warnings once a
- failure in the same scope outranked them.
- """
- rows = [
- f"| Scope | Checked value | {header} |",
- "|---|---|---|",
- ]
- is_raw_only_failure = (
- status == "fail"
- ) # a raw-text scope has no checks to warn about
- for scope in results:
- entries = scope.failures if status == "fail" else scope.warnings
- raw_failure = is_raw_only_failure and scope.raw_text and not scope.checks
- # A skipped or clean-passing scope has no matching entries and is not
- # a raw-text failure, so it contributes no row \u2014 an empty accusation
- # in a table headed "Failed checks" or "Warnings" otherwise.
- if not entries and not raw_failure:
- continue
- value = _scope_value(scope)
- value_display = _markdown_code(value) if value else "\u2014"
- if raw_failure:
- links = "_output could not be parsed \u2014 see details_"
- else:
- links = " \u00b7 ".join(_rule_markdown_link(check) for check in entries)
- rows.append(f"| {_scope_markdown_label(scope)} | {value_display} | {links} |")
- return "\n".join(rows)
-
-
def _commit_url(sha: str) -> str:
"""Link to a commit on the GitHub instance the workflow runs against.
@@ -1449,250 +1635,370 @@ def _commit_url(sha: str) -> str:
return f"{server.rstrip('/')}/{repository}/commit/{sha}"
-def _scope_markdown_label(scope: ScopeResult) -> str:
- """The table's Scope cell: the label, linked to the commit when there is one.
+def _longest_backtick_run(lines: list[str]) -> int:
+ """Length of the longest run of consecutive backticks across ``lines``."""
+ return max((len(m) for line in lines for m in re.findall(r"`+", line)), default=0)
+
+
+def _fenced(language: str, body: list[str]) -> list[str]:
+ """A fenced code block that user text cannot close.
- The link lives here rather than in the tree because the tree is a fenced
- code block, where Markdown does not render; the table row is the one
- place a reader can click through to the commit.
+ The body quotes commit subjects, errors and suggestions as they are. A
+ value with three backticks in it (a `docs:` commit showing a fence)
+ would close a fixed ``` fence and spill the rest of the report out as
+ prose, so the fence is one longer than any backtick run inside.
"""
- url = _commit_url(scope.sha)
- label = scope.display_label
- return f"[{label}]({url})" if url else label
+ fence = "`" * max(3, _longest_backtick_run(body) + 1)
+ return [f"{fence}{language}", *body, fence]
+
+
+def _status_mark(status: str, size: int, line: bool = False) -> str:
+ """The ``
`` of a status mark: the tile at 24 px, the line mark at 16."""
+ name = f"{'line-' if line else ''}{STATUS_WORDS[status]}"
+ return (
+ f'
'
+ )
+
+def _group_lines(results: list[ScopeResult]) -> str:
+ """One line per group, its status mark in front: the overview."""
+ return "
\n".join(
+ f"{_status_mark(status, 16, line=True)} {group_name}: {text}"
+ for group_name, scopes in _grouped(results)
+ for status, text in [_group_status(scopes)]
+ )
-def _markdown_details(results: list[ScopeResult]) -> str:
- """Render the collapsible details block listing every scope.
- 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".
- The same block is used whether or not anything failed — on a clean run the
- failure branches simply never fire.
+def _hunk(scope: ScopeResult, status: str) -> list[str]:
+ """One scope's findings as a diff hunk.
+
+ ``@@ where @@``, then red for what was found and has to go, green for
+ what to write instead, and a ``#`` comment for advice that has no
+ literal text to offer. GitHub colours all three in a ``diff`` block,
+ which is the only colour a comment or a job summary can carry.
"""
- _failed, total = _check_counts(results)
- unit = "check" if total == 1 else "checks"
- label = f"Show all {total} {unit}" if total else "Show details"
- body = _render_tree(results, include_docs=False)
- # The tree quotes commit subjects, errors and suggestions as they are. A
- # value with three backticks in it (a `docs:` commit showing a fence)
- # would close a fixed ``` fence and spill the rest of the report out as
- # prose, so the fence is one longer than any backtick run inside.
- fence = "`" * max(3, _longest_backtick_run(body) + 1)
- lines = ["", f"{label}
", "", f"{fence}text"]
- lines.extend(body)
- lines.extend([fence, "", " "])
- return "\n".join(lines)
+ is_commit = bool(scope.sha) and scope.label.startswith("Commit")
+ lines = [f"@@ {scope.sha[:7] if is_commit else scope.label} @@"]
+ removed: list[str] = []
+ added: list[str] = []
+ advice: list[str] = []
+ for check in _findings(scope, status):
+ minus, plus = _line_edits(scope, check)
+ removed.extend(minus)
+ added.extend(plus)
+ if not (minus or plus):
+ advice.append(_advice(check))
+ if scope.raw_text and not scope.checks:
+ advice.append("the output could not be parsed, see the step log")
+ removed, added, advice = (list(dict.fromkeys(x)) for x in (removed, added, advice))
+ value = _first_value(scope)
+ if value and value not in removed:
+ # Advice is about the value itself, so it is what was found; a fix
+ # that only touches other lines leaves it as context.
+ lines.append(f"{'-' if advice else ' '} {value}")
+ lines.extend(f"- {line}" for line in removed)
+ lines.extend(f"+ {line}" for line in added)
+ lines.extend(f"# {line}" for line in advice)
+ return lines
-def _longest_backtick_run(lines: list[str]) -> int:
- """Length of the longest run of consecutive backticks across ``lines``."""
- return max((len(m) for line in lines for m in re.findall(r"`+", line)), default=0)
+def _diff_block(results: list[ScopeResult], status: str = "fail") -> list[str]:
+ """The findings of one kind as a ``diff`` block, with their links under it.
+
+ A scope appears when it has an entry of the requested kind — checked via
+ ``scope.failures`` / ``scope.warnings``, not ``scope.status`` — so a scope
+ that both failed and warned shows up in both blocks.
+
+ A long list is cut at ``MAX_HUNKS`` and the rest folded away, so twenty
+ bad commits do not push the rest of the pull request off the screen. A
+ code block cannot hold links, so the commits shown and the rules that
+ spoke get one small line of them underneath.
+ """
+ scopes = [
+ scope
+ for scope in results
+ if _findings(scope, status)
+ or (status == "fail" and scope.raw_text and not scope.checks)
+ ]
+ hunks = [_hunk(scope, status) for scope in scopes]
+ shown = len(hunks) if len(hunks) <= MAX_HUNKS + HUNK_SLACK else MAX_HUNKS
+ lines = _fenced("diff", [line for hunk in hunks[:shown] for line in hunk])
+ if len(hunks) > shown:
+ rest = [line for hunk in hunks[shown:] for line in hunk]
+ lines += ["", "", f"and {len(hunks) - shown} more
"]
+ lines += ["", *_fenced("diff", rest), "", " "]
+ commits = [
+ f"[`{scope.sha[:7]}`]({_commit_url(scope.sha)})"
+ for scope in scopes[:shown]
+ if scope.label.startswith("Commit") and _commit_url(scope.sha)
+ ]
+ rules = list(
+ dict.fromkeys(
+ _rule_ref(check) for scope in scopes for check in _findings(scope, status)
+ )
+ )
+ links = []
+ if commits:
+ links.append(f"Commits: {' '.join(commits)}")
+ if rules:
+ links.append(f"Rules: {' · '.join(rules)}")
+ if links:
+ lines += ["", f"{' | '.join(links)}"]
+ return lines
+
+
+#: First line of the prompt "How to fix" offers for an AI coding agent.
+AGENT_PROMPT = "Fix these Commit Check findings without changing any code:"
+BRANCH_HELP = (
+ "**Branch name.** Rename the branch and push it. A pull request cannot "
+ "change its branch, so open a new one from the renamed branch:"
+)
+AUTHOR_HELP = (
+ "**Author.** Set your git identity (`git config user.name`, "
+ "`git config user.email`), then `git commit --amend --reset-author` "
+ "and force-push."
+)
+HOOK_HELP = (
+ f"**Next time.** The [pre-commit hook]({LOCAL_HOOK_URL}) runs the same "
+ "rules before you push."
+)
+
+
+def _agent_prompt(failing: list[ScopeResult]) -> list[str]:
+ """A prompt to paste into an AI coding agent, one line per failing scope."""
+ lines = [AGENT_PROMPT]
+ for scope in failing:
+ todo = list(dict.fromkeys(_instruction(scope, c) for c in scope.failures))
+ is_commit = bool(scope.sha) and scope.label.startswith("Commit")
+ label = f"Commit {scope.sha[:7]}" if is_commit else scope.label
+ lines.append(f"- {_target(scope, todo, label)}: {'; '.join(todo)}")
+ return lines
+
+def _is_last_commit(scope: ScopeResult) -> bool:
+ """Whether the scope is the newest commit: ``Commit 3/3`` or the HEAD one."""
+ position = re.fullmatch(r"Commit (\d+)/(\d+)", scope.label)
+ return not position or position.group(1) == position.group(2)
-def _markdown_code(value: str) -> str:
- """A code span that survives a GFM table cell.
- The value is user text (a commit subject, a branch name, an author) and
- commonly quotes code. A bare ```` `{value}` ```` breaks twice on that: a
- backtick inside closes the span early, and an unescaped ``|`` splits the
- cell, pushing the rule link into a fourth column the table drops. So the
- span uses one more backtick than the longest run inside the value, pads
- with a space when the value starts or ends with a backtick (GFM strips
- one on each side, so the padding is invisible), and escapes ``|``, which
- GFM honours even inside a code span when the span sits in a table cell.
+def _reword_commands(commits: list[ScopeResult]) -> list[str]:
+ """The git commands that reword the failing commits and push the result."""
+ if len(commits) == 1 and _is_last_commit(commits[0]):
+ reword = "git commit --amend"
+ else:
+ first = commits[0].sha[:7] or ""
+ note = '# change "pick" to "reword" on the commits above'
+ reword = f"git rebase -i {first}~1 {note}"
+ return [reword, "git push --force-with-lease"]
+
+
+def _how_to_fix(results: list[ScopeResult]) -> list[str]:
+ """The folded "How to fix" section: only what this run's failures need.
+
+ A prompt for whoever lets an AI agent do it, then the git commands for
+ doing it by hand. Most people who fail a message check have never
+ reworded a pushed commit, and the commands are the same every time.
"""
- fence = "`" * (_longest_backtick_run([value]) + 1)
- pad = " " if value.startswith("`") or value.endswith("`") else ""
- return f"{fence}{pad}{value.replace('|', chr(92) + '|')}{pad}{fence}"
+ failing = [scope for scope in results if scope.failures]
+ if not failing:
+ return []
+ commits = [scope for scope in failing if scope.label.startswith("Commit")]
+ labels = {scope.label for scope in failing}
+ # One list of lines per paragraph; a blank line goes between them.
+ parts = [
+ ["**With an AI coding agent.** Paste this prompt:"],
+ _fenced("text", _agent_prompt(failing)),
+ ]
+ if commits:
+ many = "it" if len(commits) == 1 else "them"
+ parts.append([f"**Commit messages.** Reword {many}, then force-push:"])
+ parts.append(_fenced("bash", _reword_commands(commits)))
+ if any(label.startswith("Author") for label in labels):
+ parts.append([AUTHOR_HELP])
+ if "Branch" in labels:
+ # The CLI's own corrected name when it has one, a pattern otherwise.
+ fixes = (
+ c.get("fix") for s in failing if s.label == "Branch" for c in s.failures
+ )
+ name = next((fix for fix in fixes if fix), "/")
+ rename = [f"git branch -m {name}", "git push -u origin HEAD"]
+ parts.extend([[BRANCH_HELP], _fenced("bash", rename)])
+ fields = [word for word in ("title", "description") if f"PR {word}" in labels]
+ if fields:
+ many = "it" if len(fields) == 1 else "them"
+ what = " and ".join(fields)
+ parts.append(
+ [f"**Pull request {what}.** Edit {many} on the pull request page."]
+ )
+ parts.append([HOOK_HELP])
+ lines = ["", "How to fix
"]
+ for part in parts:
+ lines.extend(["", *part])
+ return [*lines, "", " "]
-def _scope_value(scope: ScopeResult, max_len: int = 60) -> str:
- """First non-empty check value for a scope, trimmed to a single line.
+def _all_checks(results: list[ScopeResult]) -> list[str]:
+ """The folded listing of every scope, passed ones included.
- 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 60-character cap keeps the
- full line (prefix + value + parentheses) short enough to avoid wrapping
- in the fenced details block.
+ The same rows the step log prints (group name, one ✔/✖ row per scope with
+ the checked value), with the rule's reason, suggestion and fix under each
+ finding, so one expand answers both "what was checked" and "what failed
+ and why". The same block is used whether or not anything failed.
"""
- 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 ""
+ _failed, total = _check_counts(results)
+ unit = "check" if total == 1 else "checks"
+ label = f"All {total} {unit}" if total else "Details"
+ body = _fenced("text", _render_tree(results, include_docs=False))
+ return ["", f"{label}
", "", *body, "", " "]
# ---------------------------------------------------------------------------
# Output specification
#
# The Markdown report shared by the job summary and the PR comment renders
-# as follows (values are filled from ScopeResult data):
+# as follows (values are filled from ScopeResult data; stands for an
+#
of that status mark, see _status_mark).
#
# Every body opens with COMMENT_MARKER, which is invisible when rendered and is
# how the action recognises its own PR comment on the next run.
#
-# Success:
+# Failure:
#
#
-# ## Commit Check
+# ## Commit Check failed
#
-# ✅ **All 5 checks passed**
+# Pull request: passed
+# Commits: 2 of 3 failed
+# Branch: failed
#
-#
-# Show all 5 checks
-#
-# ```text
-# Commit message
-# ✔ PR title (feat: add login page)
-# ✔ Commit 1/11 (d87faca) (feat: add user auth)
-# Branch
-# ✔ Branch (feature/add-login)
-# Author
-# ✔ Author name (Jane Doe)
-# ✔ Author email (jane@example.com)
+# ```diff
+# @@ 5584f46 @@
+# - bad msg
+# + fix: bad msg
+# @@ 37d6def @@
+# feat: add OAuth providers
+# - Co-authored-by: Claude
+# @@ Branch @@
+# - login-page
+# # Use / with an allowed type
# ```
#
-#
-#
-# _commit-check 2.13.1 · [Rules reference](https://commit-check.com/rules/)_
-#
-# A commit scope names its commit by short hash after the label, on every
-# surface; the label itself ("Commit 1/11") stays bare in the `result` output.
-#
-# Skipped (every rule declined to run — e.g. the author is in ignore_authors):
+# Commits: [`5584f46`](…) [`37d6def`](…) | Rules: [CC001](…) · [CC013](…) · [CC201](…)
#
-# ⊘ **All 5 checks skipped** — nothing was validated
+#
+# How to fix
#
-# Skipped because author dependabot[bot] is in ignore_authors.
+# **With an AI coding agent.** Paste this prompt:
#
# ```text
-# Commit message
-# ⊘ PR title (skipped: author dependabot[bot] is in [commit].ignore_authors)
-# ⊘ Commit 1/1 (skipped: author dependabot[bot] is in [commit].ignore_authors)
-# Branch
-# ⊘ Branch (skipped: author dependabot[bot] is in [branch].ignore_authors)
-# Author
-# ⊘ Author name (skipped: author dependabot[bot] is in [commit].ignore_authors)
-# ⊘ Author email (skipped: author dependabot[bot] is in [commit].ignore_authors)
+# Fix these Commit Check findings without changing any code:
+# - Commit 5584f46: change "bad msg" to "fix: bad msg"
+# - Commit 37d6def "feat: add OAuth providers": remove "Co-authored-by: Claude "
+# - Branch "login-page": Use / with an allowed type
# ```
#
-# The reason comes from commit-check; an older CLI sends none, and the line
-# reads "(skipped)" with no "Skipped because" sentence.
+# **Commit messages.** Reword them, then force-push:
#
-# A skipped scope deliberately carries no ✔ and no checked value: nothing was
-# examined, so there is no value to report and no pass to claim. When only some
-# scopes skip, the verdict reads "✅ **3 of 5 checks passed**, 2 skipped".
+# ```bash
+# git rebase -i 5584f46~1 # change "pick" to "reword" on the commits above
+# git push --force-with-lease
+# ```
#
-# Warning (the repository's config lists a rule under the top-level `warn`):
+# **Branch name.** Rename the branch and push it. …
#
-#
-# ## Commit Check
+# **Next time.** The [pre-commit hook](…) runs the same rules before you push.
#
-# ✅ **3 of 4 checks passed**, 1 warning
-#
-# | Scope | Checked value | Warnings |
-# |---|---|---|
-# | Branch | `jsmith/fix-x` | [CC201 branch](https://commit-check.com/rules/#cc201) |
+#
#
#
-# Show all 4 checks
+# All 5 checks
#
# ```text
-# Commit message
-# ✔ PR title (feat: add login page)
+# Pull request
+# ✔ title feat: add login page
+# Commits
+# ✔ d87faca feat: add login page
+# ✖ 5584f46 bad msg
+# CC001 message
+# value: bad msg
+# The commit message should follow Conventional Commits.
+# Fix: fix: bad msg
+# ✖ 37d6def feat: add OAuth providers
+# CC013 ai-attribution
+# value: Claude Code
+# AI attribution is forbidden in this project — detected: Claude Code
+# Suggest: …
+# Fix: feat: add OAuth providers
# Branch
-# ⚠ Branch (1 warning)
+# ✖ branch login-page
# CC201 branch
-# value: jsmith/fix-x
+# value: login-page
# The branch should follow Conventional Branch.
-# Suggest: Use /
-# Author
-# ✔ Author name (Jane Doe)
-# ✔ Author email (jane@example.com)
+# Suggest: Use / with an allowed type, or …
# ```
#
#
#
-# _commit-check 2.17.0 · [Rules reference](https://commit-check.com/rules/)_
+# Checked by [Commit Check](https://commit-check.com) 2.20.5 · [Rules](…) · [Check before you push](…)
#
-# A warned scope is reported exactly like a failing one — its own table row,
-# its own entry in the details block — except the marker is ⚠ rather than ✖,
-# it counts toward "passed" rather than "failed", and it never turns the
-# workflow red. A real failure elsewhere still fails the run; the verdict
-# then reads "❌ **N of M checks failed**, K warnings" and both tables appear.
+# Success: the heading reads " Commit Check passed", then the
+# group lines, the folded listing and the footer. No diff, no "How to fix".
#
-# Failure:
-#
-#
-# ## Commit Check
+# Warning (the repository's config lists a rule under the top-level `warn`):
+# " Commit Check passed with warnings", the group lines (a warned
+# group reads " Branch: 1 warning"), then **Warnings** and
+# the same diff block built from the warnings. A warned scope is reported
+# exactly like a failing one except the mark is ⚠ rather than ✖, it counts
+# toward "passed" rather than "failed", and it never turns the workflow red.
+# A real failure elsewhere still fails the run; both diff blocks then appear.
#
-# ❌ **2 of 5 checks failed**
+# Skipped (every rule declined to run — e.g. the author is in ignore_authors):
+# " Commit Check skipped", then "Nothing was validated." and the
+# reason ("Skipped because author dependabot[bot] is in ignore_authors."). The
+# reason comes from commit-check; an older CLI sends none. A skipped scope
+# deliberately carries no ✔ and no checked value: nothing was examined, so
+# there is no value to report and no pass to claim. When only some scopes
+# skip, their group line says so ("Author: 1 passed, 1 skipped").
#
-# | Scope | Checked value | Failed checks |
-# |---|---|---|
-# | [Commit 2/11 (5584f46)](https://github.com/acme/widgets/commit/5584f46…) | `bad msg` | [CC001 message](https://commit-check.com/rules/#cc001) |
-# | [Commit 3/11 (37d6def)](https://github.com/acme/widgets/commit/37d6def…) | `feat: add login page` | [CC002 subject-capitalized](https://commit-check.com/rules/#cc002) |
+# The step log prints the same listing, each group folded under a title that
+# carries its result, plus a `Docs:` line per finding; then one annotation per
+# finding, and the verdict:
#
-#
-# Show all 5 checks
-#
-# ```text
-# Commit message
-# ✔ PR title (feat: add login page)
-# ✖ Commit 2/11 (5584f46) (1 failure)
+# ::group::✖ Commits: 2 of 3 failed
+# ✔ d87faca feat: add login page
+# ✖ 5584f46 bad msg
# CC001 message
-# value: bad msg
-# The commit message should follow Conventional Commits.
-# Suggest: Use ():
-# ✖ Commit 3/11 (37d6def) (1 failure)
-# CC002 subject-capitalized
-# value: feat: add login page
-# Subject must start with a capital letter
-# Fix: feat: Add login page
-# Branch
-# ✔ Branch (feature/add-login)
-# ```
-#
-#
-#
-# _commit-check 2.13.1 · [Rules reference](https://commit-check.com/rules/)_
-#
-# The step log prints the same tree (plus a `Docs:` line per finding), then one
-# annotation per finding whose message is the tree's detail joined on `%0A`:
-#
-# ::error title=CC002 subject-capitalized::Commit 3/11 (37d6def): Subject must start with a capital letter%0Avalue: feat: add login page%0AFix: feat: Add login page
-# ✖ commit-check: 2 of 5 checks failed
+# …
+# ::endgroup::
+# ::error title=CC001 message::Commit 2/3 (5584f46): change "bad msg" to "fix: bad msg"%0AThe commit message should follow Conventional Commits.
+# ✖ Commit Check: 2 of 5 checks failed
+# How to fix: see the job summary. Check before you push: https://commit-check.com/…
#
# The verdict is a plain line, not an `::error`, so the run's error count is
# the number of findings.
#
# Notes:
-# - The table's Scope cell links to the commit (GITHUB_SERVER_URL, so it is
-# right on GitHub Enterprise Server too); the tree cannot, being a code block.
-# - `Fix:` is the corrected text the CLI proposes, when the correction is
-# mechanical (CC002 capitalisation, CC010 WIP marker, CC012 sign-off, ...).
-# When the CLI's `suggest` is just `Use ""`, only `Fix:` is printed. A
-# multi-line fix takes one row per line.
+# - The status marks are the Commit Check mark in a status colour, so the mark
+# itself is the status signal: there is never a tick next to a failure.
+# Their alt text is the glyph the step log uses, so the report still reads
+# where the images cannot load.
+# - A red line is text that was found and has to change, a green line is text
+# to write, taken from the CLI's `fix` (see _line_edits). A rule with no
+# mechanical fix gets a `#` line with the CLI's suggestion instead.
+# - The diff lists only what failed, capped at MAX_HUNKS items with the rest
+# folded; passing scopes live in the listing.
+# - A commit is named by its short hash on every surface; the label
+# ("Commit 1/11") stays bare in the `result` output.
# - One check is one thing that was checked — a commit message, the branch, the
-# author — not one rule evaluation. The total therefore matches the number of
-# ✔/✖ lines the reader can count in the details block, and does not grow with
-# the number of commits in the pull request or rules in the config.
-# - The table lists only failed scopes; there is no per-row result column
-# because it would read ❌ on every row. Passing scopes live in the details.
-# - Values are capped at 60 characters with a literal "..." suffix, except on a
-# failing scope, where the details block prints the value in full — it is the
-# one value the reader has to act on and the cap can hide the reason.
-# - The Checked value cell is a code span whose backtick fence is longer than
-# any backtick run in the value, with `|` escaped, so a subject that quotes
-# code cannot close the span or split the row; the details fence likewise
-# grows past any backtick run in the tree. Reading the raw Markdown, expect
-# ``fix: handle `None` \| retry`` rather than `fix: handle `None` | retry`.
-# - The step log renders the same tree (_render_scopes); it adds the docs URL,
-# which the Markdown report already carries on the rule ID in the table.
+# author — not one rule evaluation. The totals therefore match the number of
+# ✔/✖ rows the reader can count in the listing, and do not grow with the
+# number of rules in the config.
+# - Passing values are capped at 60 characters with a literal "..." suffix; a
+# failing scope prints its value in full — it is the one value the reader
+# has to act on and the cap can hide the reason.
+# - Every fenced block grows its fence past any backtick run inside it, so a
+# subject that quotes code cannot close the block.
# ---------------------------------------------------------------------------
@@ -1707,23 +2013,28 @@ def _commit_check_version() -> str:
def _report_footer() -> str:
- """Attribution line: which version ran, and where the rules are documented.
+ """Attribution line: who checked, which version, and where to go next.
- The version is the first thing worth knowing when a result looks wrong, and
- it is otherwise buried in the step log.
+ The version is the first thing worth knowing when a result looks wrong,
+ and it is otherwise buried in the step log. The last link is for the
+ contributor who would rather hear this before pushing.
"""
- rules = f"[Rules reference]({RULES_URL})"
installed = _commit_check_version()
- return f"_commit-check {installed} · {rules}_" if installed else f"_{rules}_"
+ version = f" {installed}" if installed else ""
+ return (
+ f"Checked by [Commit Check]({SITE_URL}){version} · "
+ f"[Rules]({RULES_URL}) · [Check before you push]({LOCAL_HOOK_URL})"
+ )
def render_report(results: list[ScopeResult]) -> str:
"""Render the Markdown report shared by the job summary and PR comment.
- Opens with the hidden marker and the title, then a one-line verdict —
- ``✅ **All N checks passed**`` or ``❌ **N of M checks failed**`` — then the
- failure table (failures only), a warnings table when the config listed
- any rule under ``warn``, and the collapsible per-scope details.
+ Opens with the hidden marker and a heading that is the verdict — the
+ status mark and ``Commit Check failed`` / ``passed`` — then one line per
+ group, the diff of what failed, a diff of the warnings when the config
+ listed any rule under ``warn``, the folded "How to fix", and the folded
+ listing of every scope.
A warned scope counts toward "passed", never toward "failed": it ran,
found something, and is reported in full, but the config asked for it to
@@ -1732,41 +2043,34 @@ def render_report(results: list[ScopeResult]) -> str:
failed, total = _check_counts(results)
warned = _warn_count(results)
skipped = _skip_count(results)
- unit = "check" if total == 1 else "checks"
- lines = [COMMENT_MARKER, REPORT_TITLE, ""]
if failed:
- verdict = f"❌ **{failed} of {total} {unit} failed**"
- if warned:
- verdict += f", {warned} warning{'s' if warned != 1 else ''}"
- lines.append(verdict)
- lines.extend(["", _markdown_table(results), ""])
- if warned:
- lines.extend([_markdown_table(results, "warn", "Warnings"), ""])
+ status, verdict = "fail", "failed"
elif total and skipped == total:
- # Nothing ran, so there is no success to announce. Saying "all
- # checks passed" here is the defect this branch exists to prevent.
- lines.append(f"⊘ **All {total} {unit} skipped** — nothing was validated")
- lines.append("")
- if _skip_reasons(results):
- lines.extend([_skip_reasons(results), ""])
- elif warned or skipped:
- passed = total - skipped - warned
- tail = []
- if warned:
- tail.append(f"{warned} warning{'s' if warned != 1 else ''}")
- if skipped:
- tail.append(f"{skipped} skipped")
- lines.append(f"✅ **{passed} of {total} {unit} passed**, {', '.join(tail)}")
- lines.append("")
- if _skip_reasons(results):
- lines.extend([_skip_reasons(results), ""])
- if warned:
- lines.extend([_markdown_table(results, "warn", "Warnings"), ""])
+ # Nothing ran, so there is no success to announce. Saying "passed"
+ # here is the defect this branch exists to prevent.
+ status, verdict = "skip", "skipped"
+ elif warned:
+ status, verdict = "warn", "passed with warnings"
else:
- lines.append(f"✅ **All {total} {unit} passed**")
- lines.append("")
- lines.extend([_markdown_details(results), "", _report_footer()])
+ status, verdict = "pass", "passed"
+
+ lines = [COMMENT_MARKER, f"## {_status_mark(status, 24)} Commit Check {verdict}"]
+ lines.append("")
+ if status == "skip":
+ lines.extend(["Nothing was validated.", ""])
+ if results:
+ lines.extend([_group_lines(results), ""])
+ if skipped and _skip_reasons(results):
+ lines.extend([_skip_reasons(results), ""])
+ if failed:
+ lines.extend([*_diff_block(results), ""])
+ if warned:
+ lines.extend(["**Warnings**", "", *_diff_block(results, "warn"), ""])
+ if failed:
+ how = _how_to_fix(results)
+ lines.extend([*how, ""] if how else [])
+ lines.extend([*_all_checks(results), "", _report_footer()])
return "\n".join(lines)
@@ -1940,8 +2244,8 @@ def add_pr_comments(results: list[ScopeResult]) -> int:
if is_fork_pr_with_readonly_token():
# Name only the surfaces this run actually produced: with
# job-summary: false there is no summary to send the reader to.
- where = "in the annotations on the Files changed tab"
- if JOB_SUMMARY_ENABLED and GITHUB_STEP_SUMMARY:
+ where = "in the annotations on the run page"
+ if _summary_written():
where = f"in this job's summary and {where}"
msg = (
"Skipping PR comment: pull requests from forked repositories "
@@ -1958,7 +2262,7 @@ def add_pr_comments(results: list[ScopeResult]) -> int:
"Pull requests from forked repositories cannot write comments "
"using the `pull_request` event because `GITHUB_TOKEN` has "
"read-only permissions. The report above and the annotations "
- "on the Files changed tab are unaffected.\n\n"
+ "on the run page are unaffected.\n\n"
"> **\U0001f4a1 Tip:** see "
"[Fork pull requests]"
"(https://github.com/commit-check/commit-check-action/blob/main/docs/fork-pr-comments.md).\n"
diff --git a/main_test.py b/main_test.py
index 3b55797..2779b5f 100644
--- a/main_test.py
+++ b/main_test.py
@@ -22,10 +22,33 @@
#: between a contributor's machine and CI. Golden tests pin it so they assert on
#: the report layout rather than on whatever version happens to be installed.
PINNED_VERSION = "2.13.1"
+HOOK_GUIDE = "https://commit-check.com/guides/integrations/#as-a-pre-commit-hook"
FOOTER = (
- f"_commit-check {PINNED_VERSION} · "
- "[Rules reference](https://commit-check.com/rules/)_"
+ f"Checked by [Commit Check](https://commit-check.com) {PINNED_VERSION} · "
+ "[Rules](https://commit-check.com/rules/) · "
+ f"[Check before you push]({HOOK_GUIDE})"
)
+MARKS = "https://raw.githubusercontent.com/commit-check/.github/main/branding/status"
+
+
+def heading(glyph: str, word: str, verdict: str) -> str:
+ """The report's first visible line: the status tile and the verdict."""
+ return (
+ f'##
Commit Check {verdict}'
+ )
+
+
+def group_line(glyph: str, word: str, text: str) -> str:
+ """One group's line of the report: its line mark, name and result."""
+ return (
+ f'
{text}'
+ )
+
+
+#: The step log's colour codes, which most assertions read past.
+ANSI_CODES = re.compile(r"\x1b\[[0-9;]*m")
pin_version = patch("main._commit_check_version", new=lambda: PINNED_VERSION)
# ---------------------------------------------------------------------------
@@ -396,6 +419,15 @@ def test_invalid_json_falls_back_to_raw_text(self):
self.assertEqual(scope.raw_text, "unexpected output")
self.assertEqual(scope.status, "fail")
+ def test_keeps_the_text_it_piped_in(self):
+ """The report compares it with a rule's fix to name the lines to change."""
+ mock_result = MagicMock(returncode=0, stdout=json_output(make_check("message")))
+ with patch("main.subprocess.run", return_value=mock_result):
+ piped = main.check_scope("PR title", ["--message"], input_text="feat: x")
+ read_by_the_cli = main.check_scope("Branch", ["--branch"])
+ self.assertEqual(piped.text, "feat: x")
+ self.assertEqual(read_by_the_cli.text, "")
+
class TestRunPrMessageChecks(unittest.TestCase):
"""Each commit's raw message is piped in, with that commit's own author."""
@@ -462,6 +494,33 @@ def test_message_ai_attribution_passes_a_badly_formatted_message(self):
):
[scope] = main.run_pr_message_checks([(SHA_A, "WIP")])
self.assertEqual(scope.status, "pass")
+ self.assertEqual(scope.text, "WIP")
+
+ def test_message_ai_attribution_keeps_the_head_message_for_the_report(self):
+ """Outside a PR nothing is piped in, and the message rule, whose
+ value is the whole message, is one of the rules dropped."""
+ message = "feat: x\n\nCo-authored-by: Claude "
+ scope = main.ScopeResult(
+ label="Commit message",
+ checks=[
+ make_check("message", value=message),
+ make_check(
+ "ai_attribution",
+ status="fail",
+ rule_id="CC013",
+ value="Claude Code",
+ fix="feat: x",
+ ),
+ ],
+ )
+ with patch("main.MESSAGE_AI_ATTRIBUTION_ENABLED", True):
+ main.keep_ai_rules(scope)
+ self.assertEqual([c["rule_id"] for c in scope.checks], ["CC013"])
+ self.assertEqual(scope.text, message)
+ self.assertEqual(
+ main._line_edits(scope, scope.checks[0]),
+ (["Co-authored-by: Claude "], []),
+ )
def test_message_ai_attribution_under_ignore_is_skipped(self):
"""No AI rule ran: say so rather than report a pass."""
@@ -1558,36 +1617,64 @@ def test_requirements_pin_can_report_warnings(self):
class TestRenderStepLog(unittest.TestCase):
- def _run(self, results):
+ def _run(self, results, colours=False):
buffer = io.StringIO()
with patch("sys.stdout", buffer):
main.render_step_log(results)
- return buffer.getvalue()
+ output = buffer.getvalue()
+ return output if colours else ANSI_CODES.sub("", output)
def test_all_pass_prints_success_line(self):
output = self._run([pass_scope("Branch")])
- self.assertIn("✔ commit-check: all checks passed", output)
+ self.assertIn("✔ Commit Check: all checks passed", output)
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("::group::✖ Commits: failed", output)
self.assertIn("::endgroup::", output)
- self.assertIn("✖ Commit 1/1 (1 failure)", output)
+ self.assertIn(" ✖ 1/1 bad message\n", output)
self.assertIn(" CC001 message", output)
self.assertIn("value: bad message", output)
self.assertIn("Suggest: Use (): ", output)
self.assertIn("Docs: https://commit-check.com/rules/#cc001", output)
- # The annotation names the scope, which the title alone cannot carry,
- # then the value and the suggestion, so it can be acted on where it
- # is shown (the Files changed tab) without opening the step log.
+ # The run page shows one line of an annotation, so the first line
+ # names the scope, which the title alone cannot carry, and what to
+ # change. The rule's own sentence follows for whoever expands it.
self.assertIn(
- "::error title=CC001 message::Commit 1/1: The commit message should "
- "follow Conventional Commits."
- "%0Avalue: bad message"
- "%0ASuggest: Use (): \n",
+ '::error title=CC001 message::Commit 1/1 "bad message": '
+ "Use (): "
+ "%0AThe commit message should follow Conventional Commits.\n",
output,
)
+ def test_group_title_carries_the_result_of_the_group(self):
+ """GitHub folds every group, so the title is all a reader sees."""
+ output = self._run(
+ [
+ pass_scope("Commit 1/3"),
+ fail_scope("Commit 2/3"),
+ fix_scope("Commit 3/3"),
+ pass_scope("Author name"),
+ pass_scope("Author email"),
+ warn_scope(),
+ ]
+ )
+ self.assertIn("::group::✖ Commits: 2 of 3 failed\n", output)
+ self.assertIn("::group::✔ Author: 2 passed\n", output)
+ self.assertIn("::group::⚠ Branch: 1 warning\n", output)
+
+ def test_only_the_status_glyph_of_a_row_is_coloured(self):
+ output = self._run(
+ [pass_scope("Branch", value="feature/x"), fail_scope("Commit 1/1")],
+ colours=True,
+ )
+ self.assertIn(" \x1b[32m✔\x1b[0m branch feature/x\n", output)
+ self.assertIn(" \x1b[31m✖\x1b[0m 1/1 bad message\n", output)
+ self.assertIn("\n CC001 message\n", output)
+ self.assertIn(
+ "\x1b[31m\x1b[1m✖ Commit Check: 1 of 2 checks failed\x1b[0m\n", output
+ )
+
def test_commit_text_cannot_issue_workflow_commands(self):
# A failing rule prints the whole message, one line of the log per
# line of the message. The runner skips leading whitespace before
@@ -1610,24 +1697,32 @@ def test_commit_text_cannot_issue_workflow_commands(self):
)
],
)
- output = self._run([scope])
+ output = self._run([scope], colours=True)
tree = output.split("::endgroup::")[0]
for line in tree.splitlines()[1:]: # past ::group::, our own command
self.assertFalse(line.lstrip().startswith("::"), line)
self.assertNotIn("##[", line)
- self.assertNotIn("\x1b", line)
+ # The only escape sequences left are the action's own colours.
+ self.assertNotIn("\x1b", ANSI_CODES.sub("", line))
self.assertIn(" \\::warning title=Forged::text\n", tree)
self.assertIn(" \\::add-mask::secret\n", tree)
self.assertIn(" old #\\#[error]form\n", tree)
self.assertIn(" esc\\x1b]52;c;eA==\\x07\n", tree)
- # The annotations are commands already; their payload is left as is.
- self.assertIn("%0A::warning title=Forged::text%0A", output)
+ # The annotation names the subject alone, on one line.
+ self.assertIn('::Commit 1/1 "bad subject": The commit message', output)
+
+ def test_a_subject_that_starts_like_a_command_stays_text_in_its_row(self):
+ scope = main.ScopeResult(
+ label="Commit 1/1", checks=[make_check("message", value="::error::x")]
+ )
+ output = self._run([scope])
+ self.assertIn(" ✔ 1/1 ::error::x\n", output)
def test_log_safe_leaves_ordinary_text_alone(self):
for text in [
"feat: add login",
"value: a::b",
- " ✔ Branch (feature/x)",
+ " ✔ branch feature/x",
"tab\there",
]:
self.assertEqual(main._log_safe(text), text)
@@ -1636,26 +1731,20 @@ def test_annotation_names_the_commit_and_carries_the_fix(self):
output = self._run([fix_scope("Commit 2/3", sha=SHA_B)])
self.assertIn(
"::error title=CC002 subject-capitalized::Commit 2/3 (5584f46): "
- "Subject must start with a capital letter"
- "%0Avalue: feat: add login page"
- "%0AFix: feat: Add login page\n",
+ 'change "feat: add login page" to "feat: Add login page"'
+ "%0ASubject must start with a capital letter\n",
output,
)
- def test_annotation_and_tree_print_the_same_detail_lines(self):
- """One helper feeds both surfaces, so they cannot drift."""
- check = fix_scope().checks[0]
- tree = main._finding_lines(check, include_error=True)
- annotation = main._finding_lines(check, include_error=False)
+ def test_finding_lines_are_the_value_the_error_and_the_fix(self):
self.assertEqual(
- tree,
+ main._finding_lines(fix_scope().checks[0]),
[
"value: feat: add login page",
"Subject must start with a capital letter",
"Fix: feat: Add login page",
],
)
- self.assertEqual([ln for ln in tree if ln in annotation], annotation)
def test_annotation_without_an_error_still_names_the_kind_of_finding(self):
scope = main.ScopeResult(
@@ -1702,9 +1791,8 @@ def test_bespoke_suggest_and_fix_are_both_printed(self):
listing,
)
self.assertIn(
- "%0ASuggest: Drop the WIP marker: %22stuff%22%0AFix: stuff\n".replace(
- "%22", '"'
- ),
+ '::Commit 1/1: change "wip: stuff" to "stuff"'
+ "%0AWIP commits are not allowed\n",
output,
)
@@ -1733,11 +1821,11 @@ def test_a_multi_line_fix_takes_one_row_per_line(self):
" Signed-off-by: Jane Doe \n",
listing,
)
- # In the annotation the same rows ride on %0A, so the trailer lands
- # where it would in the message.
+ # The annotation names the one line the fix adds.
self.assertIn(
- "%0AFix: feat: add login page%0A%0ASigned-off-by: "
- "Jane Doe \n",
+ '::Commit 1/1 (d87faca) "feat: add login page": '
+ 'add "Signed-off-by: Jane Doe "'
+ "%0ASigned-off-by not found in latest commit\n",
output,
)
@@ -1749,11 +1837,11 @@ def test_tree_lines_carry_the_short_sha(self):
]
results[0].sha = "c" * 40
output = self._run(results)
- self.assertIn(" ✔ Commit 1/3 (ccccccc) (fix: first)\n", output)
- self.assertIn(" ✖ Commit 2/3 (5584f46) (1 failure)\n", output)
- self.assertIn(" ✖ Commit 3/3 (d87faca)\n", output)
+ self.assertIn(" ✔ ccccccc fix: first\n", output)
+ self.assertIn(" ✖ 5584f46 feat: add login page\n", output)
+ self.assertIn(" ✖ d87faca\n", output)
self.assertIn(
- "::error title=commit-check: Commit 3/3 (d87faca)::output could not "
+ "::error title=Commit Check: Commit 3/3 (d87faca)::output could not "
"be parsed\n",
output,
)
@@ -1764,14 +1852,28 @@ def test_failure_verdict_is_a_plain_line_not_an_annotation(self):
output = self._run([fail_scope("Commit 1/2"), fix_scope("Commit 2/2")])
self.assertEqual(output.count("::error"), 2)
self.assertNotIn("::error::", output)
- self.assertIn("\n✖ commit-check: 2 of 2 checks failed\n", output)
+ self.assertIn("\n✖ Commit Check: 2 of 2 checks failed\n", output)
self.assertNotIn("passed", output)
+ def test_failure_verdict_says_where_the_fix_is(self):
+ with patch("main.JOB_SUMMARY_ENABLED", True):
+ output = self._run([fail_scope("Commit 1/1")])
+ self.assertTrue(
+ output.endswith(
+ "How to fix: see the job summary. "
+ f"Check before you push: {HOOK_GUIDE}\n"
+ )
+ )
+ # With no summary to send the reader to, the log is the report.
+ with patch("main.JOB_SUMMARY_ENABLED", False):
+ output = self._run([fail_scope("Commit 1/1")])
+ self.assertIn("How to fix: see the findings above. ", output)
+
def test_failure_verdict_counts_scopes_and_names_the_warnings(self):
output = self._run(
[fail_scope("Commit 1/1"), pass_scope("PR title"), warn_scope()]
)
- self.assertIn("\n✖ commit-check: 1 of 3 checks failed, 1 warning\n", output)
+ self.assertIn("\n✖ Commit Check: 1 of 3 checks failed, 1 warning\n", output)
self.assertNotIn("dry-run", output)
def test_failure_reason_is_printed_once(self):
@@ -1803,6 +1905,7 @@ def test_annotation_payload_is_escaped(self):
make_check(
"message",
status="fail",
+ value="50% done",
error="first line\nsecond line with 100% certainty",
)
],
@@ -1810,11 +1913,11 @@ def test_annotation_payload_is_escaped(self):
output = self._run([scope])
annotation = next(ln for ln in output.splitlines() if ln.startswith("::error"))
self.assertNotIn("\n", annotation.removeprefix("::error "))
- self.assertIn("first line", annotation)
+ self.assertIn('Commit 1/1 "50%25 done": first line', annotation)
def test_pass_scopes_show_the_checked_value(self):
output = self._run([pass_scope("Branch", value="feature/add-login")])
- self.assertIn("✔ Branch (feature/add-login)", output)
+ self.assertIn("✔ branch feature/add-login", output)
def test_groups_scopes_by_category(self):
results = [
@@ -1825,9 +1928,9 @@ def test_groups_scopes_by_category(self):
output = self._run(results)
# One group each for the pull request, the commits and the branch.
self.assertEqual(output.count("::group::"), 3)
- self.assertIn("::group::Pull request", output)
- self.assertIn("::group::Commit message", output)
- self.assertIn("::group::Branch", output)
+ self.assertIn("::group::✖ Pull request: failed", output)
+ self.assertIn("::group::✔ Commits: passed", output)
+ self.assertIn("::group::✖ Branch: failed", output)
def test_title_and_description_share_one_group(self):
"""The commits come after both, so the commit group is not split."""
@@ -1837,17 +1940,14 @@ def test_title_and_description_share_one_group(self):
pass_scope("Commit 1/1"),
]
groups = [name for name, _ in main._grouped(results)]
- self.assertEqual(groups, ["Pull request", "Commit message"])
+ self.assertEqual(groups, ["Pull request", "Commits"])
def test_raw_text_fallback_is_printed(self):
scope = main.ScopeResult(label="Branch", raw_text="unexpected output")
output = self._run([scope])
- # No "(0 failures)": there is no check list to count, and claiming zero
- # next to a ✖ reads as a contradiction.
- self.assertIn("✖ Branch", output)
- self.assertNotIn("0 failures", output)
+ self.assertIn(" ✖ branch\n", output)
self.assertIn("unexpected output", output)
- self.assertIn("::error title=commit-check: Branch::", output)
+ self.assertIn("::error title=Commit Check: Branch::", output)
def test_dry_run_downgrades_annotations_and_prints_verdict(self):
"""Dry-run still reports every failure, but nothing may say "error".
@@ -1860,24 +1960,32 @@ def test_dry_run_downgrades_annotations_and_prints_verdict(self):
output = self._run([fail_scope("Commit 1/1"), pass_scope("Branch")])
self.assertNotIn("::error", output)
self.assertIn(
- "::warning title=CC001 message::Commit 1/1: The commit message should "
- "follow Conventional Commits.",
+ '::warning title=CC001 message::Commit 1/1 "bad message": '
+ "Use (): ",
output,
)
self.assertIn(
- "commit-check (dry-run): 1 of 2 checks failed; not failing the job",
+ "Commit Check (dry-run): 1 of 2 checks failed; not failing the job",
output,
)
self.assertNotIn("all checks passed", output)
- self.assertNotIn("✖ commit-check", output)
+ self.assertNotIn("✖ Commit Check", output)
def test_dry_run_without_failures_prints_the_usual_verdict(self):
with patch("main.DRY_RUN_ENABLED", True):
output = self._run([pass_scope("Branch")])
- self.assertIn("✔ commit-check: all checks passed", output)
+ self.assertIn("✔ Commit Check: all checks passed", output)
self.assertNotIn("dry-run", output)
+#: Fixed lines of the report's "How to fix" section.
+AGENT_PROMPT = "Fix these Commit Check findings without changing any code:"
+HOOK_HELP = (
+ f"**Next time.** The [pre-commit hook]({HOOK_GUIDE}) runs the same "
+ "rules before you push."
+)
+
+
class TestRenderJobSummary(unittest.TestCase):
@pin_version
def test_success_golden_output(self):
@@ -1888,25 +1996,28 @@ def test_success_golden_output(self):
pass_scope("Commit 2/2", value="fix: resolve timeout"),
pass_scope("Branch", value="feature/add-login"),
]
+ results[1].sha, results[2].sha = SHA_A, SHA_B
body = main.render_report(results)
self.assertEqual(
body,
f"{main.COMMENT_MARKER}\n"
- f"{main.REPORT_TITLE}\n"
+ f"{heading('✔', 'passed', 'passed')}\n"
"\n"
- "✅ **All 4 checks passed**\n"
+ f"{group_line('✔', 'passed', 'Pull request: passed')}
\n"
+ f"{group_line('✔', 'passed', 'Commits: 2 passed')}
\n"
+ f"{group_line('✔', 'passed', 'Branch: passed')}\n"
"\n"
"\n"
- "Show all 4 checks
\n"
+ "All 4 checks
\n"
"\n"
"```text\n"
"Pull request\n"
- " ✔ PR title (feat: add login page)\n"
- "Commit message\n"
- " ✔ Commit 1/2 (feat: add user auth)\n"
- " ✔ Commit 2/2 (fix: resolve timeout)\n"
+ " ✔ title feat: add login page\n"
+ "Commits\n"
+ " ✔ d87faca feat: add user auth\n"
+ " ✔ 5584f46 fix: resolve timeout\n"
"Branch\n"
- " ✔ Branch (feature/add-login)\n"
+ " ✔ branch feature/add-login\n"
"```\n"
"\n"
" \n"
@@ -1916,7 +2027,8 @@ def test_success_golden_output(self):
@pin_version
def test_failure_golden_output(self):
- """Pin the full failure report: failed row in the table, all in details."""
+ """Pin the full failure report: the diff of what failed, how to fix
+ it, and every scope in the listing."""
results = [
pass_scope("PR title", value="feat: add login page"),
fail_scope("Commit 2/2"),
@@ -1926,29 +2038,55 @@ def test_failure_golden_output(self):
self.assertEqual(
body,
f"{main.COMMENT_MARKER}\n"
- f"{main.REPORT_TITLE}\n"
+ f"{heading('✖', 'failed', 'failed')}\n"
"\n"
- "❌ **1 of 3 checks failed**\n"
+ f"{group_line('✔', 'passed', 'Pull request: passed')}
\n"
+ f"{group_line('✖', 'failed', 'Commits: failed')}
\n"
+ f"{group_line('✔', 'passed', 'Branch: passed')}\n"
"\n"
- "| Scope | Checked value | Failed checks |\n"
- "|---|---|---|\n"
- "| Commit 2/2 | `bad message` | "
- "[CC001 message](https://commit-check.com/rules/#cc001) |\n"
+ "```diff\n"
+ "@@ Commit 2/2 @@\n"
+ "- bad message\n"
+ "# Use (): \n"
+ "```\n"
+ "\n"
+ "Rules: [CC001](https://commit-check.com/rules/#cc001)\n"
"\n"
"\n"
- "Show all 3 checks
\n"
+ "How to fix
\n"
+ "\n"
+ "**With an AI coding agent.** Paste this prompt:\n"
+ "\n"
+ "```text\n"
+ f"{AGENT_PROMPT}\n"
+ '- Commit 2/2 "bad message": Use (): \n'
+ "```\n"
+ "\n"
+ "**Commit messages.** Reword it, then force-push:\n"
+ "\n"
+ "```bash\n"
+ "git commit --amend\n"
+ "git push --force-with-lease\n"
+ "```\n"
+ "\n"
+ f"{HOOK_HELP}\n"
+ "\n"
+ " \n"
+ "\n"
+ "\n"
+ "All 3 checks
\n"
"\n"
"```text\n"
"Pull request\n"
- " ✔ PR title (feat: add login page)\n"
- "Commit message\n"
- " ✖ Commit 2/2 (1 failure)\n"
+ " ✔ title feat: add login page\n"
+ "Commits\n"
+ " ✖ 2/2 bad message\n"
" CC001 message\n"
" value: bad message\n"
" The commit message should follow Conventional Commits.\n"
" Suggest: Use (): \n"
"Branch\n"
- " ✔ Branch (feature/add-login)\n"
+ " ✔ branch feature/add-login\n"
"```\n"
"\n"
" \n"
@@ -1958,8 +2096,9 @@ def test_failure_golden_output(self):
@pin_version
def test_failure_golden_output_with_commits(self):
- """Pin the report for a pull request's commits: the table row links
- to the commit, the tree names it, and a mechanical fix is shown."""
+ """Pin the report for a pull request's commits: each is named by its
+ short hash, linked under the diff, and a mechanical fix is a green
+ line."""
results = [
pass_scope("PR title", value="feat: Add login page"),
fail_scope("Commit 1/3", sha=SHA_A),
@@ -1971,42 +2110,74 @@ def test_failure_golden_output_with_commits(self):
env = {"GITHUB_REPOSITORY": "acme/widgets", "GITHUB_SERVER_URL": ""}
with patch.dict(os.environ, env):
body = main.render_report(results)
+ commits = "https://github.com/acme/widgets/commit"
self.assertEqual(
body,
f"{main.COMMENT_MARKER}\n"
- f"{main.REPORT_TITLE}\n"
+ f"{heading('✖', 'failed', 'failed')}\n"
+ "\n"
+ f"{group_line('✔', 'passed', 'Pull request: passed')}
\n"
+ f"{group_line('✖', 'failed', 'Commits: 2 of 3 failed')}
\n"
+ f"{group_line('✔', 'passed', 'Branch: passed')}\n"
"\n"
- "❌ **2 of 5 checks failed**\n"
+ "```diff\n"
+ "@@ d87faca @@\n"
+ "- bad message\n"
+ "# Use (): \n"
+ "@@ 5584f46 @@\n"
+ "- feat: add login page\n"
+ "+ feat: Add login page\n"
+ "```\n"
"\n"
- "| Scope | Checked value | Failed checks |\n"
- "|---|---|---|\n"
- f"| [Commit 1/3 (d87faca)](https://github.com/acme/widgets/commit/{SHA_A})"
- " | `bad message` | "
- "[CC001 message](https://commit-check.com/rules/#cc001) |\n"
- f"| [Commit 2/3 (5584f46)](https://github.com/acme/widgets/commit/{SHA_B})"
- " | `feat: add login page` | "
- "[CC002 subject-capitalized](https://commit-check.com/rules/#cc002) |\n"
+ f"Commits: [`d87faca`]({commits}/{SHA_A}) "
+ f"[`5584f46`]({commits}/{SHA_B}) | "
+ "Rules: [CC001](https://commit-check.com/rules/#cc001) · "
+ "[CC002](https://commit-check.com/rules/#cc002)\n"
"\n"
"\n"
- "Show all 5 checks
\n"
+ "How to fix
\n"
+ "\n"
+ "**With an AI coding agent.** Paste this prompt:\n"
+ "\n"
+ "```text\n"
+ f"{AGENT_PROMPT}\n"
+ '- Commit d87faca "bad message": Use (): \n'
+ '- Commit 5584f46: change "feat: add login page" to '
+ '"feat: Add login page"\n'
+ "```\n"
+ "\n"
+ "**Commit messages.** Reword them, then force-push:\n"
+ "\n"
+ "```bash\n"
+ 'git rebase -i d87faca~1 # change "pick" to "reword" on the '
+ "commits above\n"
+ "git push --force-with-lease\n"
+ "```\n"
+ "\n"
+ f"{HOOK_HELP}\n"
+ "\n"
+ " \n"
+ "\n"
+ "\n"
+ "All 5 checks
\n"
"\n"
"```text\n"
"Pull request\n"
- " ✔ PR title (feat: Add login page)\n"
- "Commit message\n"
- " ✖ Commit 1/3 (d87faca) (1 failure)\n"
+ " ✔ title feat: Add login page\n"
+ "Commits\n"
+ " ✖ d87faca bad message\n"
" CC001 message\n"
" value: bad message\n"
" The commit message should follow Conventional Commits.\n"
" Suggest: Use (): \n"
- " ✖ Commit 2/3 (5584f46) (1 failure)\n"
+ " ✖ 5584f46 feat: add login page\n"
" CC002 subject-capitalized\n"
" value: feat: add login page\n"
" Subject must start with a capital letter\n"
" Fix: feat: Add login page\n"
- " ✔ Commit 3/3 (37d6def) (fix: Handle timeout)\n"
+ " ✔ 37d6def fix: Handle timeout\n"
"Branch\n"
- " ✔ Branch (feature/add-login)\n"
+ " ✔ branch feature/add-login\n"
"```\n"
"\n"
" \n"
@@ -2014,7 +2185,7 @@ def test_failure_golden_output_with_commits(self):
f"{FOOTER}",
)
- def test_table_link_honours_the_server_url(self):
+ def test_commit_link_honours_the_server_url(self):
"""GitHub Enterprise Server is not github.com."""
env = {
"GITHUB_REPOSITORY": "acme/widgets",
@@ -2023,26 +2194,36 @@ def test_table_link_honours_the_server_url(self):
with patch.dict(os.environ, env):
body = main.render_report([fix_scope("Commit 1/1", sha=SHA_B)])
self.assertIn(
- f"| [Commit 1/1 (5584f46)](https://ghe.example.com/acme/widgets/commit/{SHA_B}) |",
+ f"Commits: [`5584f46`](https://ghe.example.com/acme/widgets/commit/{SHA_B}) ",
body,
)
- def test_table_has_no_link_without_a_repository_to_link_into(self):
+ def test_no_commit_link_without_a_repository_to_link_into(self):
with patch.dict(os.environ, {"GITHUB_REPOSITORY": ""}):
body = main.render_report([fix_scope("Commit 1/1", sha=SHA_B)])
- self.assertIn("| Commit 1/1 (5584f46) | `feat: add login page` |", body)
+ self.assertIn("@@ 5584f46 @@\n- feat: add login page\n", body)
+ self.assertIn("\nRules: [CC002](", body)
self.assertNotIn("/commit/", body)
def test_scopes_without_a_commit_are_never_linked(self):
with patch.dict(os.environ, {"GITHUB_REPOSITORY": "acme/widgets"}):
body = main.render_report([fail_scope("Branch")])
- self.assertIn("| Branch | `bad message` |", body)
+ self.assertIn("@@ Branch @@\n- bad message\n", body)
+ self.assertNotIn("/commit/", body)
+
+ def test_an_author_scope_is_named_by_its_label_not_its_commit(self):
+ """An author check runs on a commit, but the finding is the author."""
+ scope = fail_scope("Author email", sha=SHA_A)
+ with patch.dict(os.environ, {"GITHUB_REPOSITORY": "acme/widgets"}):
+ body = main.render_report([scope])
+ self.assertIn("@@ Author email @@\n", body)
+ self.assertIn(" ✖ email bad message\n", body)
self.assertNotIn("/commit/", body)
def test_skipped_scope_carries_the_sha_too(self):
scope = skip_scope("Commit 1/1")
scope.sha = SHA_A
- self.assertIn(" ⊘ Commit 1/1 (d87faca) (skipped)", main.render_report([scope]))
+ self.assertIn(" ⊘ d87faca skipped\n", main.render_report([scope]))
def test_a_check_is_a_thing_checked_not_a_rule_evaluation(self):
"""The total counts scopes, so it tracks the policy, not the PR size.
@@ -2060,15 +2241,18 @@ def test_a_check_is_a_thing_checked_not_a_rule_evaluation(self):
make_check("subject_min_length", status="fail", rule_id="CC005"),
],
)
- body = main.render_report([pass_scope("Branch"), two_failures])
- self.assertIn("❌ **1 of 2 checks failed**", body)
- self.assertIn("Show all 2 checks", body)
- # Both failing rules are still named, in the table and the details.
+ body = main.render_report(
+ [pass_scope("Branch"), two_failures, pass_scope("Commit 2/2")]
+ )
+ self.assertIn("> Commits: 1 of 2 failed", body)
+ self.assertIn("All 3 checks", body)
+ # Both failing rules are still named, under the diff and in the listing.
+ self.assertIn("Rules: CC001 · CC005", body)
self.assertIn("CC001 message", body)
self.assertIn("CC005 subject-min-length", body)
def test_total_does_not_grow_with_the_number_of_rules(self):
- """Adding rules to one scope must not change the headline total."""
+ """Adding rules to one scope must not change the totals."""
one_rule = [pass_scope("Branch")]
many_rules = [
main.ScopeResult(
@@ -2078,28 +2262,37 @@ def test_total_does_not_grow_with_the_number_of_rules(self):
],
)
]
- self.assertIn("✅ **All 1 check passed**", main.render_report(one_rule))
- self.assertIn("✅ **All 1 check passed**", main.render_report(many_rules))
-
- def test_table_has_no_constant_result_column(self):
- """Only failures reach the table, so a result column would never vary."""
- body = main.render_job_summary([fail_scope("Commit 1/1")])
- self.assertIn("| Scope | Checked value | Failed checks |", body)
- self.assertNotIn("| Result |", body)
+ for results in (one_rule, many_rules):
+ body = main.render_report(results)
+ self.assertIn("> Branch: passed\n", body)
+ self.assertIn("All 1 check", body)
def test_body_opens_with_hidden_marker(self):
body = main.render_job_summary([pass_scope("Branch")])
self.assertTrue(body.startswith(main.COMMENT_MARKER))
+ def test_the_heading_is_the_verdict(self):
+ """The mark carries the status, so no tick ever sits next to a failure."""
+ cases = [
+ ([pass_scope("Branch")], heading("✔", "passed", "passed")),
+ ([fail_scope("Branch")], heading("✖", "failed", "failed")),
+ ([warn_scope()], heading("⚠", "warned", "passed with warnings")),
+ ([skip_scope("Branch")], heading("⊘", "skipped", "skipped")),
+ ]
+ for results, expected in cases:
+ with self.subTest(expected=expected):
+ lines = main.render_report(results).splitlines()
+ self.assertEqual(lines[1], expected)
+
def test_all_pass(self):
body = main.render_job_summary([pass_scope("Branch", value="main")])
- self.assertIn(main.REPORT_TITLE, body)
- self.assertIn("✅ **All 1 check passed**", body)
+ self.assertIn("Commit Check passed\n", body)
self.assertIn("", body)
- self.assertIn("Show all 1 check
", body)
+ self.assertIn("All 1 check
", body)
self.assertIn("```text", body)
- self.assertIn("Branch", body)
- self.assertIn(" ✔ Branch (main)", body)
+ self.assertIn("\nBranch\n ✔ branch main\n", body)
+ self.assertNotIn("```diff", body)
+ self.assertNotIn("How to fix", body)
def test_all_pass_groups_scopes_like_step_log(self):
results = [
@@ -2111,42 +2304,61 @@ def test_all_pass_groups_scopes_like_step_log(self):
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"))
- self.assertIn(" ✔ PR title (feat: add login page)", body)
- self.assertIn(" ✔ Branch (feature/pr-12)", body)
- self.assertIn(" ✔ Author email (jane@example.com)", body)
+ listing = body.split("```text\n")[1]
+ # Group headers in the listing mirror the step log ordering.
+ self.assertLess(listing.index("Commits\n"), listing.index("Branch\n"))
+ self.assertLess(listing.index("Branch\n"), listing.index("Author\n"))
+ self.assertIn(" ✔ title feat: add login page\n", listing)
+ self.assertIn(" ✔ branch feature/pr-12\n", listing)
+ self.assertIn(" ✔ email jane@example.com\n", listing)
+ self.assertIn("> Author: 2 passed", 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' * 57}...)", body)
+ self.assertIn(f" ✔ 1/1 {'x' * 57}...\n", 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)
+ self.assertIn(" ✔ branch\n", body)
- def test_failure_renders_table_with_rule_links(self):
+ def test_failure_shows_the_diff_and_the_rule_links(self):
body = main.render_job_summary([fail_scope("Commit 1/1")])
- self.assertIn(main.REPORT_TITLE, body)
- self.assertIn("❌ **1 of 1 check failed**", body)
- self.assertIn("| Scope | Checked value | Failed checks |", body)
+ self.assertIn("Commit Check failed\n", body)
+ self.assertIn("> Commits: failed\n", body)
self.assertIn(
- "| Commit 1/1 | `bad message` | "
- "[CC001 message](https://commit-check.com/rules/#cc001) |",
+ "```diff\n"
+ "@@ Commit 1/1 @@\n"
+ "- bad message\n"
+ "# Use (): \n"
+ "```\n",
body,
)
- self.assertIn("", body)
- self.assertIn("Show all 1 check
", body)
- self.assertIn("```text", body)
- self.assertIn("✖ Commit 1/1 (1 failure)", body)
+ self.assertIn(
+ "Rules: [CC001](https://commit-check.com/rules/#cc001)", body
+ )
+ self.assertIn("All 1 check
", body)
+ self.assertIn(" ✖ 1/1 bad message\n", body)
self.assertIn(" CC001 message", body)
- # The failing value appears in full; the table truncates at 60.
self.assertIn(" value: bad message", body)
self.assertIn(" Suggest: Use (): ", body)
- self.assertIn("[Rules reference](https://commit-check.com/rules/)", body)
+ self.assertIn("[Rules](https://commit-check.com/rules/)", body)
+
+ def test_a_failing_value_is_printed_in_full(self):
+ """It is the one value the reader has to act on; the cap is for the
+ rows that passed."""
+ subject = "feat: " + "x" * 100
+ scope = main.ScopeResult(
+ label="Commit 1/1",
+ checks=[
+ make_check(
+ "subject_max_length", status="fail", rule_id="CC004", value=subject
+ )
+ ],
+ )
+ body = main.render_report([scope])
+ self.assertIn(f"- {subject}\n", body)
+ self.assertIn(f" ✖ 1/1 {subject}\n", body)
def test_failure_details_show_all_scopes_and_values(self):
results = [
@@ -2154,16 +2366,326 @@ def test_failure_details_show_all_scopes_and_values(self):
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)
+ self.assertIn(" ✔ 2/2 fix: resolve timeout\n", body)
+ self.assertIn(" ✖ 1/2 bad message\n", body)
- def test_pass_scope_renders_checkmark_without_value(self):
+ def test_passing_scopes_stay_out_of_the_diff(self):
body = main.render_job_summary([pass_scope("Branch"), fail_scope("Commit 1/1")])
- # 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)
+ diff = body.split("```diff\n")[1].split("```")[0]
+ self.assertEqual(diff.count("@@ "), 1)
+ self.assertIn("@@ Commit 1/1 @@", diff)
+ self.assertIn(" ✔ branch\n", body)
+
+
+def ai_scope(label: str = "Commit 1/1", sha: str = SHA_A) -> main.ScopeResult:
+ """A commit that credits an AI co-author, under ``ai_attribution = "forbid"``:
+ the rule's value is the tool, and its fix the message without the trailer."""
+ return main.ScopeResult(
+ label=label,
+ sha=sha,
+ text=(
+ "feat: add OAuth providers\n\n"
+ "Co-authored-by: Claude \n"
+ ),
+ checks=[
+ make_check(
+ "ai_attribution",
+ status="fail",
+ rule_id="CC013",
+ value="Claude Code",
+ error="AI attribution is forbidden in this project",
+ suggest="Remove the AI trailer lines and re-commit.",
+ fix="feat: add OAuth providers",
+ docs_url="https://commit-check.com/rules/#cc013",
+ )
+ ],
+ )
+
+
+class TestLineEdits(unittest.TestCase):
+ """What a rule's fix removes and adds, which the diff shows red and green."""
+
+ def test_a_subject_rule_is_compared_with_the_subject_alone(self):
+ """Its fix is the subject, so the body is not something it removed."""
+ scope = fix_scope()
+ scope.text = "feat: add login page\n\nThe body stays.\n"
+ self.assertEqual(
+ main._line_edits(scope, scope.checks[0]),
+ (["feat: add login page"], ["feat: Add login page"]),
+ )
+
+ def test_a_whole_message_fix_is_compared_with_the_whole_message(self):
+ message = "feat: add login page\n\nThe body stays."
+ scope = main.ScopeResult(
+ label="Commit 1/1",
+ text=message + "\n",
+ checks=[
+ make_check(
+ "require_signed_off_by",
+ status="fail",
+ rule_id="CC012",
+ value=message,
+ fix=f"{message}\n\nSigned-off-by: Jane Doe ",
+ )
+ ],
+ )
+ self.assertEqual(
+ main._line_edits(scope, scope.checks[0]),
+ ([], ["Signed-off-by: Jane Doe "]),
+ )
+
+ def test_an_ai_rule_is_compared_with_the_message_not_with_its_value(self):
+ """The value is the tool's name; the fix drops the trailer."""
+ scope = ai_scope()
+ self.assertEqual(
+ main._line_edits(scope, scope.checks[0]),
+ (["Co-authored-by: Claude "], []),
+ )
+
+ def test_an_ai_rule_without_the_message_has_no_line_to_name(self):
+ scope = ai_scope()
+ scope.text = ""
+ self.assertEqual(main._line_edits(scope, scope.checks[0]), ([], []))
+ self.assertEqual(
+ main._instruction(scope, scope.checks[0]),
+ "Remove the AI trailer lines and re-commit.",
+ )
+
+ def test_the_message_rule_supplies_the_text_outside_a_pull_request(self):
+ """Nothing is piped in for the HEAD commit, but the message rule's
+ value is the whole message."""
+ scope = ai_scope()
+ scope.checks.insert(0, make_check("message", value=scope.text.strip()))
+ scope.text = ""
+ self.assertEqual(
+ main._line_edits(scope, scope.checks[1]),
+ (["Co-authored-by: Claude "], []),
+ )
+ self.assertEqual(main._first_value(scope), "feat: add OAuth providers")
+
+ def test_a_rule_without_a_fix_has_no_edits(self):
+ scope = fail_scope()
+ self.assertEqual(main._line_edits(scope, scope.checks[0]), ([], []))
+
+ def test_a_fix_with_nothing_to_compare_it_with_has_no_edits(self):
+ scope = main.ScopeResult(
+ label="Branch", checks=[make_check("branch", status="fail", fix="x")]
+ )
+ self.assertEqual(main._line_edits(scope, scope.checks[0]), ([], []))
+
+ def test_recorded_edits_win_over_the_fix(self):
+ """A description is too long to carry as a fix, so its line changes
+ are recorded when it is checked."""
+ scope = fail_scope("PR description")
+ scope.edits["CC001"] = (["Generated with Claude Code"], [])
+ self.assertEqual(
+ main._line_edits(scope, scope.checks[0]),
+ (["Generated with Claude Code"], []),
+ )
+
+ def test_instruction_names_each_kind_of_change(self):
+ scope = fail_scope()
+ check = scope.checks[0]
+ cases = [
+ ((["a"], ["b"]), 'change "a" to "b"'),
+ ((["a", "b"], []), 'remove "a"; "b"'),
+ (([], ["c"]), 'add "c"'),
+ ((["a", "b"], ["c"]), 'remove "a"; "b", and add "c"'),
+ ]
+ for edits, expected in cases:
+ with self.subTest(expected=expected):
+ scope.edits["CC001"] = edits
+ self.assertEqual(main._instruction(scope, check), expected)
+
+ def test_advice_drops_what_only_the_rule_page_needs(self):
+ cases = {
+ "Use (): , where is one of: feat, fix": (
+ "Use (): "
+ ),
+ "Use / with an allowed type, or add the branch "
+ "to allow_branch_names in config": (
+ "Use / with an allowed type"
+ ),
+ 'In the description, remove "x"': 'remove "x"',
+ "Add a body\nexplaining the change": "Add a body",
+ }
+ for suggest, expected in cases.items():
+ with self.subTest(suggest=suggest):
+ check = make_check("message", status="fail", suggest=suggest)
+ self.assertEqual(main._advice(check), expected)
+
+ def test_advice_falls_back_to_the_error_without_its_link(self):
+ check = make_check(
+ "message",
+ status="fail",
+ error="Follow Conventional Commits. See https://www.conventionalcommits.org",
+ )
+ self.assertEqual(main._advice(check), "Follow Conventional Commits.")
+
+
+class TestDiffBlock(unittest.TestCase):
+ def _hunks(self, results, status="fail"):
+ lines = main._diff_block(results, status)
+ return lines[1 : lines.index("```")]
+
+ def test_a_fix_on_another_line_leaves_the_subject_as_context(self):
+ self.assertEqual(
+ self._hunks([ai_scope()]),
+ [
+ "@@ d87faca @@",
+ " feat: add OAuth providers",
+ "- Co-authored-by: Claude ",
+ ],
+ )
+
+ def test_advice_is_about_the_value_so_the_value_is_red(self):
+ scope = fail_scope("Commit 1/1", sha=SHA_A)
+ scope.checks.append(
+ make_check(
+ "subject_min_length",
+ status="fail",
+ rule_id="CC005",
+ value="bad message",
+ suggest="Write a subject of at least 20 characters",
+ )
+ )
+ self.assertEqual(
+ self._hunks([scope]),
+ [
+ "@@ d87faca @@",
+ "- bad message",
+ "# Use (): ",
+ "# Write a subject of at least 20 characters",
+ ],
+ )
+
+ def test_two_rules_that_ask_for_the_same_line_show_it_once(self):
+ scope = ai_scope()
+ scope.checks.append({**scope.checks[0], "rule_id": "CC015"})
+ self.assertEqual(len(self._hunks([scope])), 3)
+
+ def test_a_description_is_named_by_its_label_and_shows_only_the_lines(self):
+ scope = fail_scope("PR description")
+ scope.text = "Adds login.\n\nGenerated with Claude Code\n"
+ scope.edits["CC001"] = (["Generated with Claude Code"], [])
+ self.assertEqual(
+ self._hunks([scope]),
+ ["@@ PR description @@", "- Generated with Claude Code"],
+ )
+
+ def test_a_long_list_is_cut_and_the_rest_folded(self):
+ results = [fail_scope(f"Commit {n}/8", sha=f"{n}" * 40) for n in range(1, 9)]
+ with patch.dict(os.environ, {"GITHUB_REPOSITORY": "acme/widgets"}):
+ lines = main._diff_block(results)
+ body = "\n".join(lines)
+ shown, folded = body.split("")
+ self.assertEqual(shown.count("@@ "), main.MAX_HUNKS)
+ self.assertIn("and 3 more
\n\n```diff\n@@ 6666666 @@", folded)
+ self.assertEqual(folded.count("@@ "), 3)
+ # Only the commits in view are linked; the rule is named once.
+ links = lines[-1]
+ self.assertEqual(links.count("/commit/"), main.MAX_HUNKS)
+ self.assertNotIn("6666666", links)
+ self.assertEqual(links.count("[CC001]"), 1)
+
+ def test_one_or_two_leftovers_are_not_worth_a_fold(self):
+ results = [fail_scope(f"Commit {n}/7", sha=f"{n}" * 40) for n in range(1, 8)]
+ body = "\n".join(main._diff_block(results))
+ self.assertEqual(body.count("@@ "), 7)
+ self.assertNotIn("", body)
+
+ def test_warnings_get_the_same_block(self):
+ self.assertEqual(
+ self._hunks([warn_scope(), fail_scope()], "warn"),
+ ["@@ Branch @@", "- jsmith/fix-x", "# Use /"],
+ )
+
+
+class TestHowToFix(unittest.TestCase):
+ def _body(self, results):
+ return "\n".join(main._how_to_fix(results))
+
+ def test_nothing_failed_nothing_to_fix(self):
+ self.assertEqual(main._how_to_fix([pass_scope(), warn_scope()]), [])
+
+ def test_the_prompt_lists_every_failing_scope_with_its_instruction(self):
+ results = [
+ fail_scope("PR title"),
+ ai_scope("Commit 1/2"),
+ fix_scope("Commit 2/2"),
+ pass_scope("Branch"),
+ ]
+ self.assertIn(
+ "```text\n"
+ f"{AGENT_PROMPT}\n"
+ '- PR title "bad message": Use (): \n'
+ '- Commit d87faca "feat: add OAuth providers": remove '
+ '"Co-authored-by: Claude "\n'
+ '- Commit 5584f46: change "feat: add login page" to '
+ '"feat: Add login page"\n'
+ "```\n",
+ self._body(results),
+ )
+
+ def test_only_the_last_commit_failing_is_an_amend(self):
+ for scope in (
+ fail_scope("Commit 3/3", sha=SHA_A),
+ fail_scope("Commit message"),
+ ):
+ with self.subTest(label=scope.label):
+ body = self._body([scope])
+ self.assertIn("Reword it, then force-push:", body)
+ self.assertIn("```bash\ngit commit --amend\ngit push --force", body)
+ self.assertNotIn("rebase", body)
+
+ def test_an_earlier_commit_needs_a_rebase_from_its_parent(self):
+ body = self._body(
+ [fail_scope("Commit 2/3", sha=SHA_B), pass_scope("Commit 3/3")]
+ )
+ self.assertIn("```bash\ngit rebase -i 5584f46~1 # change", body)
+
+ def test_a_commit_without_a_hash_gets_a_placeholder(self):
+ body = self._body([fail_scope("Commit 1/3"), fail_scope("Commit 2/3")])
+ self.assertIn("Reword them, then force-push:", body)
+ self.assertIn("git rebase -i ~1 # change", body)
+
+ def test_each_kind_of_failure_gets_its_own_paragraph(self):
+ body = self._body(
+ [
+ fail_scope("PR title"),
+ fail_scope("PR description"),
+ fail_scope("Author email", sha=SHA_A),
+ fail_scope("Branch"),
+ ]
+ )
+ self.assertNotIn("**Commit messages.**", body)
+ self.assertIn("**Author.** Set your git identity", body)
+ self.assertIn(
+ "**Branch name.** Rename the branch and push it. A pull request cannot "
+ "change its branch, so open a new one from the renamed branch:\n\n"
+ "```bash\ngit branch -m /\ngit push -u origin HEAD\n```",
+ body,
+ )
+ self.assertIn(
+ "**Pull request title and description.** Edit them on the pull "
+ "request page.",
+ body,
+ )
+ self.assertTrue(body.endswith(f"{HOOK_HELP}\n\n "))
+
+ def test_the_branch_command_uses_the_corrected_name_when_there_is_one(self):
+ scope = fail_scope("Branch")
+ scope.checks[0].update(value="Feature/Add-Login", fix="feature/Add-Login")
+ self.assertIn(
+ "```bash\ngit branch -m feature/Add-Login\ngit push -u origin HEAD\n```",
+ self._body([scope]),
+ )
+
+ def test_one_pull_request_field_is_it_not_them(self):
+ body = self._body([fail_scope("PR title")])
+ self.assertIn("**Pull request title.** Edit it on the pull request page.", body)
+ self.assertNotIn("**Branch name.**", body)
+ self.assertNotIn("**Author.**", body)
class TestRenderPrComment(unittest.TestCase):
@@ -2172,34 +2694,41 @@ 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.COMMENT_MARKER))
- self.assertIn("✅ **All 1 check passed**", comment)
+ self.assertIn("Commit Check passed\n", 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.COMMENT_MARKER))
- self.assertIn("❌ **1 of 1 check failed**", comment)
- self.assertIn("| Scope | Checked value | Failed checks |", comment)
+ self.assertIn("Commit Check failed\n", comment)
+ self.assertIn("```diff\n@@ Commit 1/1 @@\n", comment)
class TestUnparsableScopeInTheReport(unittest.TestCase):
"""A CLI response that is not JSON is a failure with no rule to link."""
- def test_the_table_row_points_at_the_details(self):
+ def test_the_diff_points_at_the_step_log(self):
scope = main.ScopeResult(label="Branch", raw_text="Error: cchk.toml: oops")
body = main.render_report([scope])
- self.assertIn("❌ **1 of 1 check failed**", body)
+ self.assertIn("Commit Check failed\n", body)
self.assertIn(
- "| Branch | — | _output could not be parsed — see details_ |", body
+ "```diff\n"
+ "@@ Branch @@\n"
+ "# the output could not be parsed, see the step log\n"
+ "```\n",
+ body,
)
- # The details block is where the raw output went.
- self.assertIn(" ✖ Branch\n Error: cchk.toml: oops\n", body)
+ # No rule spoke, so there is none to link and nothing to instruct.
+ self.assertNotIn("Rules", body)
+ self.assertNotIn("How to fix", body)
+ # The listing is where the raw output went.
+ self.assertIn(" ✖ branch\n Error: cchk.toml: oops\n", body)
- def test_it_never_reaches_the_warnings_table(self):
+ def test_it_never_reaches_the_warnings(self):
scope = main.ScopeResult(label="Branch", raw_text="garbage")
- table = main._markdown_table([scope], "warn", "Warnings")
- self.assertEqual(table.splitlines()[2:], [])
+ self.assertNotIn("**Warnings**", main.render_report([scope]))
+ self.assertEqual(main._diff_block([scope], "warn")[:2], ["```diff", "```"])
class TestReportFooter(unittest.TestCase):
@@ -2208,8 +2737,9 @@ def test_names_the_installed_commit_check(self):
self.assertEqual(main._commit_check_version(), "9.8.7")
self.assertEqual(
main._report_footer(),
- "_commit-check 9.8.7 · "
- "[Rules reference](https://commit-check.com/rules/)_",
+ "Checked by [Commit Check](https://commit-check.com) 9.8.7 · "
+ "[Rules](https://commit-check.com/rules/) · "
+ f"[Check before you push]({HOOK_GUIDE})",
)
version.assert_called_with("commit-check")
@@ -2220,7 +2750,9 @@ def test_without_a_version_it_still_links_the_rules(self):
self.assertEqual(main._commit_check_version(), "")
self.assertEqual(
main._report_footer(),
- "_[Rules reference](https://commit-check.com/rules/)_",
+ "Checked by [Commit Check](https://commit-check.com) · "
+ "[Rules](https://commit-check.com/rules/) · "
+ f"[Check before you push]({HOOK_GUIDE})",
)
@@ -2240,7 +2772,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 1 check passed**", content)
+ self.assertIn("Commit Check passed\n", content)
def test_failure_returns_nonzero(self):
summary_path = os.path.join(tempfile.mkdtemp(), "summary.txt")
@@ -2252,8 +2784,8 @@ 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 | Checked value | Failed checks |", content)
- self.assertIn("❌", content)
+ self.assertIn("Commit Check failed\n", content)
+ self.assertIn("```diff\n@@ Commit 1/1 @@\n- bad message\n", content)
def read_github_output(path: str) -> dict[str, str]:
@@ -2443,7 +2975,7 @@ def test_fork_pr_warning_names_only_the_surfaces_that_exist(self):
rc = main.add_pr_comments([pass_scope()])
self.assertEqual(rc, 0)
printed = mock_print.call_args[0][0]
- self.assertIn("annotations on the Files changed tab", printed)
+ self.assertIn("annotations on the run page", printed)
self.assertNotIn("job's summary", printed)
self.assertFalse(os.path.exists(summary_path))
@@ -2505,7 +3037,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.COMMENT_MARKER))
- self.assertIn("| Scope | Checked value | Failed checks |", body)
+ self.assertIn("```diff\n@@ Commit 1/1 @@\n- bad message\n", body)
def test_updates_existing_comment_when_changed(self):
# A report from an earlier version: no marker, and posted by the bot,
@@ -2951,7 +3483,9 @@ def test_running_the_script_runs_main(self):
mock_run.assert_not_called()
self.assertIn("::debug::MESSAGE=false\n", out.getvalue())
self.assertTrue(
- out.getvalue().endswith("✔ commit-check: all checks passed\n"),
+ ANSI_CODES.sub("", out.getvalue()).endswith(
+ "✔ Commit Check: all checks passed\n"
+ ),
out.getvalue(),
)
@@ -3053,18 +3587,21 @@ def test_all_skipped_golden_output(self):
self.assertEqual(
body,
f"{main.COMMENT_MARKER}\n"
- f"{main.REPORT_TITLE}\n"
+ f"{heading('⊘', 'skipped', 'skipped')}\n"
"\n"
- "⊘ **All 2 checks skipped** — nothing was validated\n"
+ "Nothing was validated.\n"
+ "\n"
+ f"{group_line('⊘', 'skipped', 'Pull request: skipped')}
\n"
+ f"{group_line('⊘', 'skipped', 'Commits: skipped')}\n"
"\n"
"\n"
- "Show all 2 checks
\n"
+ "All 2 checks
\n"
"\n"
"```text\n"
"Pull request\n"
- " ⊘ PR title (skipped)\n"
- "Commit message\n"
- " ⊘ Commit 1/1 (skipped)\n"
+ " ⊘ title skipped\n"
+ "Commits\n"
+ " ⊘ 1/1 skipped\n"
"```\n"
"\n"
" \n"
@@ -3074,19 +3611,24 @@ def test_all_skipped_golden_output(self):
def test_partial_skip_does_not_claim_all_passed(self):
results = [pass_scope("Branch", value="main"), skip_scope("PR title")]
+ results.append(pass_scope("PR description"))
body = main.render_report(results)
- self.assertIn("✅ **1 of 2 checks passed**, 1 skipped", body)
- self.assertNotIn("All 2 checks passed", body)
+ self.assertIn("Commit Check passed\n", body)
+ self.assertIn("> Branch: passed
\n", body)
+ # Two scopes in the group, one of which ran: never "2 passed".
+ self.assertIn("> Pull request: 1 passed, 1 skipped\n", body)
def test_failure_still_wins_over_skips(self):
results = [fail_scope("Commit 1/1"), skip_scope("PR title")]
body = main.render_report(results)
- self.assertIn("❌ **1 of 2 checks failed**", body)
+ self.assertIn("Commit Check failed\n", body)
+ self.assertIn("> Commits: failed
\n", body)
+ self.assertIn("> Pull request: skipped\n", body)
def test_skipped_scope_reports_no_checked_value(self):
"""The ⊘ line carries no value, because nothing was examined."""
body = main.render_report([skip_scope("PR title")])
- self.assertIn(" ⊘ PR title (skipped)", body)
+ self.assertIn(" ⊘ title skipped\n", body)
def test_skip_reason_is_shown_on_the_scope_and_under_the_verdict(self):
"""A skip says why, so a deliberate bypass reads as one."""
@@ -3098,20 +3640,21 @@ def test_skip_reason_is_shown_on_the_scope_and_under_the_verdict(self):
skip_scope("Branch", reason=branch),
]
body = main.render_report(results)
+ self.assertIn("Commit Check skipped\n\nNothing was validated.\n", body)
self.assertIn(
- "⊘ **All 3 checks skipped** — nothing was validated\n\n"
+ "> Branch: skipped\n\n"
"Skipped because author dependabot[bot] is in ignore_authors.\n",
body,
)
- self.assertIn(f" ⊘ PR title (skipped: {commit})", body)
- self.assertIn(f" ⊘ Branch (skipped: {branch})", body)
+ self.assertIn(f" ⊘ title skipped: {commit}\n", body)
+ self.assertIn(f" ⊘ branch skipped: {branch}\n", body)
def test_skip_reason_follows_a_partial_skip_verdict(self):
reason = "author dependabot[bot] is in [branch].ignore_authors"
results = [pass_scope("PR title"), skip_scope("Branch", reason=reason)]
body = main.render_report(results)
self.assertIn(
- "✅ **1 of 2 checks passed**, 1 skipped\n\n"
+ "> Branch: skipped\n\n"
"Skipped because author dependabot[bot] is in ignore_authors.\n",
body,
)
@@ -3120,7 +3663,10 @@ def test_skip_reason_reaches_the_step_log(self):
reason = "author dependabot[bot] is in [commit].ignore_authors"
with patch("sys.stdout", new_callable=io.StringIO) as out:
main.render_step_log([skip_scope("PR title", reason=reason)])
- self.assertIn(f" ⊘ PR title (skipped: {reason})", out.getvalue())
+ self.assertIn("::group::⊘ Pull request: skipped\n", out.getvalue())
+ self.assertIn(
+ f" ⊘ title skipped: {reason}\n", ANSI_CODES.sub("", out.getvalue())
+ )
self.assertIn(
"Skipped because author dependabot[bot] is in ignore_authors.",
out.getvalue(),
@@ -3142,7 +3688,7 @@ def test_a_passing_scope_has_no_skip_reason(self):
def test_older_engine_without_skip_is_unaffected(self):
"""Back-compat: engines that only emit pass/fail render as before."""
results = [pass_scope("Branch", value="main")]
- self.assertIn("✅ **All 1 check passed**", main.render_report(results))
+ self.assertIn("Commit Check passed\n", main.render_report(results))
def warn_scope(label: str = "Branch", value: str = "jsmith/fix-x") -> main.ScopeResult:
@@ -3183,9 +3729,9 @@ def test_a_real_failure_outranks_a_warning_in_the_same_scope(self):
def test_a_warning_survives_reporting_on_a_scope_that_also_fails(self):
"""The scope's status names its worst outcome, but both findings
- still belong on every surface: the count, both tables, and the
- details block. Filtering any of those on ``scope.status == "warn"``
- drops this scope's warning the moment its sibling rule fails.
+ still belong on every surface: the count, both diffs, and the
+ listing. Filtering any of those on ``scope.status == "warn"`` drops
+ this scope's warning the moment its sibling rule fails.
"""
mixed = main.ScopeResult(
label="Branch",
@@ -3212,15 +3758,33 @@ def test_a_warning_survives_reporting_on_a_scope_that_also_fails(self):
self.assertEqual(main._warn_count([mixed]), 1)
body = main.render_report([mixed])
- self.assertIn("❌ **1 of 1 check failed**, 1 warning", body)
- self.assertIn("| Scope | Checked value | Failed checks |", body)
- self.assertIn("[CC202 merge-base](https://commit-check.com/rules/#cc202)", body)
- self.assertIn("| Scope | Checked value | Warnings |", body)
- self.assertIn("[CC201 branch](https://commit-check.com/rules/#cc201)", body)
- self.assertIn(" ✖ Branch (1 failure)", body)
- self.assertIn(" CC202 merge-base", body)
- self.assertIn(" ⚠ Branch (1 warning)", body)
- self.assertIn(" CC201 branch", body)
+ self.assertIn("Commit Check failed\n", body)
+ failed, warned = body.split("**Warnings**")
+ self.assertIn(
+ "```diff\n"
+ "@@ Branch @@\n"
+ "- jsmith/fix-x\n"
+ "# Current branch is not rebased onto main.\n"
+ "```\n"
+ "\n"
+ "Rules: [CC202](https://commit-check.com/rules/#cc202)\n",
+ failed,
+ )
+ self.assertIn(
+ "```diff\n"
+ "@@ Branch @@\n"
+ "- jsmith/fix-x\n"
+ "# Use /\n"
+ "```\n"
+ "\n"
+ "Rules: [CC201](https://commit-check.com/rules/#cc201)\n",
+ warned,
+ )
+ # One row, marked with the worse of the two, and both findings under
+ # it; the warning says that it is one.
+ self.assertIn(" ✖ branch jsmith/fix-x\n CC202 merge-base\n", body)
+ self.assertIn("\n CC201 branch (warning)\n", body)
+ self.assertNotIn("⚠ branch", body)
def test_step_log_and_report_agree_on_a_mixed_scope(self):
"""The step log's ::warning annotation must not be the only surface
@@ -3297,24 +3861,30 @@ def test_warning_only_golden_output(self):
self.assertEqual(
body,
f"{main.COMMENT_MARKER}\n"
- f"{main.REPORT_TITLE}\n"
+ f"{heading('⚠', 'warned', 'passed with warnings')}\n"
"\n"
- "✅ **1 of 2 checks passed**, 1 warning\n"
+ f"{group_line('✔', 'passed', 'Pull request: passed')}
\n"
+ f"{group_line('⚠', 'warned', 'Branch: 1 warning')}\n"
"\n"
- "| Scope | Checked value | Warnings |\n"
- "|---|---|---|\n"
- "| Branch | `jsmith/fix-x` | "
- "[CC201 branch](https://commit-check.com/rules/#cc201) |\n"
+ "**Warnings**\n"
+ "\n"
+ "```diff\n"
+ "@@ Branch @@\n"
+ "- jsmith/fix-x\n"
+ "# Use /\n"
+ "```\n"
+ "\n"
+ "Rules: [CC201](https://commit-check.com/rules/#cc201)\n"
"\n"
"\n"
- "Show all 2 checks
\n"
+ "All 2 checks
\n"
"\n"
"```text\n"
"Pull request\n"
- " ✔ PR title (feat: add login page)\n"
+ " ✔ title feat: add login page\n"
"Branch\n"
- " ⚠ Branch (1 warning)\n"
- " CC201 branch\n"
+ " ⚠ branch jsmith/fix-x\n"
+ " CC201 branch (warning)\n"
" value: jsmith/fix-x\n"
" The branch should follow Conventional Branch.\n"
" Suggest: Use /\n"
@@ -3325,30 +3895,43 @@ def test_warning_only_golden_output(self):
f"{FOOTER}",
)
- def test_warning_and_skip_both_fold_into_the_title_in_order(self):
- results = [pass_scope("PR title"), warn_scope(), skip_scope("Author")]
+ def test_warning_and_skip_each_get_their_own_group_line(self):
+ results = [pass_scope("PR title"), warn_scope(), skip_scope("Author name")]
body = main.render_report(results)
- self.assertIn("✅ **1 of 3 checks passed**, 1 warning, 1 skipped", body)
+ self.assertIn("Commit Check passed with warnings\n", body)
+ self.assertIn(
+ f"{group_line('✔', 'passed', 'Pull request: passed')}
\n"
+ f"{group_line('⚠', 'warned', 'Branch: 1 warning')}
\n"
+ f"{group_line('⊘', 'skipped', 'Author: skipped')}\n",
+ body,
+ )
def test_a_failure_still_fails_and_names_the_warning_too(self):
results = [fail_scope("Commit 1/1"), warn_scope()]
body = main.render_report(results)
- self.assertIn("❌ **1 of 2 checks failed**, 1 warning", body)
- self.assertIn("| Scope | Checked value | Failed checks |", body)
- self.assertIn("| Scope | Checked value | Warnings |", body)
- self.assertLess(
- body.index("Failed checks"),
- body.index("| Scope | Checked value | Warnings |"),
- )
+ self.assertIn("Commit Check failed\n", body)
+ self.assertEqual(body.count("```diff\n"), 2)
+ # The failures come first, the warnings under their own heading, and
+ # only the failures get instructions.
+ self.assertLess(body.index("@@ Commit 1/1 @@"), body.index("**Warnings**"))
+ self.assertLess(body.index("**Warnings**"), body.index("@@ Branch @@"))
+ self.assertLess(body.index("@@ Branch @@"), body.index("How to fix"))
+ self.assertNotIn("Branch name.", body)
def test_step_log_prints_a_warning_annotation_not_an_error(self):
buf = io.StringIO()
with patch("sys.stdout", buf):
main.render_step_log([warn_scope()])
out = buf.getvalue()
- self.assertIn("::warning title=CC201 branch::Branch: The branch should ", out)
+ self.assertIn(
+ '::warning title=CC201 branch::Branch "jsmith/fix-x": '
+ "Use /%0AThe branch should ",
+ out,
+ )
self.assertNotIn("::error", out)
- self.assertIn(" ⚠ Branch (1 warning)", out)
+ self.assertIn("::group::⚠ Branch: 1 warning\n", out)
+ self.assertIn(" \x1b[33m⚠\x1b[0m branch jsmith/fix-x\n", out)
+ self.assertNotIn("How to fix", out)
def test_step_log_friendly_line_mentions_the_warning(self):
buf = io.StringIO()
@@ -3415,16 +3998,12 @@ class TestMarkdownEscaping(unittest.TestCase):
"""User text in the report must not break the Markdown that carries it.
Commit subjects quote code, branch names and author names may contain
- pipes; the table cell and the fenced details block have to survive both.
- The checks below come from a real ``commit-check --format json`` run on
- the subject ``fix: handle `None` | retry`` (all rules pass on it, so the
- CC001 outcome is flipped to make a table row).
+ pipes; every value lands in a fenced block, and the fence has to survive
+ both. The checks below come from a real ``commit-check --format json``
+ run on the subject ``fix: handle `None` | retry`` (all rules pass on it,
+ so the CC001 outcome is flipped to make it a finding).
"""
- #: Splits a table row on the pipes that are cell separators, not on the
- #: escaped ones inside a cell.
- CELL_SEPARATOR = re.compile(r"(? main.ScopeResult:
return main.ScopeResult(
label="Commit 1/1",
@@ -3442,71 +4021,71 @@ def _scope(self, value: str) -> main.ScopeResult:
],
)
- def test_markdown_code_wraps_backticks_and_escapes_pipes(self):
- cases = {
- "plain": "`plain`",
- "a|b": "`a\\|b`",
- "fix: handle `None` | retry": "``fix: handle `None` \\| retry``",
- "`x`": "`` `x` ``",
- "``x``": "``` ``x`` ```",
- "x`": "`` x` ``",
- }
- for value, expected in cases.items():
- with self.subTest(value=value):
- self.assertEqual(main._markdown_code(value), expected)
-
- def test_table_row_with_backticks_and_a_pipe_keeps_three_cells(self):
- table = main._markdown_table([self._scope("fix: handle `None` | retry")])
- row = table.splitlines()[2]
- # Strip the outer pipes before counting: "| a | b | c |" -> 3 cells.
- cells = self.CELL_SEPARATOR.split(row.strip().strip("|"))
- self.assertEqual(len(cells), 3, row)
- self.assertEqual(cells[1].strip(), "``fix: handle `None` \\| retry``")
- self.assertEqual(
- cells[2].strip(), "[CC001 message](https://commit-check.com/rules/#cc001)"
- )
-
- def test_truncated_value_with_an_unbalanced_backtick_still_closes(self):
- """The 60-char cap can cut inside a backtick run; the fence is chosen
- after truncation, so the span still closes at the right place."""
+ def test_diff_fence_outgrows_a_triple_backtick_in_the_subject(self):
+ diff = main._diff_block([self._scope("docs: show ``` usage")])
+ self.assertEqual(diff[0], "````diff")
+ self.assertEqual(diff[2], "- docs: show ``` usage")
+ self.assertEqual(diff[4], "````")
+
+ def test_prompt_fence_outgrows_a_triple_backtick_in_the_subject(self):
+ how = main._how_to_fix([self._scope("docs: show ``` usage")])
+ self.assertIn("````text", how)
+ self.assertEqual(how.count("````"), 1)
+ # The commands quote nothing of the user's, so their fence stays.
+ self.assertIn("```bash", how)
+
+ def test_a_subject_with_a_pipe_or_backticks_is_shown_as_it_is(self):
+ """Every place a value lands is a code block, so nothing is escaped."""
+ subject = "fix: handle `None` | retry"
+ body = main.render_report([self._scope(subject)])
+ self.assertIn(f"- {subject}\n", body)
+ self.assertIn(f" ✖ 5584f46 {subject}\n", body)
+
+ def test_capped_value_with_an_unbalanced_backtick_stays_in_its_block(self):
+ """The 60-char cap can cut inside a backtick run."""
value = "fix: " + "`x`, " * 20
- truncated = main._scope_value(self._scope(value))
- self.assertTrue(truncated.endswith("`x..."), truncated) # cut mid-span
- cell = main._markdown_code(truncated)
- self.assertEqual(cell, f"``{truncated}``")
+ scope = pass_scope("Commit 1/1", value=value)
+ capped = main._scope_value(scope)
+ self.assertTrue(capped.endswith("`x..."), capped) # cut mid-span
+ listing = main._all_checks([scope])
+ self.assertEqual(listing[3], "```text")
+ self.assertEqual(listing[-3], "```")
def test_details_fence_outgrows_a_triple_backtick_in_the_value(self):
- details = main._markdown_details([self._scope("docs: show ``` usage")])
+ details = "\n".join(main._all_checks([self._scope("docs: show ``` usage")]))
lines = details.splitlines()
self.assertEqual(lines[3], "````text")
self.assertEqual(lines[-3], "````")
self.assertIn("value: docs: show ``` usage", details)
def test_details_fence_stays_three_backticks_for_ordinary_values(self):
- details = main._markdown_details([pass_scope(value="feat: add `login`")])
- lines = details.splitlines()
+ lines = main._all_checks([pass_scope(value="feat: add `login`")])
self.assertEqual(lines[3], "```text")
self.assertEqual(lines[-3], "```")
class TestSkipRenderingEdgeCases(unittest.TestCase):
- def test_failure_table_omits_skipped_scopes(self):
- """A skipped scope has no failed checks, so it must not get a row.
-
- It previously contributed a row with an empty value and an empty
- rule list — a blank accusation under "Failed checks".
- """
- table = main._markdown_table([fail_scope("Commit 1/1"), skip_scope("PR title")])
- self.assertIn("| Commit 1/1 |", table)
- self.assertNotIn("PR title", table)
- # header + separator + exactly one data row
- self.assertEqual(len(table.splitlines()), 3)
+ def test_the_diff_omits_skipped_scopes(self):
+ """A skipped scope has no failed checks, so it must not get a hunk:
+ a blank accusation among what failed otherwise."""
+ diff = main._diff_block([fail_scope("Commit 1/1"), skip_scope("PR title")])
+ self.assertEqual(
+ diff[:5],
+ [
+ "```diff",
+ "@@ Commit 1/1 @@",
+ "- bad message",
+ "# Use (): ",
+ "```",
+ ],
+ )
def test_empty_result_set_is_not_reported_as_all_skipped(self):
"""`skipped == total` is trivially true for no scopes at all."""
body = main.render_report([])
self.assertNotIn("skipped", body)
- self.assertIn("✅ **All 0 checks passed**", body)
+ self.assertIn("Commit Check passed\n\n\n", body)
+ self.assertIn("Details
", body)
def test_step_log_partial_skip_does_not_claim_all_passed(self):
buf = io.StringIO()
@@ -3521,9 +4100,9 @@ def test_step_log_all_skipped_claims_no_pass(self):
with patch("sys.stdout", buf):
main.render_step_log([skip_scope(), skip_scope("Branch")])
out = buf.getvalue()
- self.assertIn(" ⊘ PR title (skipped)\n", out)
+ self.assertIn(" \x1b[90m⊘\x1b[0m title skipped\n", out)
self.assertTrue(
- out.endswith("⊘ commit-check: all checks skipped, nothing was validated\n"),
+ out.endswith("⊘ Commit Check: all checks skipped, nothing was validated\n"),
out,
)
self.assertNotIn("passed", out)
@@ -3639,7 +4218,7 @@ def test_failing_message(self):
self.assertEqual(main.overall_status([scope]), "fail")
# The rendered report must carry the rule link the CLI supplied.
self.assertIn(
- "[CC001 message](https://commit-check.com/rules/#cc001)",
+ "Rules: [CC001](https://commit-check.com/rules/#cc001)",
main.render_report([scope]),
)
@@ -3887,6 +4466,11 @@ def test_forbid_names_the_line_to_delete(self):
'In the description, remove "Link to Devin run: https://app.devin.ai/sessions/1"',
)
self.assertEqual(scope.checks[0]["fix"], "")
+ # The fix is gone from the check, so the lines are kept for the diff.
+ self.assertEqual(
+ scope.edits,
+ {"CC013": (["Link to Devin run: https://app.devin.ai/sessions/1"], [])},
+ )
def test_disclose_names_the_line_to_add_and_the_one_it_replaces(self):
body = "Adds a parser.\n\nCo-Authored-By: Claude "