Skip to content

Commit e981880

Browse files
committed
init commit
Signed-off-by: Paul Jickling <paul.jickling@ethereum.org>
1 parent 27ebfc6 commit e981880

4 files changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: Docker
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches: [ "main" ]
7+
tags: [ 'v*.*.*' ]
8+
pull_request:
9+
branches: [ "main" ]
10+
11+
jobs:
12+
call-docker-build:
13+
uses: ethdevops/workflows/.github/workflows/basic-docker-build.yaml@main
14+
secrets:
15+
docker_registry_user: ${{ secrets.DOCKER_REGISTRY_USER }}
16+
docker_registry_password: ${{ secrets.DOCKER_REGISTRY_SECRET }}

Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
5+
COPY requirements.txt .
6+
RUN pip install --no-cache-dir -r requirements.txt
7+
8+
COPY tracker.py .
9+
10+
CMD ["python", "tracker.py"]

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests==2.32.3
2+
python-dateutil==2.9.0

tracker.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import json
2+
import logging
3+
import os
4+
import re
5+
import sys
6+
from datetime import datetime, timezone
7+
8+
import requests
9+
from dateutil.relativedelta import relativedelta
10+
11+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
12+
log = logging.getLogger(__name__)
13+
14+
15+
def parse_section(body: str, section: str) -> str:
16+
"""Extract text under a ## section header, stripping HTML comments."""
17+
pattern = rf"##\s+{re.escape(section)}\s*\n(.*?)(?=\n##\s|\Z)"
18+
match = re.search(pattern, body, re.DOTALL | re.IGNORECASE)
19+
if not match:
20+
return ""
21+
content = re.sub(r"<!--.*?-->", "", match.group(1), flags=re.DOTALL)
22+
return content.strip()
23+
24+
25+
def parse_duration(body: str, year_plus_months: int) -> tuple[str, relativedelta | None]:
26+
"""Return (raw_value, expiry_offset). Offset is None for uncertain/indefinite."""
27+
raw = parse_section(body, "Resource Duration").lower()
28+
29+
if "less than 6 months" in raw:
30+
return "Less than 6 months", relativedelta(months=6)
31+
if "6 months to a year" in raw:
32+
return "6 months to a year", relativedelta(months=12)
33+
if "year+" in raw:
34+
return "year+", relativedelta(months=year_plus_months)
35+
if "uncertain" in raw or "indefinite" in raw:
36+
return "uncertain/indefinite", None
37+
38+
return f"unparseable: {raw[:80]}", None
39+
40+
41+
def fetch_github_issues(repo: str, token: str) -> list[dict]:
42+
"""Fetch all open non-PR issues from the repo, handling pagination."""
43+
headers = {
44+
"Authorization": f"Bearer {token}",
45+
"Accept": "application/vnd.github+json",
46+
"X-GitHub-Api-Version": "2022-11-28",
47+
}
48+
issues = []
49+
page = 1
50+
while True:
51+
resp = requests.get(
52+
f"https://api.github.com/repos/{repo}/issues",
53+
headers=headers,
54+
params={"state": "open", "per_page": 100, "page": page},
55+
timeout=30,
56+
)
57+
resp.raise_for_status()
58+
batch = resp.json()
59+
if not batch:
60+
break
61+
issues.extend(i for i in batch if "pull_request" not in i)
62+
page += 1
63+
return issues
64+
65+
66+
def check_netbox(project_name: str, netbox_url: str, token: str) -> tuple[bool, str | None]:
67+
"""Look up a VM by project name in Netbox. Checks VMs then physical devices."""
68+
if not project_name:
69+
return False, None
70+
headers = {"Authorization": f"Token {token}"}
71+
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"]
82+
return False, None
83+
84+
85+
def main() -> None:
86+
github_token = os.environ["GITHUB_TOKEN"]
87+
netbox_token = os.environ["NETBOX_TOKEN"]
88+
github_repo = os.environ.get("GITHUB_REPO", "ethereum/devops")
89+
netbox_url = os.environ.get("NETBOX_URL", "https://netbox.ethquokkaops.io").rstrip("/")
90+
year_plus_months = int(os.environ.get("THRESHOLD_YEAR_PLUS_MONTHS", "18"))
91+
92+
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:
125+
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:
133+
expired.append(entry)
134+
135+
expired.sort(key=lambda e: e["expired_days_ago"], reverse=True)
136+
137+
log.info(f"Expired: {len(expired)}, no expiry set: {len(no_expiry)}")
138+
139+
print(json.dumps({"generated_at": now.isoformat(), "expired": expired, "no_expiry": no_expiry}, indent=2))
140+
141+
142+
if __name__ == "__main__":
143+
main()

0 commit comments

Comments
 (0)