Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ If the adopter passes nothing, surface the resolved output path and cache state
2. **Check cache freshness.** Inspect
`<cache>/issues.json` mtime, where `<cache>` is the
`tracker_stats_cache` value from the step 1 config (else
`${TRACKER_STATS_CACHE:-/tmp/tracker-stats-cache}`, the fetch scripts' default).
`${TRACKER_STATS_CACHE:-<platform temp dir>/tracker-stats-cache}`, the
fetch scripts' default — `$TMPDIR` on POSIX, `%TEMP%` on Windows).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because prose in Step 2 was modified here, the frontmatter surface_hash and measured_tokens (lines 19–21) are now out of date.

Running prek run --all-files (or python tools/dev/check-measured-tokens.py --fix) will recalculate and update these stamps so the CI skill-surface-hash hook passes.

Step 3 passes the same `<cache>` so the check and the fetch agree.
If older than 24 h, propose a fresh fetch; if missing or
the user passed `clear-cache`, do a fresh fetch unconditionally.
Expand Down
5 changes: 4 additions & 1 deletion plugins/magpie-setup/templates/security-tracker-stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ review).
## Cache directory

```yaml
tracker_stats_cache: /tmp/tracker-stats-cache
# Left unset, the fetch scripts default to
# `<platform temp dir>/tracker-stats-cache` ($TMPDIR on POSIX, %TEMP% on
# Windows) — the one location a sandboxed run can always write.
# tracker_stats_cache: /path/to/cache
```

