Coverage for .github / scripts / apply_live_validation_label.py: 100.00%
48 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1#!/usr/bin/env python3
2"""Label a pull request that touches a deployed path ``live-validation-required``.
4docs/LIVE_RELEASE_VALIDATION.md decides when a change needs a live release
5validation run, and the pull-request template asks the author to record that
6decision. Nothing pointed a reviewer at the decision before merge, so a weekly
7Dependabot batch that bumped the AWS SDK clients bundled into the inference
8streaming Lambda merged on green like a CI-only update.
10This adds the label when any changed file sits on a path that ships in, or
11drives, a deployment (``DEPLOYED_PREFIXES`` and ``DEPLOYED_FILES``), and prints
12which files matched. It is a prompt, not the decision: the docs' rule is
13risk-based, so a docstring-only edit under ``gco/`` is labelled too, and the
14author still records "Not required" with a reason in the template.
16Scope is deliberately narrow. The script only ever adds the one label it owns
17and never removes it, so a maintainer who applies it by hand, or Dependabot's
18``runtime`` group, which applies it from ``.github/dependabot.yml``, is never
19overridden. Markdown files are ignored everywhere: a README next to a Lambda
20does not ship.
22Usage::
24 apply_live_validation_label.py --pr 490 # add the label if needed
25 apply_live_validation_label.py --pr 490 --dry-run # print the plan only
27``GH_TOKEN`` and ``GH_REPO`` come from the environment, as the ``gh`` CLI
28expects. The changed-file list is read through ``gh api`` with only the PR
29number, so no pull-request text reaches a shell.
30"""
32from __future__ import annotations
34import argparse
35import json
36import subprocess
37import sys
39#: The label this script owns. Dependabot's ``runtime`` group applies the same
40#: label from ``.github/dependabot.yml``; a test keeps the two in agreement.
41LABEL = "live-validation-required"
43#: Directories whose contents ship in a deployment: the CDK app and services
44#: (``gco/``), every Lambda (``lambda/``) and the service images
45#: (``dockerfiles/``).
46DEPLOYED_PREFIXES: tuple[str, ...] = ("gco/", "lambda/", "dockerfiles/")
48#: Single files that change what a deployment synthesizes or runs: the CDK
49#: entry point and context, the Python runtime graph, the root npm graph (it
50#: carries the AWS CDK CLI), and the deploy/destroy orchestration.
51DEPLOYED_FILES: frozenset[str] = frozenset(
52 {
53 "app.py",
54 "cdk.json",
55 "pyproject.toml",
56 "requirements-lock.txt",
57 "package.json",
58 "package-lock.json",
59 "cli/stacks.py",
60 }
61)
64def deployed_paths(paths: list[str]) -> list[str]:
65 """Return the changed paths that sit on a deployed path, sorted."""
66 matched = {
67 path
68 for path in paths
69 if not path.lower().endswith(".md")
70 and (path in DEPLOYED_FILES or path.startswith(DEPLOYED_PREFIXES))
71 }
72 return sorted(matched)
75def _gh(args: list[str]) -> str:
76 proc = subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
77 if proc.returncode != 0:
78 raise RuntimeError(f"gh {' '.join(args)} failed: {proc.stderr.strip()[:400]}")
79 return proc.stdout
82def changed_files(number: int) -> list[str]:
83 """Return every file the pull request changes.
85 ``gh pr view --json files`` and ``gh pr diff`` both stop short on large pull
86 requests, so this pages the REST files endpoint instead. ``--jq`` emits one
87 JSON string per line, which keeps file names with spaces intact.
88 """
89 out = _gh(
90 [
91 "api",
92 "--paginate",
93 f"repos/{{owner}}/{{repo}}/pulls/{number}/files?per_page=100",
94 "--jq",
95 ".[].filename | tojson",
96 ]
97 )
98 return [str(json.loads(line)) for line in out.splitlines() if line.strip()]
101def current_labels(number: int) -> list[str]:
102 data = json.loads(_gh(["pr", "view", str(number), "--json", "labels"]) or "{}")
103 return [str(item["name"]) for item in data.get("labels") or []]
106def add_label(number: int) -> None:
107 _gh(["pr", "edit", str(number), "--add-label", LABEL])
110def main(argv: list[str] | None = None) -> int:
111 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
112 parser.add_argument("--pr", type=int, required=True, help="Pull request number")
113 parser.add_argument(
114 "--dry-run",
115 action="store_true",
116 help="Report the plan without changing any labels.",
117 )
118 args = parser.parse_args(argv)
120 matched = deployed_paths(changed_files(args.pr))
121 if not matched:
122 print(f"PR #{args.pr}: no deployed path changed; leaving labels unchanged.")
123 return 0
125 print(f"PR #{args.pr}: {len(matched)} changed file(s) on a deployed path:")
126 for path in matched[:20]:
127 print(f" {path}")
128 if len(matched) > 20:
129 print(f" ... and {len(matched) - 20} more")
130 print(
131 "Record the live release validation decision in the PR template "
132 "(docs/LIVE_RELEASE_VALIDATION.md)."
133 )
134 if LABEL in current_labels(args.pr):
135 print(f" '{LABEL}' is already applied; nothing to do")
136 return 0
137 if args.dry_run:
138 print(f" would add: {LABEL} (dry run: no changes made)")
139 return 0
140 add_label(args.pr)
141 print(f" added: {LABEL}")
142 return 0
145if __name__ == "__main__":
146 sys.exit(main())