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
13 changes: 10 additions & 3 deletions plugins/magpie-issue/skills/backlog-stats/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ argument-hint: "[repo:owner/name] [since:date] [--markdown] [--tables-only] [cle
capability: capability:stats
surface_hash: sha256:0f124437a9fa54f9
license: Apache-2.0
measured_tokens: 4984
measured_tokens: 5055
---

<!-- SPDX-License-Identifier: Apache-2.0
Expand Down Expand Up @@ -207,9 +207,16 @@ For each open issue, determine exactly one triage class:
| `UNTRIAGED` | No comment from a collaborator (`OWNER`, `MEMBER`, `COLLABORATOR`) that contains a triage-proposal marker (the string `Triage proposal` for GitHub Issues, or the project's configured marker from `issue-tracker-config.md`). |
| `TRIAGED` | A collaborator triage-proposal comment exists. Issue has no linked open PR and no assignee. |
| `IN-PROGRESS` | A collaborator triage-proposal comment exists AND the issue has an assignee or a linked open PR. |
| `STALE-CANDIDATE` | `days_since_updated >= warn_days` regardless of triage status. When both `IN-PROGRESS` and `STALE-CANDIDATE` apply, the issue is counted in both (staleness is orthogonal). |
| `SKIP-SECURITY` | Title or first comment contains security signals (see Golden rule 7). Excluded from all aggregate counts. |

Staleness is **not** a class. It is the separate `is_stale_candidate`
boolean (`days_since_updated >= warn_days`), so an issue keeps its real
class and can be stale on top of it. An untriaged issue last updated 216

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.

This paragraph is the exact missing contract piece. Grounding it with the 216-day / 90 warn_days example (which directly matches case-4-stale-candidate) gives Sonnet unambiguous context to emit triage_class: "UNTRIAGED" and is_stale_candidate: true.

days ago with `warn_days` at 90 is `UNTRIAGED` with
`is_stale_candidate: true` — never `STALE-CANDIDATE`. Every downstream
count reads that boolean: the Step 3 `stale_candidate` bucket, the
pressure score, and the untriaged funnel.

Cache the class per `(issue_number, updated_at)` in the scratch cache.

For GitHub Issues, collaborator status is determined by `authorAssociation`
Expand All @@ -230,7 +237,7 @@ Per area, compute:
- `untriaged` — issues with class `UNTRIAGED`.
- `triaged` — issues with class `TRIAGED`.
- `in_progress` — issues with class `IN-PROGRESS`.
- `stale_candidate` — issues with class `STALE-CANDIDATE`.
- `stale_candidate` — issues with `is_stale_candidate: true`, whatever their class.
- `age_buckets` — histogram of `[< 7 d, 7–30 d, 30–90 d, > 90 d]`.

Also compute a `TOTAL` row where each issue is counted exactly once (NOT
Expand Down
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).
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
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}"
Loading