Where the fetch scripts persist their cache. Safe to delete (forces a
Expand Down
4 changes: 2 additions & 2 deletions tools/security-tracker-stats-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ Env knobs (all optional):
| Var | Default | Notes |
|---|---|---|
| `TRACKER_STATS_REPO` | *(e.g. `airflow-s/airflow-s`)* | `<tracker>` repo slug |
| `TRACKER_STATS_OUT` | `/tmp/airflow_s_monthly.html` | output HTML path |
| `TRACKER_STATS_CACHE` | `/tmp/tracker-stats-cache` | fetch cache dir |
| `TRACKER_STATS_OUT` | `$TMPDIR/airflow_s_monthly.html` | output HTML path |
| `TRACKER_STATS_CACHE` | `$TMPDIR/tracker-stats-cache` | fetch cache dir |
| `TRACKER_STATS_CONFIG` | *(unset)* | path to a YAML overlay file |
| `TRACKER_STATS_BUCKETS` | *(from config: `monthly`)* | `monthly` or `quarterly` |
| `TRACKER_STATS_START` | *(from config: `null`)* | `YYYY-MM` or `YYYY-Qn` |
Expand Down
5 changes: 4 additions & 1 deletion tools/security-tracker-stats-dashboard/fetch_bodies.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import json
import os
import subprocess
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed

EXTRA_FIELDS = ("body", "closedByPullRequestsReferences")
Expand Down Expand Up @@ -85,7 +86,9 @@ def fetch(repo, n):


def main():
root = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
# Default under the platform temp root, not a hardcoded `/tmp`: a sandboxed run
# is granted its own scratch directory and cannot write `/tmp`. See fetch_issues.py.
root = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache")
repo = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s")
out = f"{root}/issue_extra.json"

Expand Down
5 changes: 4 additions & 1 deletion tools/security-tracker-stats-dashboard/fetch_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
import json
import os
import subprocess
import tempfile

ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
# Default under the platform temp root, not a hardcoded `/tmp`: a sandboxed run is
# granted its own scratch directory and cannot write `/tmp`. See fetch_issues.py.
ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache")
REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s")
EVENTS_DIR = f"{ROOT}/events"

Expand Down
140 changes: 100 additions & 40 deletions tools/security-tracker-stats-dashboard/fetch_issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,49 +21,109 @@

import json
import os
import re
import subprocess
import sys
import tempfile
import time

ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
# The cache defaults under the platform temp root rather than a hardcoded `/tmp`:
# a sandboxed run is typically granted its own scratch directory and cannot write
# `/tmp`, so `/tmp/tracker-stats-cache` made the first fetch step fail outright.
# `tempfile.gettempdir()` honours `$TMPDIR` (POSIX) and `%TEMP%` (Windows).
ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache")
REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s")

os.makedirs(ROOT, exist_ok=True)

print(f"Fetching issue list from {REPO} (state=all, limit 1000) ...")
r = subprocess.run(
[
"gh",
"issue",
"list",
"--repo",
REPO,
"--state",
"all",
"--limit",
"1000",
"--json",
# `updatedAt` is consumed by fetch_events.py, which refuses to trust a cached
# events file older than the issue's last update. Dropping it here silently
# disables that guard, because the check falls back to "no timestamp -> trust
# the cache" and every relabelled issue keeps its stale event history.
# `body` and `closedByPullRequestsReferences` ride on this one paginated call
# so fetch_bodies.py can build issue_extra.json without a `gh issue view`
# per issue.
"number,title,state,stateReason,createdAt,closedAt,updatedAt,labels,comments,body,closedByPullRequestsReferences",
],
capture_output=True,
text=True,
timeout=300,
# `gh` reports a server-side hiccup as a non-zero exit with the status in stderr.
# A single transient `HTTP 502` on the first call used to abort the entire
# dashboard run, so retry the failures that are worth retrying and fail fast on
# everything else (auth errors, a bad repo slug, an unknown flag).
_TRANSIENT_FAILURE = re.compile(
r"HTTP\s+5\d\d"
r"|Bad Gateway"
r"|Service Unavailable"
r"|Gateway Time-?out"
r"|Internal Server Error"
r"|connection reset"
r"|timed out",
re.IGNORECASE,
)
if r.returncode != 0:
raise SystemExit(f"gh failed: {r.stderr}")

issues = json.loads(r.stdout)
with open(f"{ROOT}/issues.json", "w") as f:
json.dump(issues, f)

print(f"Wrote {len(issues)} issues to {ROOT}/issues.json")
if len(issues) >= 1000:
print(
"WARNING: the issue list hit the 1000-issue cap; older issues are missing "
"and every count in the dashboard is a floor. Raise --limit in fetch_issues.py."

ATTEMPTS = 3
BASE_DELAY_SECONDS = 2.0


def is_transient_failure(stderr: str) -> bool:
"""True when a failed `gh` call looks like a retryable server-side error."""
return bool(_TRANSIENT_FAILURE.search(stderr or ""))


def run_gh(
args: list[str],
*,
attempts: int = ATTEMPTS,
base_delay: float = BASE_DELAY_SECONDS,
timeout: int = 300,
) -> subprocess.CompletedProcess:
"""Run `gh`, retrying transient failures with exponential backoff.

Raises `SystemExit` with the last stderr once the attempts are exhausted, or

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_TRANSIENT_FAILURE regex includes "timed out", which matches if gh itself outputs a timeout to stderr. However, if the command hits the 300s subprocess limit, subprocess.run raises subprocess.TimeoutExpired instead of returning a completed process.

Consider wrapping subprocess.run to catch TimeoutExpired and treat it as transient so it can also be retried:

Suggested change
Raises `SystemExit` with the last stderr once the attempts are exhausted, or
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired as exc:
if attempt == attempts:
raise SystemExit(f"gh timed out after {timeout}s: {exc}")
delay = base_delay * 2 ** (attempt - 1)
print(
f"gh timed out (attempt {attempt}/{attempts}), retrying in {delay:g}s",
file=sys.stderr,
)
time.sleep(delay)
continue

immediately for a failure that retrying cannot fix.
"""
for attempt in range(1, attempts + 1):
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
if r.returncode == 0:
return r
if attempt == attempts or not is_transient_failure(r.stderr):
raise SystemExit(f"gh failed: {r.stderr}")
delay = base_delay * 2 ** (attempt - 1)
print(
f"gh failed (attempt {attempt}/{attempts}), retrying in {delay:g}s: "
f"{(r.stderr or '').strip()[:200]}",
file=sys.stderr,
)
time.sleep(delay)
raise AssertionError("unreachable: run_gh returns or raises on every attempt")


def main() -> None:
os.makedirs(ROOT, exist_ok=True)

print(f"Fetching issue list from {REPO} (state=all, limit 1000) ...")
r = run_gh(
[
"gh",
"issue",
"list",
"--repo",
REPO,
"--state",
"all",
"--limit",
"1000",
"--json",
# `updatedAt` is consumed by fetch_events.py, which refuses to trust a cached
# events file older than the issue's last update. Dropping it here silently
# disables that guard, because the check falls back to "no timestamp -> trust
# the cache" and every relabelled issue keeps its stale event history.
# `body` and `closedByPullRequestsReferences` ride on this one paginated call
# so fetch_bodies.py can build issue_extra.json without a `gh issue view`
# per issue.
"number,title,state,stateReason,createdAt,closedAt,updatedAt,labels,comments,body,closedByPullRequestsReferences",
]
)

issues = json.loads(r.stdout)
with open(f"{ROOT}/issues.json", "w") as f:
json.dump(issues, f)

print(f"Wrote {len(issues)} issues to {ROOT}/issues.json")
if len(issues) >= 1000:
print(
"WARNING: the issue list hit the 1000-issue cap; older issues are missing "
"and every count in the dashboard is a floor. Raise --limit in fetch_issues.py."
)


if __name__ == "__main__":
main()
5 changes: 4 additions & 1 deletion tools/security-tracker-stats-dashboard/fetch_prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@
import os
import re
import subprocess
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed

ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
# Default under the platform temp root, not a hardcoded `/tmp`: a sandboxed run is
# granted its own scratch directory and cannot write `/tmp`. See fetch_issues.py.
ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache")
UPSTREAM = os.environ.get("TRACKER_STATS_UPSTREAM_REPO", "apache/airflow")
if UPSTREAM in ("", "none", "null"):
print("TRACKER_STATS_UPSTREAM_REPO is empty/none - skipping PR fetch.")
Expand Down
5 changes: 4 additions & 1 deletion tools/security-tracker-stats-dashboard/fetch_roster.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@

import os
import subprocess
import tempfile

ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
# Default under the platform temp root, not a hardcoded `/tmp`: a sandboxed run is
# granted its own scratch directory and cannot write `/tmp`. See fetch_issues.py.
ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache")
REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s")

os.makedirs(ROOT, exist_ok=True)
Expand Down
12 changes: 9 additions & 3 deletions tools/security-tracker-stats-dashboard/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

"""
Regenerate a tracker-stats dashboard. Reads cached issues+events+PR data
from `$TRACKER_STATS_CACHE` (default `/tmp/tracker-stats-cache`) and writes
from `$TRACKER_STATS_CACHE` (default `<tempdir>/tracker-stats-cache`) and writes
a self-contained HTML page to `$TRACKER_STATS_OUT`.

Configuration is loaded from `scripts/default-config.yaml`, optionally
Expand Down Expand Up @@ -52,6 +52,7 @@
import re
import statistics
import sys
import tempfile
from collections import defaultdict

# Make the src/ package importable when render.py is run as a standalone script.
Expand Down Expand Up @@ -104,8 +105,13 @@ def yaml_load(text):

# --- Config loading -------------------------------------------------

ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
OUT_PATH = os.environ.get("TRACKER_STATS_OUT", "/tmp/airflow_s_monthly.html")
# Both defaults sit under the platform temp root, not a hardcoded `/tmp`: a
# sandboxed run is granted its own scratch directory and can write neither.
# See fetch_issues.py.
ROOT = os.environ.get("TRACKER_STATS_CACHE") or os.path.join(tempfile.gettempdir(), "tracker-stats-cache")
OUT_PATH = os.environ.get("TRACKER_STATS_OUT") or os.path.join(
tempfile.gettempdir(), "airflow_s_monthly.html"
)
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_CONFIG_PATH = os.path.join(HERE, "default-config.yaml")

Expand Down
6 changes: 3 additions & 3 deletions tools/security-tracker-stats-dashboard/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
# Usage: run.sh [output-path]
#
# Env overrides:
# TRACKER_STATS_CACHE (default: /tmp/tracker-stats-cache)
# TRACKER_STATS_OUT (default: /tmp/airflow_s_monthly.html - or arg $1)
# TRACKER_STATS_CACHE (default: $TMPDIR/tracker-stats-cache)
# TRACKER_STATS_OUT (default: $TMPDIR/airflow_s_monthly.html - or arg $1)
# TRACKER_STATS_REPO tracker repo (default: airflow-s/airflow-s)
# TRACKER_STATS_BUCKETS monthly | quarterly (overlay)
# TRACKER_STATS_START "YYYY-MM" / "YYYY-Qn" (overlay)
Expand Down Expand Up @@ -55,4 +55,4 @@ echo "-> fetch_prs"
echo "-> render"
"${PY_CMD[@]}" "$HERE/render.py"

echo "done: ${TRACKER_STATS_OUT:-/tmp/airflow_s_monthly.html}"
echo "done: ${TRACKER_STATS_OUT:-${TMPDIR:-/tmp}/airflow_s_monthly.html}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small consistency nit: render.py resolves the path via Python's tempfile.gettempdir(), which maps to %TEMP% on Windows, whereas bash's ${TMPDIR:-/tmp} will fallback to /tmp.

Not a blocker on POSIX, but worth keeping in mind or letting render.py print the exact final path it wrote to.

Loading