Skip to content

Commit 2d4d988

Browse files
committed
improvements: cacheing and improved logging
Signed-off-by: Paul Jickling <paul.jickling@ethereum.org>
1 parent 60ee188 commit 2d4d988

1 file changed

Lines changed: 134 additions & 58 deletions

File tree

tracker.py

Lines changed: 134 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import logging
33
import os
44
import re
5-
import sys
65
from datetime import datetime, timezone
76

87
import requests
@@ -11,6 +10,8 @@
1110
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
1211
log = logging.getLogger(__name__)
1312

13+
CACHE_VERSION = 1
14+
1415

1516
def parse_section(body: str, section: str) -> str:
1617
"""Extract text under a ## section header, stripping HTML comments."""
@@ -25,7 +26,6 @@ def parse_section(body: str, section: str) -> str:
2526
def parse_duration(body: str, year_plus_months: int) -> tuple[str, relativedelta | None]:
2627
"""Return (raw_value, expiry_offset). Offset is None for uncertain/indefinite."""
2728
raw = parse_section(body, "Resource Duration").lower()
28-
2929
if "less than 6 months" in raw:
3030
return "Less than 6 months", relativedelta(months=6)
3131
if "6 months to a year" in raw:
@@ -34,12 +34,11 @@ def parse_duration(body: str, year_plus_months: int) -> tuple[str, relativedelta
3434
return "year+", relativedelta(months=year_plus_months)
3535
if "uncertain" in raw or "indefinite" in raw:
3636
return "uncertain/indefinite", None
37-
3837
return f"unparseable: {raw[:80]}", None
3938

4039

41-
def fetch_github_issues(repo: str, token: str) -> list[dict]:
42-
"""Fetch all open non-PR issues from the repo, handling pagination."""
40+
def fetch_github_issues(repo: str, token: str, since: str | None = None) -> list[dict]:
41+
"""Fetch all non-PR issues (open and closed), optionally filtered by update time."""
4342
headers = {
4443
"Authorization": f"Bearer {token}",
4544
"Accept": "application/vnd.github+json",
@@ -48,10 +47,13 @@ def fetch_github_issues(repo: str, token: str) -> list[dict]:
4847
issues = []
4948
page = 1
5049
while True:
50+
params = {"state": "all", "per_page": 100, "page": page}
51+
if since:
52+
params["since"] = since
5153
resp = requests.get(
5254
f"https://api.github.com/repos/{repo}/issues",
5355
headers=headers,
54-
params={"state": "open", "per_page": 100, "page": page},
56+
params=params,
5557
timeout=30,
5658
)
5759
resp.raise_for_status()
@@ -69,74 +71,148 @@ def check_netbox(project_name: str, netbox_url: str, token: str) -> tuple[bool,
6971
return False, None
7072
headers = {"Authorization": f"Token {token}"}
7173
for endpoint in ("virtualization/virtual-machines", "dcim/devices"):
72-
resp = requests.get(
73-
f"{netbox_url}/api/{endpoint}/",
74-
headers=headers,
75-
params={"name": project_name, "limit": 1},
76-
timeout=30,
77-
)
78-
if resp.status_code == 200:
79-
results = resp.json().get("results", [])
80-
if results:
81-
return True, results[0]["name"]
74+
try:
75+
resp = requests.get(
76+
f"{netbox_url}/api/{endpoint}/",
77+
headers=headers,
78+
params={"name": project_name, "limit": 1},
79+
timeout=30,
80+
)
81+
if resp.status_code == 200:
82+
results = resp.json().get("results", [])
83+
if results:
84+
return True, results[0]["name"]
85+
except requests.exceptions.RequestException as e:
86+
log.warning(f"Netbox request failed for '{project_name}' on {endpoint}: {e}")
8287
return False, None
8388

8489

90+
def load_cache(path: str) -> dict:
91+
if os.path.exists(path):
92+
try:
93+
with open(path) as f:
94+
return json.load(f)
95+
except (json.JSONDecodeError, OSError) as e:
96+
log.warning(f"Cache unreadable at {path}, starting fresh: {e}")
97+
return {"version": CACHE_VERSION, "last_run": None, "issues": {}}
98+
99+
100+
def save_cache(cache: dict, path: str) -> None:
101+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
102+
with open(path, "w") as f:
103+
json.dump(cache, f, indent=2)
104+
105+
106+
def compute_expiry(entry: dict, now: datetime, year_plus_months: int) -> None:
107+
"""Recompute lifecycle_status, expiry_date, and expired_days_ago in place."""
108+
duration = entry.get("duration_stated", "").lower()
109+
try:
110+
created_at = datetime.fromisoformat(entry["created_at"]).replace(tzinfo=timezone.utc)
111+
except (ValueError, KeyError) as e:
112+
log.warning(f"Invalid created_at for issue #{entry.get('issue_number')}: {e}")
113+
entry["lifecycle_status"] = "unknown"
114+
return
115+
116+
if "less than 6 months" in duration:
117+
offset = relativedelta(months=6)
118+
elif "6 months to a year" in duration:
119+
offset = relativedelta(months=12)
120+
elif "year+" in duration:
121+
offset = relativedelta(months=year_plus_months)
122+
else:
123+
entry["lifecycle_status"] = "no_expiry"
124+
entry.pop("expiry_date", None)
125+
entry.pop("expired_days_ago", None)
126+
return
127+
128+
expiry = created_at + offset
129+
days = (now - expiry).days
130+
entry["expiry_date"] = expiry.date().isoformat()
131+
entry["expired_days_ago"] = days
132+
entry["lifecycle_status"] = "expired" if days >= 0 else "active"
133+
134+
135+
def process_issue(issue: dict, year_plus_months: int) -> dict | None:
136+
"""Parse a GitHub issue into a cache entry. Returns None if not a VM request."""
137+
body = issue.get("body") or ""
138+
if "## Resource Duration" not in body:
139+
return None
140+
try:
141+
created_at = datetime.fromisoformat(issue["created_at"].replace("Z", "+00:00"))
142+
except ValueError as e:
143+
log.warning(f"Issue #{issue['number']} has unparseable created_at: {e}")
144+
return None
145+
146+
duration_raw, _ = parse_duration(body, year_plus_months)
147+
return {
148+
"issue_number": issue["number"],
149+
"issue_url": issue["html_url"],
150+
"project_name": parse_section(body, "Project Name"),
151+
"team": parse_section(body, "Team Owner"),
152+
"contact": parse_section(body, "Team Contact"),
153+
"created_at": created_at.date().isoformat(),
154+
"duration_stated": duration_raw,
155+
}
156+
157+
85158
def main() -> None:
86159
github_token = os.environ["GITHUB_TOKEN"]
87160
netbox_token = os.environ["NETBOX_TOKEN"]
88161
github_repo = os.environ["GITHUB_REPO"]
89162
netbox_url = os.environ.get("NETBOX_URL", "https://netbox.ethquokkaops.io").rstrip("/")
90163
year_plus_months = int(os.environ.get("THRESHOLD_YEAR_PLUS_MONTHS", "18"))
164+
cache_file = os.environ.get("CACHE_FILE", "/data/cache.json")
91165

92166
now = datetime.now(timezone.utc)
93-
94-
log.info(f"Fetching issues from {github_repo}")
95-
issues = fetch_github_issues(github_repo, github_token)
96-
log.info(f"Found {len(issues)} open issues")
97-
98-
expired = []
99-
no_expiry = []
100-
101-
for issue in issues:
102-
body = issue.get("body") or ""
103-
if "## Resource Duration" not in body:
104-
continue
105-
106-
created_at = datetime.fromisoformat(issue["created_at"].replace("Z", "+00:00"))
107-
project_name = parse_section(body, "Project Name")
108-
duration_raw, offset = parse_duration(body, year_plus_months)
109-
110-
netbox_match, netbox_vm = check_netbox(project_name, netbox_url, netbox_token)
111-
112-
entry = {
113-
"issue_number": issue["number"],
114-
"issue_url": issue["html_url"],
115-
"project_name": project_name,
116-
"team": parse_section(body, "Team Owner"),
117-
"contact": parse_section(body, "Team Contact"),
118-
"created_at": created_at.date().isoformat(),
119-
"duration_stated": duration_raw,
120-
"netbox_match": netbox_match,
121-
"netbox_vm": netbox_vm,
122-
}
123-
124-
if offset is None:
167+
cache = load_cache(cache_file)
168+
last_run = cache.get("last_run")
169+
170+
# Step 1: fetch new and updated issues from GitHub since last run
171+
log.info(f"Fetching issues from {github_repo} (since {last_run or 'beginning'})")
172+
try:
173+
updated_issues = fetch_github_issues(github_repo, github_token, since=last_run)
174+
except requests.exceptions.RequestException as e:
175+
log.error(f"GitHub API request failed: {e}")
176+
raise SystemExit(1)
177+
log.info(f"Fetched {len(updated_issues)} new/updated issues")
178+
179+
for issue in updated_issues:
180+
entry = process_issue(issue, year_plus_months)
181+
if entry:
182+
cache["issues"][str(issue["number"])] = entry
183+
184+
# Step 2: recheck Netbox and recompute expiry for all cached issues
185+
log.info(f"Rechecking {len(cache['issues'])} cached issues")
186+
expired, no_expiry, not_found = [], [], []
187+
188+
for entry in cache["issues"].values():
189+
netbox_match, netbox_vm = check_netbox(entry["project_name"], netbox_url, netbox_token)
190+
entry["netbox_match"] = netbox_match
191+
entry["netbox_vm"] = netbox_vm
192+
entry["last_checked"] = now.isoformat()
193+
194+
compute_expiry(entry, now, year_plus_months)
195+
196+
status = entry.get("lifecycle_status")
197+
if not netbox_match:
198+
not_found.append(entry)
199+
elif status == "no_expiry":
125200
no_expiry.append(entry)
126-
continue
127-
128-
expiry = created_at + offset
129-
entry["expiry_date"] = expiry.date().isoformat()
130-
entry["expired_days_ago"] = (now - expiry).days
131-
132-
if entry["expired_days_ago"] >= 0:
201+
elif status == "expired":
133202
expired.append(entry)
134203

135-
expired.sort(key=lambda e: e["expired_days_ago"], reverse=True)
204+
expired.sort(key=lambda e: e.get("expired_days_ago", 0), reverse=True)
136205

137-
log.info(f"Expired: {len(expired)}, no expiry set: {len(no_expiry)}")
206+
cache["last_run"] = now.isoformat()
207+
save_cache(cache, cache_file)
138208

139-
print(json.dumps({"generated_at": now.isoformat(), "expired": expired, "no_expiry": no_expiry}, indent=2))
209+
log.info(f"Expired: {len(expired)}, no expiry: {len(no_expiry)}, not in Netbox: {len(not_found)}")
210+
print(json.dumps({
211+
"generated_at": now.isoformat(),
212+
"expired": expired,
213+
"no_expiry": no_expiry,
214+
"not_found": not_found,
215+
}, indent=2))
140216

141217

142218
if __name__ == "__main__":

0 commit comments

Comments
 (0)