22import logging
33import os
44import re
5- import sys
65from datetime import datetime , timezone
76
87import requests
1110logging .basicConfig (level = logging .INFO , format = "%(levelname)s %(message)s" )
1211log = logging .getLogger (__name__ )
1312
13+ CACHE_VERSION = 1
14+
1415
1516def 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:
2526def 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+
85158def 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
142218if __name__ == "__main__" :
0 commit comments