blob: a371cb8743cdece5546ca57017cdc17d2500d7dd [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Fetch createdAt + mergedAt + state for every upstream-repo PR referenced
by any tracker (via closedByPullRequestsReferences or body parse). Cache to
`<TRACKER_STATS_CACHE>/prs.json`.
The upstream repo is `$TRACKER_STATS_UPSTREAM_REPO` (default
`apache/airflow`); set to `none` / `""` to skip this fetch entirely."""
import json
import os
import re
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/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.")
raise SystemExit(0)
EXTRA = f"{ROOT}/issue_extra.json"
OUT = f"{ROOT}/prs.json"
with open(EXTRA) as f:
extra = json.load(f)
PR_PAT = re.compile(
rf"{re.escape(UPSTREAM)}#(\d+)|https://github\.com/{re.escape(UPSTREAM)}/pull/(\d+)",
re.I,
)
def extract_prs(v):
nums = set()
cb = v.get("closedByPullRequestsReferences") or []
for ref in cb:
if ref.get("repository", {}).get("nameWithOwner") == UPSTREAM:
nums.add(ref["number"])
body = v.get("body") or ""
# Only parse the "PR with the fix" field portion if we can find it,
# but also accept apache/airflow PR mentions anywhere in the body
# (the spec allows either).
for m in PR_PAT.findall(body):
n = m[0] or m[1]
if n:
nums.add(int(n))
return nums
# Build issue -> PR set + collect all unique PRs
issue_to_prs = {}
all_prs = set()
for issue_n, v in extra.items():
prs = extract_prs(v)
issue_to_prs[issue_n] = sorted(prs)
all_prs.update(prs)
# Save the issue_to_prs linkage map alongside
with open(f"{ROOT}/issue_to_prs.json", "w") as f:
json.dump(issue_to_prs, f)
print(f"unique {UPSTREAM} PRs to fetch: {len(all_prs)}")
# Resume support
cache = {}
if os.path.exists(OUT):
with open(OUT) as f:
cache = json.load(f)
print(f"resume: {len(cache)} cached")
todo = [n for n in all_prs if str(n) not in cache]
print(f"to fetch: {len(todo)}")
def fetch(n):
try:
r = subprocess.run(
["gh", "pr", "view", str(n), "--repo", UPSTREAM, "--json", "number,createdAt,mergedAt,state"],
capture_output=True,
text=True,
timeout=60,
)
if r.returncode != 0:
return n, {"error": r.stderr.strip()}
return n, json.loads(r.stdout)
except Exception as e:
return n, {"error": str(e)}
done = 0
with ThreadPoolExecutor(max_workers=12) as ex:
futs = {ex.submit(fetch, n): n for n in todo}
for fut in as_completed(futs):
n, data = fut.result()
cache[str(n)] = data
done += 1
if done % 25 == 0:
with open(OUT, "w") as f:
json.dump(cache, f)
print(f" {done}/{len(todo)}")
with open(OUT, "w") as f:
json.dump(cache, f)
errs = sum(1 for v in cache.values() if "error" in v)
print(f"done: cached {len(cache)} PRs ({errs} errors) → {OUT}")