Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/impact.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Change impact

on:
pull_request:
workflow_dispatch:

permissions:
contents: read
pull-requests: write # so the action can post the score as a PR comment

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
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,44 @@ 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
pull-requests: write # so the action can post the score as a PR comment
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 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 and JSON. Done. This is it.
- Core CLI. Score staged, worktree, or range. Warn or block. Text, JSON, markdown. 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.
- 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.
89 changes: 89 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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/<base_ref>)."
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: "."
github-token:
description: "Token used to post the PR comment. Defaults to the workflow token."
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 }}
GITHUB_TOKEN: ${{ inputs.github-token || github.token }}
run: |
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
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

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"
35 changes: 33 additions & 2 deletions impact_gate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import argparse
import os
import sys

from .core.config import MeasureConfig
Expand All @@ -27,7 +28,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)
Expand Down Expand Up @@ -64,15 +65,38 @@ 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:
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.")
Expand All @@ -82,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)
Expand Down
94 changes: 94 additions & 0 deletions impact_gate/ghapi.py
Original file line number Diff line number Diff line change
@@ -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 = "<!-- impact-gate -->"
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"
46 changes: 41 additions & 5 deletions impact_gate/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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} "
Expand Down Expand Up @@ -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)
Loading