77import sys
88import urllib .error
99import urllib .request
10- from dataclasses import dataclass
10+ from dataclasses import dataclass , field
1111from pathlib import Path
1212from typing import Any
1313from urllib .parse import urlencode
2727TESTING_STATE_FILE = Path ('./_testing-prs.json' )
2828_GITHUB_API_BASE = "https://api.github.com/repos/internetarchive/openlibrary"
2929_JENKINS_URL = "https://jenkins.openlibrary.org/job/testing-deploy/buildWithParameters"
30+ _JENKINS_JOB_URL = "https://jenkins.openlibrary.org/job/testing-deploy/"
3031
3132
3233class status (delegate .page ):
3334 def GET (self ):
34- testing_prs = _load_testing_state () # None if state file doesn't exist
35+ testing_state = _load_testing_state ()
3536 is_maintainer_user = _is_maintainer ()
3637 drift_info = {}
37- if testing_prs :
38+ if testing_state :
3839 # NOTE: makes 1-2 GitHub API calls per PR; acceptable for small testing sets
39- drift_info = {p .pr : _get_pr_drift (p ) for p in testing_prs }
40- show_testing = testing_prs is not None or is_maintainer_user
40+ drift_info = {p .pr : _get_pr_drift (p ) for p in testing_state .prs }
41+ show_testing = testing_state is not None or is_maintainer_user
42+ i = web .input (deploy_triggered = None )
4143 return render_template (
4244 "status" ,
4345 status_info ,
4446 feature_flags ,
4547 dev_merged_status = get_dev_merged_status (),
46- testing_prs = testing_prs or [] ,
48+ testing_state = testing_state ,
4749 drift_info = drift_info ,
4850 is_maintainer = is_maintainer_user ,
4951 show_testing = show_testing ,
52+ deploy_triggered = bool (i .deploy_triggered ),
53+ jenkins_job_url = _JENKINS_JOB_URL ,
5054 )
5155
5256
@@ -65,13 +69,13 @@ def POST(self):
6569 pr_numbers .append (_parse_pr_number (val ))
6670 if not pr_numbers :
6771 raise web .badrequest ()
68- prs = _load_testing_state () or []
69- existing = {p .pr for p in prs }
72+ state = _load_testing_state () or TestingState ( last_deploy_at = '' , prs = [])
73+ existing = {p .pr for p in state . prs }
7074 user = get_current_user ()
7175 for pr_number in pr_numbers :
7276 if pr_number not in existing :
7377 info = _get_pr_info (pr_number )
74- prs .append (
78+ state . prs .append (
7579 TestingPR (
7680 pr = pr_number ,
7781 commit = info ['head_sha' ],
@@ -82,8 +86,7 @@ def POST(self):
8286 )
8387 )
8488 existing .add (pr_number )
85- _save_testing_state (prs )
86- _trigger_rebuild ()
89+ _save_testing_state (state )
8790 raise web .seeother ('/status' )
8891
8992
@@ -95,27 +98,42 @@ def POST(self):
9598 raise web .unauthorized ()
9699 i = web .input (prs = [])
97100 to_remove = {int (p ) for p in i .prs }
98- _save_testing_state (
99- [ p for p in ( _load_testing_state () or []) if p . pr not in to_remove ]
100- )
101- _trigger_rebuild ( )
101+ state = _load_testing_state ()
102+ if state :
103+ state . prs = [ p for p in state . prs if p . pr not in to_remove ]
104+ _save_testing_state ( state )
102105 raise web .seeother ('/status' )
103106
104107
105- class status_toggle (delegate .page ):
106- path = '/status/toggle '
108+ class status_enable (delegate .page ):
109+ path = '/status/enable '
107110
108111 def POST (self ):
109112 if not _is_maintainer ():
110113 raise web .unauthorized ()
111114 i = web .input (prs = [])
112- to_toggle = {int (p ) for p in i .prs }
113- prs = _load_testing_state () or []
114- for p in prs :
115- if p .pr in to_toggle :
116- p .active = not p .active
117- _save_testing_state (prs )
118- _trigger_rebuild ()
115+ to_enable = {int (p ) for p in i .prs }
116+ state = _load_testing_state () or TestingState (last_deploy_at = '' , prs = [])
117+ for p in state .prs :
118+ if p .pr in to_enable :
119+ p .pending_active = True
120+ _save_testing_state (state )
121+ raise web .seeother ('/status' )
122+
123+
124+ class status_disable (delegate .page ):
125+ path = '/status/disable'
126+
127+ def POST (self ):
128+ if not _is_maintainer ():
129+ raise web .unauthorized ()
130+ i = web .input (prs = [])
131+ to_disable = {int (p ) for p in i .prs }
132+ state = _load_testing_state () or TestingState (last_deploy_at = '' , prs = [])
133+ for p in state .prs :
134+ if p .pr in to_disable :
135+ p .pending_active = False
136+ _save_testing_state (state )
119137 raise web .seeother ('/status' )
120138
121139
@@ -127,25 +145,35 @@ def POST(self):
127145 raise web .unauthorized ()
128146 i = web .input (prs = [])
129147 to_update = {int (p ) for p in i .prs }
130- prs = _load_testing_state () or []
131- for p in prs :
148+ state = _load_testing_state () or TestingState ( last_deploy_at = '' , prs = [])
149+ for p in state . prs :
132150 if p .pr in to_update :
133151 info = _get_pr_info (p .pr )
134- if info ['head_sha' ]:
135- p .commit = info ['head_sha' ]
136- _save_testing_state (prs )
137- _trigger_rebuild ()
152+ if info ['head_sha' ] and info ['head_sha' ] != p .commit :
153+ p .pull_latest_sha = info ['head_sha' ]
154+ _save_testing_state (state )
138155 raise web .seeother ('/status' )
139156
140157
141- class status_rebuild (delegate .page ):
142- path = '/status/rebuild '
158+ class status_deploy (delegate .page ):
159+ path = '/status/deploy '
143160
144161 def POST (self ):
145162 if not _is_maintainer ():
146163 raise web .unauthorized ()
164+ state = _load_testing_state () or TestingState (last_deploy_at = '' , prs = [])
165+ # Apply all pending changes before deploying
166+ for p in state .prs :
167+ if p .pull_latest_sha :
168+ p .commit = p .pull_latest_sha
169+ p .pull_latest_sha = ''
170+ if p .pending_active is not None :
171+ p .active = p .pending_active
172+ p .pending_active = None
173+ state .last_deploy_at = datetime .datetime .now (datetime .UTC ).isoformat ()
174+ _save_testing_state (state )
147175 _trigger_rebuild ()
148- raise web .seeother ('/status' )
176+ raise web .seeother ('/status?deploy_triggered=1 ' )
149177
150178
151179@functools .cache
@@ -227,24 +255,35 @@ class TestingPR:
227255 title : str
228256 added_at : str # ISO timestamp
229257 added_by : str # OL username
258+ pull_latest_sha : str = '' # pending SHA from "Pull to Latest"; applied on deploy
259+ pending_active : bool | None = None # pending enable/disable; applied on deploy
230260
231261 @property
232262 def short_commit (self ) -> str :
233263 return self .commit [:7 ]
234264
265+ @property
266+ def short_pull_latest (self ) -> str :
267+ return self .pull_latest_sha [:7 ] if self .pull_latest_sha else ''
268+
235269 @property
236270 def added_date (self ) -> str :
237271 return self .added_at [:10 ] if self .added_at else ''
238272
239273 def to_dict (self ) -> dict :
240- return {
274+ d = {
241275 'pr' : self .pr ,
242276 'commit' : self .commit ,
243277 'active' : self .active ,
244278 'title' : self .title ,
245279 'added_at' : self .added_at ,
246280 'added_by' : self .added_by ,
247281 }
282+ if self .pull_latest_sha :
283+ d ['pull_latest_sha' ] = self .pull_latest_sha
284+ if self .pending_active is not None :
285+ d ['pending_active' ] = self .pending_active
286+ return d
248287
249288 @classmethod
250289 def from_dict (cls , d : dict ) -> 'TestingPR' :
@@ -255,20 +294,46 @@ def from_dict(cls, d: dict) -> 'TestingPR':
255294 title = d .get ('title' , f"PR #{ d ['pr' ]} " ),
256295 added_at = d .get ('added_at' , '' ),
257296 added_by = d .get ('added_by' , '' ),
297+ pull_latest_sha = d .get ('pull_latest_sha' , '' ),
298+ pending_active = d .get ('pending_active' , None ),
299+ )
300+
301+
302+ @dataclass
303+ class TestingState :
304+ last_deploy_at : str # ISO timestamp, empty if never deployed
305+ prs : list [TestingPR ] = field (default_factory = list )
306+
307+ def to_dict (self ) -> dict :
308+ return {
309+ 'last_deploy_at' : self .last_deploy_at ,
310+ 'prs' : [p .to_dict () for p in self .prs ],
311+ }
312+
313+ @classmethod
314+ def from_dict (cls , d : dict ) -> 'TestingState' :
315+ return cls (
316+ last_deploy_at = d .get ('last_deploy_at' , '' ),
317+ prs = [TestingPR .from_dict (p ) for p in d .get ('prs' , [])],
258318 )
259319
260320
261- def _load_testing_state () -> 'list[TestingPR] | None' :
262- """Returns list of TestingPRs if state file exists, None otherwise."""
321+ def _load_testing_state () -> 'TestingState | None' :
322+ """Returns TestingState if state file exists, None otherwise."""
263323 if TESTING_STATE_FILE .exists ():
264- return [
265- TestingPR .from_dict (d ) for d in json .loads (TESTING_STATE_FILE .read_text ())
266- ]
324+ data = json .loads (TESTING_STATE_FILE .read_text ())
325+ if isinstance (data , list ):
326+ # Backward compat: old format was a bare array
327+ return TestingState (
328+ last_deploy_at = '' ,
329+ prs = [TestingPR .from_dict (d ) for d in data ],
330+ )
331+ return TestingState .from_dict (data )
267332 return None
268333
269334
270- def _save_testing_state (prs : list [ TestingPR ] ) -> None :
271- TESTING_STATE_FILE .write_text (json .dumps ([ p .to_dict () for p in prs ] , indent = 2 ))
335+ def _save_testing_state (state : TestingState ) -> None :
336+ TESTING_STATE_FILE .write_text (json .dumps (state .to_dict (), indent = 2 ))
272337 get_dev_merged_status .cache_clear ()
273338
274339
@@ -329,7 +394,8 @@ def _trigger_rebuild() -> bool:
329394 token = getattr (config , 'jenkins_token' , None )
330395 if not token :
331396 return False
332- prs = _load_testing_state () or []
397+ state = _load_testing_state ()
398+ prs = state .prs if state else []
333399 lines = '\n ' .join (f"origin pull/{ p .pr } /head # { p .title } " for p in prs if p .active )
334400 url = f"{ _JENKINS_URL } ?{ urlencode ({'token' : token , 'GH_REPO_AND_BRANCH' : lines })} "
335401 try :
0 commit comments