Skip to content

Commit acbc837

Browse files
authored
feat(fls-audit): automate audit issue triage (#382)
* feat(fls-audit): add delta-colored diffs Provide cached ANSI diff output for audit reviews in CI and locally. * perf(fls-audit): skip redundant fetch Reduce audit runtime by avoiding fetch when cache exists. * perf(fls-audit): parse only changed spec files Limit docutils parsing to changed .rst files while expanding ordering directives for accuracy and document the behavior. * feat(fls-audit): automate audit issue triage Keep audit findings actionable and streamline spec.lock updates via the bot.
1 parent 77f0934 commit acbc837

File tree

10 files changed

+1193
-44
lines changed

10 files changed

+1193
-44
lines changed

.github/reviewer-bot-tests/test_reviewer_bot.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,22 @@ def test_handle_comment_event_label_command(stub_api, captured_comments):
333333
assert "Added label" in captured_comments[0]["body"]
334334

335335

336+
def test_handle_comment_event_accept_no_fls_changes(stub_api, captured_comments, monkeypatch):
337+
state = make_state()
338+
os.environ["COMMENT_BODY"] = "@guidelines-bot /accept-no-fls-changes"
339+
os.environ["COMMENT_AUTHOR"] = "alice"
340+
os.environ["ISSUE_NUMBER"] = "42"
341+
monkeypatch.setattr(
342+
reviewer_bot,
343+
"handle_accept_no_fls_changes_command",
344+
lambda *args, **kwargs: ("ok", True),
345+
)
346+
handled = reviewer_bot.handle_comment_event(state)
347+
assert handled is False
348+
assert len(captured_comments) == 1
349+
assert captured_comments[0]["body"] == "ok"
350+
351+
336352
def test_handle_comment_event_queue_command(stub_api, captured_comments):
337353
state = make_state()
338354
os.environ["COMMENT_BODY"] = "@guidelines-bot /queue"
@@ -462,6 +478,19 @@ def test_handle_issue_or_pr_opened_assigns_reviewer(stub_api, captured_comments,
462478
assert "assigned to review" in captured_comments[0]["body"]
463479

464480

481+
def test_handle_issue_or_pr_opened_assigns_reviewer_for_fls_audit(stub_api, captured_comments, monkeypatch):
482+
state = make_state()
483+
os.environ["ISSUE_NUMBER"] = "42"
484+
os.environ["ISSUE_AUTHOR"] = "dana"
485+
os.environ["ISSUE_LABELS"] = "[\"fls-audit\"]"
486+
monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda *args, **kwargs: [])
487+
monkeypatch.setattr(reviewer_bot, "get_next_reviewer", lambda *args, **kwargs: "alice")
488+
handled = reviewer_bot.handle_issue_or_pr_opened(state)
489+
assert handled is True
490+
assert len(captured_comments) == 1
491+
assert "assigned to review" in captured_comments[0]["body"]
492+
493+
465494
def test_handle_issue_or_pr_opened_missing_label(stub_api, captured_comments, monkeypatch):
466495
state = make_state()
467496
os.environ["ISSUE_NUMBER"] = "42"
@@ -486,6 +515,19 @@ def test_handle_labeled_event_assigns_reviewer(stub_api, captured_comments, monk
486515
assert "assigned to review" in captured_comments[0]["body"]
487516

488517

518+
def test_handle_labeled_event_assigns_reviewer_for_fls_audit(stub_api, captured_comments, monkeypatch):
519+
state = make_state()
520+
os.environ["LABEL_NAME"] = "fls-audit"
521+
os.environ["ISSUE_NUMBER"] = "42"
522+
os.environ["ISSUE_AUTHOR"] = "dana"
523+
monkeypatch.setattr(reviewer_bot, "get_issue_assignees", lambda *args, **kwargs: [])
524+
monkeypatch.setattr(reviewer_bot, "get_next_reviewer", lambda *args, **kwargs: "alice")
525+
handled = reviewer_bot.handle_labeled_event(state)
526+
assert handled is True
527+
assert len(captured_comments) == 1
528+
assert "assigned to review" in captured_comments[0]["body"]
529+
530+
489531
def test_handle_labeled_event_wrong_label(stub_api, captured_comments):
490532
state = make_state()
491533
os.environ["LABEL_NAME"] = "not-it"

.github/workflows/fls-audit.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: FLS Audit
2+
3+
on:
4+
schedule:
5+
- cron: '0 4 * * *'
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: fls-audit
10+
cancel-in-progress: false
11+
12+
permissions:
13+
contents: read
14+
issues: write
15+
16+
jobs:
17+
fls-audit:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v4
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v6
25+
26+
- name: Install dependencies
27+
run: uv sync --dev
28+
29+
- name: Run FLS audit
30+
run: uv run python scripts/fls_audit.py --no-delta
31+
32+
- name: Update audit issue
33+
env:
34+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35+
REPO_OWNER: ${{ github.repository_owner }}
36+
REPO_NAME: ${{ github.event.repository.name }}
37+
run: |
38+
uv run python scripts/fls_audit_issue.py \
39+
--report-json build/fls_audit/report.json \
40+
--report-md build/fls_audit/report.md \
41+
--label fls-audit \
42+
--title-prefix "FLS audit:"

.github/workflows/reviewer-bot.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ concurrency:
4040
permissions:
4141
issues: write
4242
pull-requests: write
43+
contents: write
4344

4445
env:
4546
# Issue number where bot state is stored (create this issue first!)

docs/fls-audit.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,36 @@ uv run python scripts/fls_audit.py
2020

2121
- `build/fls_audit/report.json`
2222
- `build/fls_audit/report.md`
23+
- `build/fls_audit/report.ansi.md`
24+
25+
## Colored diffs (delta)
26+
27+
The audit tool can render ANSI-colored diffs using `delta`.
28+
When needed, it downloads a pinned delta release into `./.cache/fls-audit/tools/delta/`.
29+
If `delta` is unavailable, the ANSI report falls back to plain unified diffs.
30+
31+
```shell
32+
uv run python scripts/fls_audit.py --print-diffs
33+
```
34+
35+
Overrides and opt-out:
36+
37+
- `--delta-path path/to/delta`
38+
- `--no-delta`
39+
40+
View the ANSI report in a terminal:
41+
42+
```shell
43+
less -R build/fls_audit/report.ansi.md
44+
bat --style=plain --paging=always build/fls_audit/report.ansi.md
45+
```
46+
47+
## Performance note
48+
49+
The audit parses only changed `.rst` files by default. If any ordering files
50+
(`.. toctree::` or `.. appendices::`, including `:glob:` patterns) change, the
51+
audit also parses the referenced files to keep header and reorder detection
52+
accurate.
2353

2454
## Baseline and current selection
2555

@@ -77,7 +107,7 @@ uv run python scripts/fls_audit.py --include-legacy-report
77107

78108
## Cache
79109

80-
The FLS repo is cached under `./.cache/fls-audit/` and is safe to delete.
110+
The FLS repo and delta binaries are cached under `./.cache/fls-audit/` and are safe to delete.
81111

82112
## Rationalization checklist
83113

scripts/common/delta_diff.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# SPDX-License-Identifier: MIT OR Apache-2.0
2+
# SPDX-FileCopyrightText: The Coding Guidelines Subcommittee Contributors
3+
4+
from __future__ import annotations
5+
6+
import hashlib
7+
import os
8+
import platform
9+
import shutil
10+
import subprocess
11+
import sys
12+
import tarfile
13+
import tempfile
14+
import zipfile
15+
from pathlib import Path
16+
17+
import requests
18+
19+
DELTA_VERSION = "0.18.2"
20+
DELTA_RELEASE_BASE = (
21+
f"https://github.com/dandavison/delta/releases/download/{DELTA_VERSION}"
22+
)
23+
24+
DELTA_ASSETS: dict[str, dict[str, str]] = {
25+
"x86_64-unknown-linux-gnu": {
26+
"filename": f"delta-{DELTA_VERSION}-x86_64-unknown-linux-gnu.tar.gz",
27+
"sha256": "99607c43238e11a77fe90a914d8c2d64961aff84b60b8186c1b5691b39955b0f",
28+
"binary": "delta",
29+
},
30+
"aarch64-unknown-linux-gnu": {
31+
"filename": f"delta-{DELTA_VERSION}-aarch64-unknown-linux-gnu.tar.gz",
32+
"sha256": "adf7674086daa4582f598f74ce9caa6b70c1ba8f4a57d2911499b37826b014f9",
33+
"binary": "delta",
34+
},
35+
"aarch64-apple-darwin": {
36+
"filename": f"delta-{DELTA_VERSION}-aarch64-apple-darwin.tar.gz",
37+
"sha256": "6ba38dce9f91ee1b9a24aa4aede1db7195258fe176c3f8276ae2d4457d8170a0",
38+
"binary": "delta",
39+
},
40+
"x86_64-pc-windows-msvc": {
41+
"filename": f"delta-{DELTA_VERSION}-x86_64-pc-windows-msvc.zip",
42+
"sha256": "6ea59864091b4cfca89d9ee38388ff1a3ccdc8244b6e1cdd5201259de89b0b06",
43+
"binary": "delta.exe",
44+
},
45+
}
46+
47+
DELTA_ARGS = [
48+
"--color-only",
49+
"--paging=never",
50+
"--side-by-side",
51+
"--line-numbers",
52+
"--file-style",
53+
"bold yellow ul",
54+
"--file-decoration-style",
55+
"none",
56+
"--hunk-header-decoration-style",
57+
"none",
58+
"--max-line-length",
59+
"0",
60+
"--wrap-max-lines",
61+
"0",
62+
"--whitespace-error-style",
63+
"red reverse",
64+
]
65+
66+
67+
def detect_target() -> str | None:
68+
system = sys.platform
69+
machine = platform.machine().lower()
70+
if system.startswith("linux"):
71+
if machine in ("x86_64", "amd64"):
72+
return "x86_64-unknown-linux-gnu"
73+
if machine in ("aarch64", "arm64"):
74+
return "aarch64-unknown-linux-gnu"
75+
if system == "darwin":
76+
if machine in ("aarch64", "arm64"):
77+
return "aarch64-apple-darwin"
78+
if system == "win32":
79+
if machine in ("x86_64", "amd64"):
80+
return "x86_64-pc-windows-msvc"
81+
return None
82+
83+
84+
def resolve_delta_binary(
85+
cache_dir: Path,
86+
session: requests.Session,
87+
delta_path: Path | None,
88+
disable_delta: bool,
89+
) -> tuple[Path | None, str | None]:
90+
if disable_delta:
91+
return None, None
92+
93+
if delta_path:
94+
resolved = delta_path
95+
if not resolved.is_absolute():
96+
resolved = Path.cwd() / resolved
97+
if not resolved.exists():
98+
raise RuntimeError(f"delta binary not found at {resolved}")
99+
if not resolved.is_file():
100+
raise RuntimeError(f"delta path is not a file: {resolved}")
101+
return resolved, None
102+
103+
warning = None
104+
target = detect_target()
105+
if target:
106+
try:
107+
return install_delta(cache_dir, session, target), None
108+
except RuntimeError as exc:
109+
warning = str(exc)
110+
else:
111+
warning = f"delta not available for platform {sys.platform} {platform.machine()}"
112+
113+
system_delta = shutil.which("delta")
114+
if system_delta:
115+
return Path(system_delta), warning
116+
117+
return None, warning
118+
119+
120+
def install_delta(cache_dir: Path, session: requests.Session, target: str) -> Path:
121+
info = DELTA_ASSETS.get(target)
122+
if not info:
123+
raise RuntimeError(f"delta target {target} is not supported")
124+
125+
install_dir = cache_dir / "tools" / "delta" / DELTA_VERSION / target
126+
binary_path = install_dir / info["binary"]
127+
if binary_path.exists():
128+
return binary_path
129+
130+
install_dir.mkdir(parents=True, exist_ok=True)
131+
archive_path = install_dir / info["filename"]
132+
if archive_path.exists() and not verify_sha256(archive_path, info["sha256"]):
133+
archive_path.unlink()
134+
135+
if not archive_path.exists():
136+
url = f"{DELTA_RELEASE_BASE}/{info['filename']}"
137+
download_asset(session, url, archive_path)
138+
139+
if not verify_sha256(archive_path, info["sha256"]):
140+
raise RuntimeError(f"delta checksum mismatch for {archive_path.name}")
141+
142+
with tempfile.TemporaryDirectory(dir=install_dir) as temp_dir:
143+
temp_path = Path(temp_dir)
144+
extract_archive(archive_path, temp_path)
145+
extracted = find_binary(temp_path, info["binary"])
146+
shutil.copy2(extracted, binary_path)
147+
148+
if os.name != "nt":
149+
binary_path.chmod(binary_path.stat().st_mode | 0o111)
150+
151+
return binary_path
152+
153+
154+
def download_asset(session: requests.Session, url: str, dest: Path) -> None:
155+
dest.parent.mkdir(parents=True, exist_ok=True)
156+
with session.get(url, stream=True, timeout=60) as response:
157+
response.raise_for_status()
158+
with tempfile.NamedTemporaryFile(dir=dest.parent, delete=False) as temp_file:
159+
for chunk in response.iter_content(chunk_size=1024 * 1024):
160+
if chunk:
161+
temp_file.write(chunk)
162+
temp_path = Path(temp_file.name)
163+
temp_path.replace(dest)
164+
165+
166+
def verify_sha256(path: Path, expected: str) -> bool:
167+
digest = hashlib.sha256()
168+
with path.open("rb") as handle:
169+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
170+
digest.update(chunk)
171+
return digest.hexdigest() == expected
172+
173+
174+
def extract_archive(archive_path: Path, dest_dir: Path) -> None:
175+
if archive_path.name.endswith(".tar.gz"):
176+
with tarfile.open(archive_path, "r:gz") as archive:
177+
safe_extract_tar(archive, dest_dir)
178+
return
179+
if archive_path.suffix == ".zip":
180+
with zipfile.ZipFile(archive_path, "r") as archive:
181+
safe_extract_zip(archive, dest_dir)
182+
return
183+
raise RuntimeError(f"Unsupported delta archive {archive_path.name}")
184+
185+
186+
def safe_extract_tar(archive: tarfile.TarFile, dest_dir: Path) -> None:
187+
for member in archive.getmembers():
188+
member_path = dest_dir / member.name
189+
if not is_within_directory(dest_dir, member_path):
190+
raise RuntimeError("Blocked tar extraction outside destination")
191+
archive.extractall(dest_dir)
192+
193+
194+
def safe_extract_zip(archive: zipfile.ZipFile, dest_dir: Path) -> None:
195+
for member in archive.infolist():
196+
member_path = dest_dir / member.filename
197+
if not is_within_directory(dest_dir, member_path):
198+
raise RuntimeError("Blocked zip extraction outside destination")
199+
archive.extractall(dest_dir)
200+
201+
202+
def is_within_directory(directory: Path, target: Path) -> bool:
203+
directory_resolved = directory.resolve()
204+
target_resolved = target.resolve(strict=False)
205+
if target_resolved == directory_resolved:
206+
return True
207+
return str(target_resolved).startswith(str(directory_resolved) + os.sep)
208+
209+
210+
def find_binary(root: Path, binary_name: str) -> Path:
211+
matches = list(root.rglob(binary_name))
212+
if not matches:
213+
raise RuntimeError(f"delta binary {binary_name} not found in archive")
214+
matches.sort()
215+
return matches[0]
216+
217+
218+
def render_delta_diff(delta_path: Path, diff_lines: list[str]) -> tuple[str | None, str | None]:
219+
if not diff_lines:
220+
return None, None
221+
diff_text = "\n".join(diff_lines)
222+
if not diff_text.endswith("\n"):
223+
diff_text += "\n"
224+
result = subprocess.run(
225+
[str(delta_path), *DELTA_ARGS],
226+
input=diff_text,
227+
text=True,
228+
capture_output=True,
229+
check=False,
230+
)
231+
if result.returncode != 0:
232+
error = result.stderr.strip() or f"delta exited with status {result.returncode}"
233+
return None, error
234+
return result.stdout, None

0 commit comments

Comments
 (0)