From e55e76b7961e98b7ba8e8fb58dbcdd3ad56f1d38 Mon Sep 17 00:00:00 2001 From: Bryan Beverly Date: Sun, 19 Apr 2026 21:32:58 -0700 Subject: [PATCH] Add org-wide PR template, label taxonomy, reusable workflows, and lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This populates trufflesecurity/.github with shared building blocks that consumer repos in the org will call: * `.github/PULL_REQUEST_TEMPLATE.md` — default PR template inherited by any repo without its own * `labels.yml` — single source of truth for the 11-label taxonomy (size/risk/review/status/complexity) * `.github/workflows/pr-labeler-reusable.yml` — applies size/risk/checkbox labels to PRs (size from additions+deletions, risk parsed from Bugbot's CURSOR_SUMMARY block, three-state checkbox labels from the template) * `.github/workflows/label-sync-reusable.yml` — additively syncs labels.yml into a caller repo via `gh label create --force` * `.github/workflows/stale-reusable.yml` — wraps actions/stale@v9 with the org's PR hygiene policy (14d stale, 16d close, exempts review/urgent and drafts, throttled to 30 ops/run) * `.github/scripts/{pr_labeler,label_sync}.py` — labeler/sync logic * `.github/scripts/test_pr_labeler.py` — 35 unit tests for labeler logic * `.github/workflows/test-scripts.yml` — runs unit tests on PRs and pushes * `.github/workflows/lint.yml` — ruff (Python) + actionlint (workflow YAML) * `README.md` — documents what lives here, the perms model, and how to add a label No reusable workflow declares its own `permissions:` block — they inherit from callers, so each consumer must grant the minimum needed (documented in the README). Made-with: Cursor --- .github/PULL_REQUEST_TEMPLATE.md | 22 ++ .github/scripts/label_sync.py | 75 ++++++ .github/scripts/pr_labeler.py | 269 ++++++++++++++++++++++ .github/scripts/test_pr_labeler.py | 229 ++++++++++++++++++ .github/workflows/label-sync-reusable.yml | 33 +++ .github/workflows/lint.yml | 35 +++ .github/workflows/pr-labeler-reusable.yml | 42 ++++ .github/workflows/stale-reusable.yml | 61 +++++ .github/workflows/test-scripts.yml | 23 ++ .gitignore | 4 + README.md | 68 +++++- labels.yml | 44 ++++ 12 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/scripts/label_sync.py create mode 100644 .github/scripts/pr_labeler.py create mode 100644 .github/scripts/test_pr_labeler.py create mode 100644 .github/workflows/label-sync-reusable.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/pr-labeler-reusable.yml create mode 100644 .github/workflows/stale-reusable.yml create mode 100644 .github/workflows/test-scripts.yml create mode 100644 .gitignore create mode 100644 labels.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..14f528d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## Review guidance +- [ ] **Urgent**: needs same-day review +- [ ] **High complexity**: non-obvious logic, needs careful review +- **Estimated review time**: +- **Key files to focus on**: +- **Areas you're unsure about**: + + + + + + +## Testing +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] Manual testing performed (describe below) +- [ ] No testing needed (explain why) + +## Deployment notes + diff --git a/.github/scripts/label_sync.py b/.github/scripts/label_sync.py new file mode 100644 index 0000000..3c6bfa0 --- /dev/null +++ b/.github/scripts/label_sync.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Sync labels from labels.yml to a single repo using `gh label create --force`. + +This is intentionally additive: labels in the repo that are not in labels.yml +are left alone. This avoids deleting legacy labels that teams may still rely on +during the rollout. + +Inputs (environment variables): + GITHUB_REPOSITORY e.g. "owner/repo" (always set on Actions) + LABELS_FILE path to labels.yml (defaults to ./labels.yml) +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import yaml + + +def gh(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(["gh", *args], capture_output=True, text=True, check=True) + + +def upsert_label(repo: str, label: dict) -> None: + name = label["name"] + color = label["color"] + description = label.get("description", "") + gh( + [ + "label", + "create", + name, + "--repo", + repo, + "--color", + color, + "--description", + description, + "--force", + ] + ) + + +def main() -> int: + repo = os.environ["GITHUB_REPOSITORY"] + labels_path = Path(os.environ.get("LABELS_FILE", "labels.yml")) + labels = yaml.safe_load(labels_path.read_text()) + if not isinstance(labels, list): + print( + f"Expected a YAML list in {labels_path}, got {type(labels).__name__}", + file=sys.stderr, + ) + return 1 + + print(f"Syncing {len(labels)} labels to {repo} from {labels_path}") + failures = 0 + for label in labels: + try: + upsert_label(repo, label) + print(f" ok: {label['name']}") + except subprocess.CalledProcessError as exc: + failures += 1 + print( + f" fail: {label.get('name', '')}: {exc.stderr.strip() or exc}", + file=sys.stderr, + ) + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/pr_labeler.py b/.github/scripts/pr_labeler.py new file mode 100644 index 0000000..a614fd7 --- /dev/null +++ b/.github/scripts/pr_labeler.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""PR labeler: compute size, risk, and checkbox labels for one or more PRs. + +Inputs come from environment variables set by the calling workflow: + GITHUB_REPOSITORY e.g. "owner/repo" (always set on Actions) + PR_NUMBER "" (event mode), "all" (backfill), or "" + DRY_RUN "true" or "false" + EVENT_PR_NUMBER PR number from `pull_request` event (if any), else "" + +The script processes each PR by: + 1. Fetching additions, deletions, body, and current labels from the GitHub API. + 2. Computing the size bucket from additions+deletions. + 3. Parsing the Bugbot CURSOR_SUMMARY block for a risk level. + 4. Parsing the PR template checkboxes for `urgent` and `high complexity`. + 5. Reconciling with current labels and applying adds/removes via `gh pr edit`. + +For backfill mode (PR_NUMBER == "all"), per-PR failures are logged but do not +abort the run, unless more than 10% of PRs fail. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field + +SIZE_LABELS = ["size/XS", "size/S", "size/M", "size/L", "size/XL"] +RISK_LABELS = ["risk/low", "risk/medium", "risk/high"] +URGENT_LABEL = "review/urgent" +COMPLEXITY_LABEL = "complexity/high" + +CURSOR_SUMMARY_MARKER = "" +RISK_REGEX = re.compile(r"\*\*(\w+)\s+Risk\*\*", re.IGNORECASE) +RISK_MAP = { + "low": "risk/low", + "medium": "risk/medium", + "high": "risk/high", +} +# Conservative fallback for unmapped Bugbot levels (e.g., "Critical", "Minimal"). +RISK_FALLBACK = "risk/high" + + +# Match a markdown checkbox followed (with whitespace and optional bold/markdown +# punctuation) by a target keyword. The `[xX ]` part captures the state. +def checkbox_regex(keyword: str) -> re.Pattern[str]: + # Examples that should match (state captured): + # - [x] **Urgent**: needs same-day review + # - [ ] **High complexity**: ... + # * [X] urgent + return re.compile( + rf"[-*]\s*\[\s*([xX ])\s*\]\s*[*_`]*\s*{re.escape(keyword)}", + re.IGNORECASE, + ) + + +URGENT_REGEX = checkbox_regex("urgent") +COMPLEXITY_REGEX = checkbox_regex("high complexity") + + +@dataclass +class LabelPlan: + """Planned label changes for a single PR.""" + + pr_number: int + add: list[str] = field(default_factory=list) + remove: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + def summary(self) -> str: + parts = [] + for label in self.add: + parts.append(f"+{label}") + for label in self.remove: + parts.append(f"-{label}") + parts.extend(self.notes) + return ( + f"PR #{self.pr_number} " + " ".join(parts) + if parts + else f"PR #{self.pr_number} (no changes)" + ) + + +def gh(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(["gh", *args], capture_output=True, text=True, check=check) + + +def fetch_pr(repo: str, pr_number: int) -> dict: + result = gh( + [ + "pr", + "view", + str(pr_number), + "--repo", + repo, + "--json", + "number,additions,deletions,body,labels,state", + ] + ) + return json.loads(result.stdout) + + +def list_open_prs(repo: str) -> list[int]: + result = gh( + [ + "pr", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + "1000", + "--json", + "number", + ] + ) + return [pr["number"] for pr in json.loads(result.stdout)] + + +def size_bucket(total: int) -> str | None: + if total <= 0: + return None + if total <= 10: + return "size/XS" + if total <= 50: + return "size/S" + if total <= 250: + return "size/M" + if total <= 999: + return "size/L" + return "size/XL" + + +def risk_from_body(body: str, plan: LabelPlan) -> str | None: + if CURSOR_SUMMARY_MARKER not in body: + return None + after = body.split(CURSOR_SUMMARY_MARKER, 1)[1] + match = RISK_REGEX.search(after) + if not match: + plan.notes.append( + "[warn: CURSOR_SUMMARY present but risk regex did not match -- check Bugbot format]" + ) + return None + level = match.group(1).lower() + label = RISK_MAP.get(level) + if label is None: + plan.notes.append( + f"[warn: unmapped Bugbot risk '{match.group(1)}' -> {RISK_FALLBACK}]" + ) + return RISK_FALLBACK + return label + + +def checkbox_state(body: str, regex: re.Pattern[str]) -> str | None: + """Return 'on', 'off', or None (not present).""" + match = regex.search(body) + if not match: + return None + return "on" if match.group(1).lower() == "x" else "off" + + +def reconcile( + pr: dict, + *, + plan: LabelPlan, +) -> None: + current_labels = {label["name"] for label in pr.get("labels", [])} + body = pr.get("body") or "" + additions = pr.get("additions", 0) or 0 + deletions = pr.get("deletions", 0) or 0 + + # Size: pick exactly one bucket, remove any other size labels. + desired_size = size_bucket(additions + deletions) + for label in SIZE_LABELS: + if label == desired_size: + if label not in current_labels: + plan.add.append(label) + elif label in current_labels: + plan.remove.append(label) + + # Risk: pick one (if any), remove other risk labels. + desired_risk = risk_from_body(body, plan) + for label in RISK_LABELS: + if label == desired_risk: + if label not in current_labels: + plan.add.append(label) + elif label in current_labels and desired_risk is not None: + # Only remove an existing risk label when we have a new one; don't + # strip a manually-set risk label just because Bugbot didn't comment. + plan.remove.append(label) + + # Checkboxes: three-state (on/off/absent). + for regex, label in [ + (URGENT_REGEX, URGENT_LABEL), + (COMPLEXITY_REGEX, COMPLEXITY_LABEL), + ]: + state = checkbox_state(body, regex) + if state == "on" and label not in current_labels: + plan.add.append(label) + elif state == "off" and label in current_labels: + plan.remove.append(label) + + +def apply(repo: str, plan: LabelPlan, dry_run: bool) -> None: + if dry_run or (not plan.add and not plan.remove): + return + args = ["pr", "edit", str(plan.pr_number), "--repo", repo] + for label in plan.add: + args.extend(["--add-label", label]) + for label in plan.remove: + args.extend(["--remove-label", label]) + gh(args) + + +def determine_targets(repo: str, pr_number_input: str, event_pr: str) -> list[int]: + if pr_number_input == "all": + return list_open_prs(repo) + if pr_number_input: + return [int(pr_number_input)] + if event_pr: + return [int(event_pr)] + return [] + + +def main() -> int: + repo = os.environ["GITHUB_REPOSITORY"] + pr_number_input = os.environ.get("PR_NUMBER", "").strip() + dry_run = os.environ.get("DRY_RUN", "false").lower() == "true" + event_pr = os.environ.get("EVENT_PR_NUMBER", "").strip() + + targets = determine_targets(repo, pr_number_input, event_pr) + if not targets: + print("No PR to process; exiting.") + return 0 + + print(f"Processing {len(targets)} PR(s) in {repo} (dry_run={dry_run})") + + failures = 0 + for pr_number in targets: + plan = LabelPlan(pr_number=pr_number) + try: + pr = fetch_pr(repo, pr_number) + if pr.get("state") != "OPEN": + print(f"PR #{pr_number} (skip: not open)") + continue + reconcile(pr, plan=plan) + apply(repo, plan, dry_run) + print(plan.summary()) + except subprocess.CalledProcessError as exc: + failures += 1 + print( + f"PR #{pr_number} (error: {exc.stderr.strip() or exc})", + file=sys.stderr, + ) + + if targets and failures / len(targets) > 0.10: + print( + f"Failure rate {failures}/{len(targets)} exceeds 10% threshold; failing run.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_pr_labeler.py b/.github/scripts/test_pr_labeler.py new file mode 100644 index 0000000..7615dbf --- /dev/null +++ b/.github/scripts/test_pr_labeler.py @@ -0,0 +1,229 @@ +"""Tests for pr_labeler module. + +Run with: python -m pytest .github/scripts/test_pr_labeler.py -v +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +import pr_labeler # noqa: E402 + + +# ---- size_bucket ----------------------------------------------------------- + + +class TestSizeBucket: + def test_zero_or_negative_returns_none(self): + assert pr_labeler.size_bucket(0) is None + assert pr_labeler.size_bucket(-1) is None + + def test_xs_boundary(self): + assert pr_labeler.size_bucket(1) == "size/XS" + assert pr_labeler.size_bucket(10) == "size/XS" + + def test_s_boundary(self): + assert pr_labeler.size_bucket(11) == "size/S" + assert pr_labeler.size_bucket(50) == "size/S" + + def test_m_boundary(self): + assert pr_labeler.size_bucket(51) == "size/M" + assert pr_labeler.size_bucket(250) == "size/M" + + def test_l_boundary(self): + assert pr_labeler.size_bucket(251) == "size/L" + assert pr_labeler.size_bucket(999) == "size/L" + + def test_xl_starts_at_1000(self): + assert pr_labeler.size_bucket(1000) == "size/XL" + assert pr_labeler.size_bucket(50_000) == "size/XL" + + +# ---- risk_from_body -------------------------------------------------------- + + +def _plan() -> pr_labeler.LabelPlan: + return pr_labeler.LabelPlan(pr_number=1) + + +class TestRiskFromBody: + def test_no_marker_returns_none(self): + plan = _plan() + assert pr_labeler.risk_from_body("nothing here", plan) is None + assert plan.notes == [] + + def test_low_risk(self): + body = "\n**Low Risk** assessment OK" + assert pr_labeler.risk_from_body(body, _plan()) == "risk/low" + + def test_medium_risk(self): + body = "\nthings\n**Medium Risk** detected" + assert pr_labeler.risk_from_body(body, _plan()) == "risk/medium" + + def test_high_risk(self): + body = "\n**High Risk** is here" + assert pr_labeler.risk_from_body(body, _plan()) == "risk/high" + + def test_case_insensitive(self): + body = "\n**HIGH risk** seen" + assert pr_labeler.risk_from_body(body, _plan()) == "risk/high" + + def test_unmapped_level_falls_back_to_high_with_warning(self): + plan = _plan() + body = "\n**Critical Risk** detected" + assert pr_labeler.risk_from_body(body, plan) == pr_labeler.RISK_FALLBACK + assert any("unmapped" in note for note in plan.notes) + + def test_marker_present_no_match_warns(self): + plan = _plan() + body = "\nNo risk verbiage at all" + assert pr_labeler.risk_from_body(body, plan) is None + assert any("regex did not match" in note for note in plan.notes) + + def test_text_before_marker_ignored(self): + body = "**Low Risk** appears before\n\n**High Risk**" + assert pr_labeler.risk_from_body(body, _plan()) == "risk/high" + + +# ---- checkbox_state -------------------------------------------------------- + + +class TestCheckboxState: + def test_urgent_checked(self): + body = "- [x] **Urgent**: needs same-day review" + assert pr_labeler.checkbox_state(body, pr_labeler.URGENT_REGEX) == "on" + + def test_urgent_unchecked(self): + body = "- [ ] **Urgent**: needs same-day review" + assert pr_labeler.checkbox_state(body, pr_labeler.URGENT_REGEX) == "off" + + def test_urgent_capital_x(self): + body = "- [X] **Urgent**" + assert pr_labeler.checkbox_state(body, pr_labeler.URGENT_REGEX) == "on" + + def test_urgent_absent(self): + body = "no template here" + assert pr_labeler.checkbox_state(body, pr_labeler.URGENT_REGEX) is None + + def test_urgent_without_bold(self): + body = "- [x] urgent: needs same-day review" + assert pr_labeler.checkbox_state(body, pr_labeler.URGENT_REGEX) == "on" + + def test_complexity_checked(self): + body = "- [x] **High complexity**: non-obvious logic" + assert pr_labeler.checkbox_state(body, pr_labeler.COMPLEXITY_REGEX) == "on" + + def test_complexity_unchecked(self): + body = "- [ ] **High complexity**: non-obvious logic" + assert pr_labeler.checkbox_state(body, pr_labeler.COMPLEXITY_REGEX) == "off" + + def test_extra_whitespace(self): + body = "- [ x ] **Urgent**: needs same-day review" + assert pr_labeler.checkbox_state(body, pr_labeler.URGENT_REGEX) == "on" + + def test_asterisk_bullet(self): + body = "* [x] urgent" + assert pr_labeler.checkbox_state(body, pr_labeler.URGENT_REGEX) == "on" + + +# ---- reconcile ------------------------------------------------------------- + + +def _pr(*, additions=0, deletions=0, body="", labels=()): + return { + "additions": additions, + "deletions": deletions, + "body": body, + "labels": [{"name": name} for name in labels], + "state": "OPEN", + } + + +class TestReconcile: + def test_adds_size_label_for_new_pr(self): + plan = _plan() + pr_labeler.reconcile(_pr(additions=5, deletions=2), plan=plan) + assert "size/XS" in plan.add + assert plan.remove == [] + + def test_swaps_size_label_when_changed(self): + plan = _plan() + pr_labeler.reconcile( + _pr(additions=300, deletions=0, labels=("size/S",)), + plan=plan, + ) + assert "size/L" in plan.add + assert "size/S" in plan.remove + + def test_keeps_correct_size_label(self): + plan = _plan() + pr_labeler.reconcile( + _pr(additions=300, deletions=0, labels=("size/L",)), + plan=plan, + ) + assert plan.add == [] + assert plan.remove == [] + + def test_does_not_remove_manual_risk_when_no_bugbot(self): + plan = _plan() + pr_labeler.reconcile( + _pr(additions=5, body="no marker", labels=("risk/high",)), + plan=plan, + ) + assert "risk/high" not in plan.remove + + def test_swaps_risk_label_when_bugbot_changes(self): + plan = _plan() + body = "\n**Low Risk**" + pr_labeler.reconcile( + _pr(additions=5, body=body, labels=("risk/high",)), + plan=plan, + ) + assert "risk/low" in plan.add + assert "risk/high" in plan.remove + + def test_urgent_checkbox_on_adds_label(self): + plan = _plan() + body = "- [x] **Urgent**: needs same-day review" + pr_labeler.reconcile(_pr(additions=5, body=body), plan=plan) + assert pr_labeler.URGENT_LABEL in plan.add + + def test_urgent_checkbox_off_removes_label(self): + plan = _plan() + body = "- [ ] **Urgent**: needs same-day review" + pr_labeler.reconcile( + _pr(additions=5, body=body, labels=(pr_labeler.URGENT_LABEL,)), + plan=plan, + ) + assert pr_labeler.URGENT_LABEL in plan.remove + + def test_urgent_checkbox_absent_leaves_manual_label(self): + plan = _plan() + pr_labeler.reconcile( + _pr(additions=5, body="no template", labels=(pr_labeler.URGENT_LABEL,)), + plan=plan, + ) + assert pr_labeler.URGENT_LABEL not in plan.remove + + +# ---- determine_targets ------------------------------------------------------ + + +class TestDetermineTargets: + def test_explicit_number(self, monkeypatch): + targets = pr_labeler.determine_targets("repo", "42", "") + assert targets == [42] + + def test_event_fallback(self, monkeypatch): + targets = pr_labeler.determine_targets("repo", "", "99") + assert targets == [99] + + def test_event_overridden_by_explicit(self, monkeypatch): + targets = pr_labeler.determine_targets("repo", "10", "99") + assert targets == [10] + + def test_no_input_returns_empty(self): + assert pr_labeler.determine_targets("repo", "", "") == [] diff --git a/.github/workflows/label-sync-reusable.yml b/.github/workflows/label-sync-reusable.yml new file mode 100644 index 0000000..1ac0337 --- /dev/null +++ b/.github/workflows/label-sync-reusable.yml @@ -0,0 +1,33 @@ +name: Label Sync (Reusable) + +# Reusable workflow that syncs labels from this repo's labels.yml into the +# caller repo. Idempotent and additive -- legacy labels not in labels.yml are +# left alone. Permissions are inherited from the caller (do not declare here). +# Callers must grant: issues: write. + +on: + workflow_call: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout label definitions + uses: actions/checkout@v4 + with: + repository: trufflesecurity/.github + ref: main + path: org-github + sparse-checkout: | + labels.yml + .github/scripts/label_sync.py + + - name: Install PyYAML + run: pip install --quiet pyyaml + + - name: Sync labels + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + LABELS_FILE: org-github/labels.yml + run: python3 org-github/.github/scripts/label_sync.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..9bb4ef1 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,35 @@ +name: Lint + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + pull-requests: read + +jobs: + python: + name: Python (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + with: + src: '.github/scripts' + args: 'check' + - uses: astral-sh/ruff-action@v3 + with: + src: '.github/scripts' + args: 'format --check' + + workflows: + name: Workflows (actionlint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run actionlint + run: | + bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) + ./actionlint -color diff --git a/.github/workflows/pr-labeler-reusable.yml b/.github/workflows/pr-labeler-reusable.yml new file mode 100644 index 0000000..98e32ad --- /dev/null +++ b/.github/workflows/pr-labeler-reusable.yml @@ -0,0 +1,42 @@ +name: PR Labeler (Reusable) + +# Reusable workflow for size/risk/checkbox labeling. Permissions are inherited +# from the caller (intentionally not declared here -- declaring them would +# override the caller's grant and produce "Resource not accessible" failures). +# Callers must grant: pull-requests: write. + +on: + workflow_call: + inputs: + pr_number: + description: 'PR number, "all" for full backfill, or empty for event-driven' + type: string + required: false + default: '' + dry_run: + description: 'Log labels without applying' + type: boolean + required: false + default: false + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Checkout reusable workflow scripts + uses: actions/checkout@v4 + with: + repository: trufflesecurity/.github + ref: main + path: org-github + sparse-checkout: | + .github/scripts + + - name: Apply labels + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr_number }} + DRY_RUN: ${{ inputs.dry_run }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + run: python3 org-github/.github/scripts/pr_labeler.py diff --git a/.github/workflows/stale-reusable.yml b/.github/workflows/stale-reusable.yml new file mode 100644 index 0000000..fcedc76 --- /dev/null +++ b/.github/workflows/stale-reusable.yml @@ -0,0 +1,61 @@ +name: Stale PRs (Reusable) + +# Reusable wrapper around actions/stale. Permissions are inherited from the +# caller (do not declare here). Callers must grant: +# pull-requests: write +# issues: write (labels are an issue resource in GitHub's perm model) + +on: + workflow_call: + inputs: + days-before-stale: + type: number + required: false + default: 14 + days-before-close: + type: number + required: false + default: 16 + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + # PR thresholds + days-before-pr-stale: ${{ inputs.days-before-stale }} + days-before-pr-close: ${{ inputs.days-before-close }} + + # We do not stale issues at all -- this workflow is PR-only. + # Without -1 the action would also start staling issues across all + # consumer repos. + days-before-issue-stale: -1 + days-before-issue-close: -1 + + # Our taxonomy uses status/stale rather than the action's default Stale. + stale-pr-label: status/stale + + # Author-declared urgency is exempt from staling. + exempt-pr-labels: review/urgent + + # Drafts are work-in-progress and never stale. + exempt-draft-pr: true + + # Auto-remove status/stale when activity resumes (default true; explicit). + remove-stale-when-updated: true + + # Never delete branches when closing. + delete-branch: false + + # Throttle to spread waves over multiple days (default 30; explicit). + operations-per-run: 30 + + stale-pr-message: > + This PR has been inactive for ${{ inputs.days-before-stale }} days. + To keep it active, push a commit, leave a comment, check the + "Urgent" box in the PR description, or convert to draft. + Otherwise it will auto-close in ${{ inputs.days-before-close }} days. + + close-pr-message: > + Closing due to inactivity. Reopen if work resumes. diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml new file mode 100644 index 0000000..329b977 --- /dev/null +++ b/.github/workflows/test-scripts.yml @@ -0,0 +1,23 @@ +name: Test scripts + +on: + pull_request: + paths: + - '.github/scripts/**' + - '.github/workflows/test-scripts.yml' + push: + branches: [main] + paths: + - '.github/scripts/**' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install pytest pyyaml + - run: python -m pytest .github/scripts -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17cd2fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/README.md b/README.md index a46ae92..7391df3 100644 --- a/README.md +++ b/README.md @@ -1 +1,67 @@ -# .github \ No newline at end of file +# trufflesecurity/.github + +Org-wide defaults and shared automation for TruffleHog repositories. + +## What lives here + +- **`.github/PULL_REQUEST_TEMPLATE.md`** — default PR template inherited by any + repo in the org that does not define its own. Repos can ship their own + template at the same path to extend or replace it. +- **`labels.yml`** — single source of truth for the standard label taxonomy + (size/risk/review/status/complexity). Synced into every consumer repo by the + reusable label-sync workflow on a daily cron. +- **`.github/workflows/pr-labeler-reusable.yml`** — reusable workflow that + applies size/risk/checkbox labels to PRs. Called from each consumer repo's + `.github/workflows/pr-labeler.yml`. +- **`.github/workflows/stale-reusable.yml`** — reusable workflow wrapping + `actions/stale` with the org's PR hygiene policy (14-day stale, 16-day + close, exempt `review/urgent` and drafts). Called from each consumer's + `.github/workflows/stale.yml`. +- **`.github/workflows/label-sync-reusable.yml`** — reusable workflow that + reads `labels.yml` and applies it to its caller repo via + `gh label create --force`. Idempotent and additive. +- **`.github/scripts/`** — Python scripts powering the reusable workflows. + Unit-tested via `.github/workflows/test-scripts.yml`. + +## How to add or change a label + +Edit `labels.yml`. The next scheduled run of each consumer's `Sync Labels` +workflow propagates the change (midnight UTC daily). To propagate immediately, +trigger the workflow on each consumer repo via: + +```bash +gh workflow run sync-labels.yml --repo trufflesecurity/ +``` + +The current list of consumer repos is maintained in our internal rollout doc +(see the PR Labeling & Hygiene plan). + +Also update the org-level **Settings > Repository defaults > Repository labels** +list so brand-new repos get the same set on day one. + +## Permissions model for consumer workflows + +Permissions are declared in caller workflows and inherited by these reusable +workflows via `GITHUB_TOKEN`. Do **not** add `permissions:` blocks to the +reusable workflows — they would override the caller's grant and surface as +"Resource not accessible by integration" failures. + +| Reusable workflow | Permission required | +| --- | --- | +| `pr-labeler-reusable.yml` | `pull-requests: write` | +| `label-sync-reusable.yml` | `issues: write` (labels are an issue resource) | +| `stale-reusable.yml` | `pull-requests: write` and `issues: write` | + +## Versioning + +Caller workflows reference these reusables at `@main`. Pushes to this repo's +`main` branch immediately affect all consumer repos. Branch protection on +`main` requires PR review before merging workflow changes. + +## Local development + +```bash +python3 -m venv .venv +.venv/bin/pip install pytest pyyaml +.venv/bin/python -m pytest .github/scripts -v +``` diff --git a/labels.yml b/labels.yml new file mode 100644 index 0000000..44ceb22 --- /dev/null +++ b/labels.yml @@ -0,0 +1,44 @@ +# Single source of truth for labels across all private TruffleHog repos. +# Synced via .github/workflows/label-sync-reusable.yml in this repo. +# To add or change a label: edit this file. The next scheduled sync (daily, +# midnight UTC) propagates the change to all consumer repos. To propagate +# immediately, manually trigger the `Sync Labels` workflow in each repo via +# `gh workflow run sync-labels.yml --repo trufflesecurity/`. +# +# IMPORTANT: also update the org-level Repository defaults in +# Settings > Repository defaults > Repository labels for the trufflesecurity +# org so that newly created repos get the same set on day one. + +- name: size/XS + color: c2e0ff + description: 1-10 lines changed +- name: size/S + color: 85c1ff + description: 11-50 lines changed +- name: size/M + color: 4a90d9 + description: 51-250 lines changed +- name: size/L + color: 2563a0 + description: 251-999 lines changed +- name: size/XL + color: 0e3d6b + description: 1000+ lines changed +- name: risk/low + color: 2da44e + description: Bugbot risk assessment -- low +- name: risk/medium + color: d29922 + description: Bugbot risk assessment -- medium +- name: risk/high + color: cf222e + description: Bugbot risk assessment -- high +- name: review/urgent + color: e01e5a + description: Needs same-day review (author-declared) +- name: status/stale + color: 8b949e + description: No activity for 14+ days (auto-applied) +- name: complexity/high + color: 8957e5 + description: Non-obvious logic, careful review (author-declared)