From 8130f3466d62bcd9db14cdc892f0c6d6ffd7dd48 Mon Sep 17 00:00:00 2001 From: Daniel Sagenschneider Date: Thu, 27 Aug 2026 00:57:51 +0800 Subject: [PATCH 1/2] Adding it as GitHub action --- .github/workflows/impact.yml | 19 +++++++++ README.md | 34 +++++++++++++++- action.yml | 75 ++++++++++++++++++++++++++++++++++++ impact_gate/cli.py | 4 +- impact_gate/report.py | 46 +++++++++++++++++++--- tests/test_cli.py | 11 ++++++ 6 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/impact.yml create mode 100644 action.yml diff --git a/.github/workflows/impact.yml b/.github/workflows/impact.yml new file mode 100644 index 0000000..5b19970 --- /dev/null +++ b/.github/workflows/impact.yml @@ -0,0 +1,19 @@ +name: Change impact + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + impact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # needed so the base branch and merge-base are present + - uses: ./ # the impact-gate action in this repo + with: + enforcement: warn # report only for now; switch to block when ready diff --git a/README.md b/README.md index b5acb07..ddbb269 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,42 @@ wmc_context: before # measure definition. Uses the pre-change container. Thi CLI flags override the file. A CI job can pass `--tolerance` or `--warn-at`. So a team can dial tolerance without editing the repo. +## Use in GitHub Actions + +Add a workflow to your repo. The action scores the PR branch against its base and writes +a summary. `fetch-depth: 0` is required so the base branch and merge-base are present. + +```yaml +name: Change impact +on: pull_request +permissions: + contents: read +jobs: + impact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: officefloor/ImpactGate@v1 + with: + enforcement: warn # switch to block when ready + # warn-at: 50000 + # block-at: 200000 + # tolerance: 1.0 +``` + +The score appears in the job summary. In `block` mode the job fails when impact exceeds +the block threshold. Make the check required in branch protection to gate merges. + ## Roadmap -- Core CLI. Score staged, worktree, or range. Warn or block. Text and JSON. Done. This is it. +- Core CLI. Score staged, worktree, or range. Warn or block. Text, JSON, markdown. Done. +- GitHub Action. Composite action plus a job-summary report. Done. - Baseline and grading curve. Profile the project history to set thresholds automatically. Blend a seed-corpus prior with the project's own impact distribution. Grade a change by its percentile. This is next. - Distribution. A Dockerfile so it runs on any CI with Docker. A `pip` package. -- CI plugins. A GitHub Action first. Then a GitLab CI template and a Jenkins shared library. +- More CI plugins. A GitLab CI template and a Jenkins shared library. - Hooks and IDE. An `impact-gate install-hook` for pre-commit. Editor integration over LSP. +- PR comment. Post the score as a sticky comment, not just a job summary. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..09ce277 --- /dev/null +++ b/action.yml @@ -0,0 +1,75 @@ +name: "impact-gate" +description: "Measure and gate the structural change-impact of a pull request." +branding: + icon: "activity" + color: "purple" + +inputs: + mode: + description: "What to score: staged, worktree, or range (default, for CI)." + default: "range" + base: + description: "Base ref for range mode. Defaults to the PR base (origin/)." + default: "" + enforcement: + description: "off, warn, or block. Overrides .impact-gate.yml." + default: "" + warn-at: + description: "Impact above which to warn." + default: "" + block-at: + description: "Impact above which to block." + default: "" + tolerance: + description: "Multiplier applied to both thresholds." + default: "" + wmc-context: + description: "before (canonical) or after." + default: "" + config: + description: "Path to an .impact-gate.yml." + default: "" + working-directory: + description: "Path to the repository to score." + default: "." + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install impact-gate + shell: bash + run: pip install "${{ github.action_path }}" + - name: Score change impact + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + INPUT_MODE: ${{ inputs.mode }} + INPUT_BASE: ${{ inputs.base }} + INPUT_ENFORCEMENT: ${{ inputs.enforcement }} + INPUT_WARN_AT: ${{ inputs.warn-at }} + INPUT_BLOCK_AT: ${{ inputs.block-at }} + INPUT_TOLERANCE: ${{ inputs.tolerance }} + INPUT_WMC_CONTEXT: ${{ inputs.wmc-context }} + INPUT_CONFIG: ${{ inputs.config }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -o pipefail + BASE="$INPUT_BASE" + if [ -z "$BASE" ]; then + if [ -n "$GITHUB_BASE_REF" ]; then + BASE="origin/$GITHUB_BASE_REF" + else + BASE="origin/$DEFAULT_BRANCH" + fi + fi + args=(score --repo . --mode "$INPUT_MODE" --base "$BASE") + if [ -n "$INPUT_ENFORCEMENT" ]; then args+=(--enforcement "$INPUT_ENFORCEMENT"); fi + if [ -n "$INPUT_WARN_AT" ]; then args+=(--warn-at "$INPUT_WARN_AT"); fi + if [ -n "$INPUT_BLOCK_AT" ]; then args+=(--block-at "$INPUT_BLOCK_AT"); fi + if [ -n "$INPUT_TOLERANCE" ]; then args+=(--tolerance "$INPUT_TOLERANCE"); fi + if [ -n "$INPUT_WMC_CONTEXT" ]; then args+=(--wmc-context "$INPUT_WMC_CONTEXT"); fi + if [ -n "$INPUT_CONFIG" ]; then args+=(--config "$INPUT_CONFIG"); fi + impact-gate "${args[@]}" --format markdown | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/impact_gate/cli.py b/impact_gate/cli.py index e17cb00..61e98ed 100644 --- a/impact_gate/cli.py +++ b/impact_gate/cli.py @@ -27,7 +27,7 @@ def _add_score_args(p: argparse.ArgumentParser) -> None: help="base ref for --mode range (default: main). Use e.g. " "origin/main in CI.") p.add_argument("--repo", default=".", help="path to the git repo (default: .)") - p.add_argument("--format", choices=("text", "json"), default="text") + p.add_argument("--format", choices=("text", "json", "markdown"), default="text") p.add_argument("--config", help="path to an .impact-gate.yml (else auto-discovered in --repo)") # threshold / enforcement overrides (win over the config file when given) p.add_argument("--warn-at", type=int) @@ -64,6 +64,8 @@ def _cmd_score(args) -> int: if args.format == "json": print(report.render_json(score, cfg, level, args.mode, args.base, blocked)) + elif args.format == "markdown": + print(report.render_markdown(score, cfg, level, args.mode, args.base, blocked)) else: print(report.render_text(score, cfg, level, args.mode, args.base, blocked)) if blocked: diff --git a/impact_gate/report.py b/impact_gate/report.py index fc72245..26f7fad 100644 --- a/impact_gate/report.py +++ b/impact_gate/report.py @@ -24,9 +24,9 @@ def render_text(score: ChangeScore, cfg: GateConfig, level: str, mode: str, base: str, blocked: bool) -> str: if score.empty: return "impact-gate: no source changes to score." - # Tag reflects the OUTCOME under the current enforcement, not just the severity: - # in warn/off mode a change over the block threshold is allowed (tag WARN) with a - # nudge that it will fail once enforcement is 'block' — the warn->block on-ramp. + # Tag reflects the OUTCOME under the current enforcement, not just the severity. + # In warn/off mode a change over the block threshold is allowed (tag WARN), with a + # nudge that it will fail once enforcement is 'block'. This is the warn->block on-ramp. tag = "BLOCK" if blocked else ("OK" if level == "ok" else "WARN") desc = _MODE_DESC[mode].format(base=base) lines = [ @@ -36,11 +36,11 @@ def render_text(score: ChangeScore, cfg: GateConfig, level: str, f"({desc}, wmc-context: {cfg.wmc_context})", ] if level == "block" and not blocked: - lines.append(" note: over the block threshold — this will fail once " + lines.append(" note: over the block threshold. This will fail once " "enforcement is set to 'block'.") if level != "ok" and score.units: lines.append("") - lines.append("Top cost drivers — simplify or refactor these:") + lines.append("Top cost drivers. Simplify or refactor these:") for u in score.units[:5]: loc = f"{u.path}:{u.name}" if u.name else u.path lines.append(f" {u.cost:>12,} {loc} " @@ -73,3 +73,39 @@ def render_json(score: ChangeScore, cfg: GateConfig, level: str, "cc": u.cc, "wmc_other": u.wmc_other, "cost": u.cost, "kind": u.kind} for u in score.units[:10]], }, indent=2) + + +def render_markdown(score: ChangeScore, cfg: GateConfig, level: str, + mode: str, base: str, blocked: bool) -> str: + """GitHub/GitLab-friendly summary. Written to the CI job summary.""" + if score.empty: + return "**impact-gate:** no source changes to score." + verdict = {"ok": "✅ OK", "warn": "⚠️ WARN", "block": "⛔ BLOCK"}[ + "block" if blocked else ("ok" if level == "ok" else "warn")] + desc = _MODE_DESC[mode].format(base=base) + lines = [ + f"## Change impact: {score.impact:,} {verdict}", + "", + "| metric | value |", + "|---|---|", + f"| files changed | {score.files_changed} |", + f"| mutation (disturbing existing code) | {score.mutation:,} |", + f"| new code | {score.godclass:,} |", + ] + w, b = cfg.effective_warn(), cfg.effective_block() + if w is not None: + lines.append(f"| warn threshold | {int(w):,} |") + if b is not None: + lines.append(f"| block threshold | {int(b):,} |") + lines.append(f"| scope | {desc}, wmc-context {cfg.wmc_context} |") + if level == "block" and not blocked: + lines += ["", "> Over the block threshold. This will fail once enforcement " + "is set to `block`."] + if level != "ok" and score.units: + lines += ["", "### Top cost drivers. Simplify or refactor these.", "", + "| cost | location | CC | WMC_other | kind |", + "|---|---|---|---|---|"] + for u in score.units[:5]: + loc = f"`{u.path}:{u.name}`" if u.name else f"`{u.path}`" + lines.append(f"| {u.cost:,} | {loc} | {u.cc} | {u.wmc_other} | {u.kind} |") + return "\n".join(lines) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4974e94..ecb5ad8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -56,6 +56,17 @@ def test_json_output_shape(repo, capsys): assert set(("impact", "level", "blocked", "mode", "thresholds")) <= data.keys() +def test_markdown_output(repo, capsys): + _prepare(repo) + code = main(["score", "--repo", str(repo), "--mode", "worktree", + "--format", "markdown", "--warn-at", "1"]) + out = capsys.readouterr().out + assert code == 0 + assert "## Change impact: 2" in out + assert "| metric | value |" in out + assert "Top cost drivers" in out # warn level shows drivers + + def test_bad_base_returns_exit_1(repo, capsys): write(repo, "m.py", BASE) commit(repo, "base") From 4f92ca8879b2dfd90c7540c26bcd66e60ff460d3 Mon Sep 17 00:00:00 2001 From: Daniel Sagenschneider Date: Thu, 27 Aug 2026 01:12:50 +0800 Subject: [PATCH 2/2] Providing PR comment --- .github/workflows/impact.yml | 1 + README.md | 10 ++-- action.yml | 18 ++++++- impact_gate/cli.py | 31 +++++++++++- impact_gate/ghapi.py | 94 ++++++++++++++++++++++++++++++++++++ tests/test_ghapi.py | 57 ++++++++++++++++++++++ 6 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 impact_gate/ghapi.py create mode 100644 tests/test_ghapi.py diff --git a/.github/workflows/impact.yml b/.github/workflows/impact.yml index 5b19970..174eaf1 100644 --- a/.github/workflows/impact.yml +++ b/.github/workflows/impact.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: write # so the action can post the score as a PR comment jobs: impact: diff --git a/README.md b/README.md index ddbb269..041123f 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ name: Change impact on: pull_request permissions: contents: read + pull-requests: write # so the action can post the score as a PR comment jobs: impact: runs-on: ubuntu-latest @@ -90,17 +91,18 @@ jobs: # tolerance: 1.0 ``` -The score appears in the job summary. In `block` mode the job fails when impact exceeds -the block threshold. Make the check required in branch protection to gate merges. +The score appears in the job summary and as a sticky comment on the PR (one comment, +updated each run). In `block` mode the job fails when impact exceeds the block threshold. +Make the check required in branch protection to gate merges. The comment needs +`pull-requests: write`. Without it the run still passes and just skips the comment. ## Roadmap - Core CLI. Score staged, worktree, or range. Warn or block. Text, JSON, markdown. Done. -- GitHub Action. Composite action plus a job-summary report. Done. +- GitHub Action. Composite action, job-summary report, and a sticky PR comment. Done. - Baseline and grading curve. Profile the project history to set thresholds automatically. Blend a seed-corpus prior with the project's own impact distribution. Grade a change by its percentile. This is next. - Distribution. A Dockerfile so it runs on any CI with Docker. A `pip` package. - More CI plugins. A GitLab CI template and a Jenkins shared library. - Hooks and IDE. An `impact-gate install-hook` for pre-commit. Editor integration over LSP. -- PR comment. Post the score as a sticky comment, not just a job summary. diff --git a/action.yml b/action.yml index 09ce277..93f84b8 100644 --- a/action.yml +++ b/action.yml @@ -32,6 +32,9 @@ inputs: working-directory: description: "Path to the repository to score." default: "." + github-token: + description: "Token used to post the PR comment. Defaults to the workflow token." + default: "" runs: using: "composite" @@ -55,8 +58,9 @@ runs: INPUT_WMC_CONTEXT: ${{ inputs.wmc-context }} INPUT_CONFIG: ${{ inputs.config }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_TOKEN: ${{ inputs.github-token || github.token }} run: | - set -o pipefail + set +e # do not abort on a blocking score; capture it and gate at the end BASE="$INPUT_BASE" if [ -z "$BASE" ]; then if [ -n "$GITHUB_BASE_REF" ]; then @@ -72,4 +76,14 @@ runs: if [ -n "$INPUT_TOLERANCE" ]; then args+=(--tolerance "$INPUT_TOLERANCE"); fi if [ -n "$INPUT_WMC_CONTEXT" ]; then args+=(--wmc-context "$INPUT_WMC_CONTEXT"); fi if [ -n "$INPUT_CONFIG" ]; then args+=(--config "$INPUT_CONFIG"); fi - impact-gate "${args[@]}" --format markdown | tee -a "$GITHUB_STEP_SUMMARY" + + report="$RUNNER_TEMP/impact-gate.md" + impact-gate "${args[@]}" --format markdown | tee "$report" + code=${PIPESTATUS[0]} + cat "$report" >> "$GITHUB_STEP_SUMMARY" + + if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ -n "$GITHUB_TOKEN" ]; then + impact-gate comment --body-file "$report" \ + || echo "::warning::impact-gate could not post the PR comment. Grant 'pull-requests: write' to the workflow." + fi + exit "$code" diff --git a/impact_gate/cli.py b/impact_gate/cli.py index 61e98ed..12f7de0 100644 --- a/impact_gate/cli.py +++ b/impact_gate/cli.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse +import os import sys from .core.config import MeasureConfig @@ -69,12 +70,33 @@ def _cmd_score(args) -> int: else: print(report.render_text(score, cfg, level, args.mode, args.base, blocked)) if blocked: - print("\nimpact-gate: change BLOCKED — impact exceeds the block threshold. " + print("\nimpact-gate: change BLOCKED. Impact exceeds the block threshold. " "Simplify the change or refactor the code it touches, then retry.", file=sys.stderr) return 2 if blocked else 0 +def _cmd_comment(args) -> int: + from . import ghapi + token = args.token or os.environ.get("GITHUB_TOKEN") + repo = args.repo_slug or os.environ.get("GITHUB_REPOSITORY") + pr = args.pr or ghapi.detect_pr_number(os.environ.get("GITHUB_EVENT_PATH")) + if not token or not repo or not pr: + print("impact-gate: need a token, repo (owner/name), and PR number to comment " + "(GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_EVENT_PATH are set in Actions).", + file=sys.stderr) + return 1 + body = (open(args.body_file, encoding="utf-8").read() + if args.body_file else sys.stdin.read()) + try: + result = ghapi.upsert_pr_comment(ghapi.GitHubAPI(token), repo, int(pr), body) + except Exception as e: + print(f"impact-gate: could not post PR comment: {e}", file=sys.stderr) + return 1 + print(f"impact-gate: PR comment {result}") + return 0 + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(prog="impact-gate", description="Report and gate on the change-impact of a change.") @@ -84,6 +106,13 @@ def main(argv: list[str] | None = None) -> int: _add_score_args(s) s.set_defaults(func=_cmd_score) + c = sub.add_parser("comment", help="upsert a sticky PR comment with a report (CI)") + c.add_argument("--body-file", help="markdown file to post (default: read stdin)") + c.add_argument("--repo-slug", help="owner/name (default: $GITHUB_REPOSITORY)") + c.add_argument("--pr", type=int, help="PR number (default: from $GITHUB_EVENT_PATH)") + c.add_argument("--token", help="GitHub token (default: $GITHUB_TOKEN)") + c.set_defaults(func=_cmd_comment) + args = ap.parse_args(argv) try: return args.func(args) diff --git a/impact_gate/ghapi.py b/impact_gate/ghapi.py new file mode 100644 index 0000000..4c5f9e5 --- /dev/null +++ b/impact_gate/ghapi.py @@ -0,0 +1,94 @@ +"""Minimal GitHub REST client for posting a sticky PR comment. + +Standard library only (urllib), so the tool keeps its lizard-only footprint. Used by +the `impact-gate comment` subcommand from CI. The pure helpers (`find_existing`, +`detect_pr_number`) are separated from HTTP so they can be unit tested without network. +""" +from __future__ import annotations + +import json +import os +import urllib.request + +# Hidden marker used to find and update our own comment, so each run edits one sticky +# comment instead of adding a new one every time. +MARKER = "" +API = "https://api.github.com" + + +def find_existing(comments: list, marker: str = MARKER): + """Return the first comment whose body carries the marker, or None.""" + for c in comments: + if marker in (c.get("body") or ""): + return c + return None + + +def detect_pr_number(event_path: str | None) -> int | None: + """Read the PR number from the GitHub event payload (GITHUB_EVENT_PATH).""" + if not event_path or not os.path.isfile(event_path): + return None + try: + with open(event_path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None + if isinstance(data.get("pull_request"), dict) and data["pull_request"].get("number"): + return int(data["pull_request"]["number"]) + if data.get("number"): + return int(data["number"]) + return None + + +class GitHubAPI: + def __init__(self, token: str, api: str = API): + self.token = token + self.api = api.rstrip("/") + + def _request(self, method: str, path: str, payload: dict | None = None): + url = path if path.startswith("http") else f"{self.api}{path}" + body = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Authorization", f"Bearer {self.token}") + req.add_header("Accept", "application/vnd.github+json") + req.add_header("X-GitHub-Api-Version", "2022-11-28") + req.add_header("User-Agent", "impact-gate") + if payload is not None: + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req) as resp: + raw = resp.read().decode("utf-8") + return json.loads(raw) if raw else None + + def list_issue_comments(self, repo: str, issue: int) -> list: + out: list = [] + page = 1 + while True: + batch = self._request( + "GET", f"/repos/{repo}/issues/{issue}/comments?per_page=100&page={page}") + if not batch: + break + out.extend(batch) + if len(batch) < 100: + break + page += 1 + return out + + def create_comment(self, repo: str, issue: int, body: str): + return self._request("POST", f"/repos/{repo}/issues/{issue}/comments", + {"body": body}) + + def update_comment(self, repo: str, comment_id: int, body: str): + return self._request("PATCH", f"/repos/{repo}/issues/comments/{comment_id}", + {"body": body}) + + +def upsert_pr_comment(api: GitHubAPI, repo: str, pr: int, body: str, + marker: str = MARKER) -> str: + """Create the sticky comment, or update it if one already exists. Returns which.""" + tagged = f"{marker}\n{body}" + existing = find_existing(api.list_issue_comments(repo, pr), marker) + if existing: + api.update_comment(repo, existing["id"], tagged) + return "updated" + api.create_comment(repo, pr, tagged) + return "created" diff --git a/tests/test_ghapi.py b/tests/test_ghapi.py new file mode 100644 index 0000000..64f1a2d --- /dev/null +++ b/tests/test_ghapi.py @@ -0,0 +1,57 @@ +"""Sticky PR-comment logic, without touching the network.""" +import json + +from impact_gate import ghapi + + +class FakeAPI: + def __init__(self, existing): + self.existing = existing + self.created = [] + self.updated = [] + + def list_issue_comments(self, repo, pr): + return self.existing + + def create_comment(self, repo, pr, body): + self.created.append((repo, pr, body)) + + def update_comment(self, repo, comment_id, body): + self.updated.append((repo, comment_id, body)) + + +def test_find_existing_matches_marker(): + comments = [{"body": "unrelated"}, {"id": 5, "body": ghapi.MARKER + "\nhi"}] + assert ghapi.find_existing(comments)["id"] == 5 + assert ghapi.find_existing([{"body": "none here"}]) is None + + +def test_detect_pr_number(tmp_path): + p = tmp_path / "event.json" + p.write_text(json.dumps({"pull_request": {"number": 7}})) + assert ghapi.detect_pr_number(str(p)) == 7 + p2 = tmp_path / "event2.json" + p2.write_text(json.dumps({"number": 9})) + assert ghapi.detect_pr_number(str(p2)) == 9 + assert ghapi.detect_pr_number(None) is None + assert ghapi.detect_pr_number(str(tmp_path / "missing.json")) is None + + +def test_upsert_creates_when_absent(): + api = FakeAPI(existing=[]) + result = ghapi.upsert_pr_comment(api, "o/r", 3, "BODY") + assert result == "created" + repo, pr, body = api.created[0] + assert repo == "o/r" and pr == 3 + assert ghapi.MARKER in body and "BODY" in body + assert not api.updated + + +def test_upsert_updates_when_present(): + api = FakeAPI(existing=[{"id": 42, "body": ghapi.MARKER + "\nold"}]) + result = ghapi.upsert_pr_comment(api, "o/r", 3, "NEW BODY") + assert result == "updated" + repo, comment_id, body = api.updated[0] + assert comment_id == 42 + assert "NEW BODY" in body and ghapi.MARKER in body + assert not api.created