Skip to content

Commit e1a057f

Browse files
committed
feat: staged-action UX with single Perform Deploy trigger
Replaces immediate-action model with a staged workflow: - Add PR(s): new rows highlighted blue until next deploy - Fetch Latest: queues a commit upgrade (strikethrough + ↑↑), not applied yet - Enable/Disable: stages active state change (green/red rows) - Remove: immediately removes row from state (no deploy needed) - Perform Deploy: only action that triggers Jenkins; applies all pending changes atomically, records last_deploy_at, and redirects with a Jenkins progress link State file format updated from bare array to {"last_deploy_at":"","prs":[...]} with backward-compat read. Shell script updated to handle new format.
1 parent ce8d262 commit e1a057f

File tree

3 files changed

+161
-58
lines changed

3 files changed

+161
-58
lines changed

openlibrary/plugins/openlibrary/status.py

Lines changed: 108 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import sys
88
import urllib.error
99
import urllib.request
10-
from dataclasses import dataclass
10+
from dataclasses import dataclass, field
1111
from pathlib import Path
1212
from typing import Any
1313
from urllib.parse import urlencode
@@ -27,26 +27,30 @@
2727
TESTING_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

3233
class 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:

openlibrary/templates/status.html

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
$def with (status = {}, flags = {}, dev_merged_status=None, testing_prs=None, drift_info=None, is_maintainer=False, show_testing=False)
1+
$def with (status = {}, flags = {}, dev_merged_status=None, testing_state=None, drift_info=None, is_maintainer=False, show_testing=False, deploy_triggered=False, jenkins_job_url='')
22
$# Template entry point for /status page
33
$var title: $_("Open Library server status")
44

@@ -26,8 +26,18 @@
2626
.drift-high { background: #fff1f0; }
2727
.row-merged td { text-decoration: line-through; color: #999; }
2828
.row-inactive { opacity: 0.6; }
29+
.row-new td { background: #e6f0ff; }
30+
.row-pending-enable td { background: #f0fff4; }
31+
.row-pending-disable td { background: #fff0f0; }
2932
.testing-actions { margin-top: 0.5em; }
3033
.testing-actions button { margin-right: 4px; }
34+
.deploy-banner {
35+
background: #e6ffe6;
36+
border: 1px solid #5a5;
37+
padding: 8px 12px;
38+
margin-bottom: 1em;
39+
border-radius: 4px;
40+
}
3141
</style>
3242

3343
<div id="contentHead">
@@ -39,13 +49,21 @@ <h1>$_("Server status")</h1>
3949
$if show_testing:
4050
<h2>$_("Testing Environment")</h2>
4151

52+
$if deploy_triggered:
53+
<div class="deploy-banner">
54+
$_("Deploy triggered!")
55+
$if jenkins_job_url:
56+
&mdash; <a href="$jenkins_job_url" target="_blank">$_("View Jenkins progress")</a>
57+
</div>
58+
4259
$if is_maintainer:
4360
<form method="POST" action="/status/add" style="margin-bottom: 1em;">
4461
<input type="text" name="pr" placeholder="$_('PR numbers or URLs, space/comma separated')" size="50">
45-
<button type="submit">$_("Add PR")</button>
62+
<button type="submit">$_("Add PR(s)")</button>
4663
</form>
4764

48-
$if testing_prs:
65+
$if testing_state and testing_state.prs:
66+
$ last_deploy_at = testing_state.last_deploy_at
4967
<form method="POST" id="testing-form">
5068
<table class="testing-table">
5169
<thead>
@@ -62,18 +80,26 @@ <h2>$_("Testing Environment")</h2>
6280
</tr>
6381
</thead>
6482
<tbody>
65-
$for pr in testing_prs:
83+
$for pr in testing_state.prs:
6684
$ d = (drift_info or {}).get(pr.pr, {})
6785
$ drift = d.get('drift', -1)
6886
$ head_sha = d.get('head_sha', '')
6987
$ merged = d.get('merged', False)
70-
$ row_class = ('row-merged ' if merged else '') + ('row-inactive ' if not pr.active else '') + ('drift-high' if drift > 5 else ('drift-low' if drift > 0 else ''))
88+
$ is_new = bool(last_deploy_at and pr.added_at > last_deploy_at)
89+
$ row_class = 'row-merged' if merged else ('row-pending-enable' if pr.pending_active is True else ('row-pending-disable' if pr.pending_active is False else ('row-new' if is_new else ('' if pr.active else 'row-inactive'))))
90+
$ row_class += (' drift-high' if drift > 5 else (' drift-low' if drift > 0 else ''))
7191
<tr class="$row_class.strip()">
7292
$if is_maintainer:
7393
<td><input type="checkbox" name="prs" value="$pr.pr"></td>
7494
<td><a href="https://github.com/internetarchive/openlibrary/pull/$pr.pr">#$pr.pr</a></td>
7595
<td>$pr.title</td>
76-
<td><a href="https://github.com/internetarchive/openlibrary/commit/$pr.commit">$pr.short_commit</a></td>
96+
<td>
97+
$if pr.pull_latest_sha:
98+
<a href="https://github.com/internetarchive/openlibrary/commit/$pr.commit"><s>$pr.short_commit</s></a>
99+
&#x2191;&#x2191; <a href="https://github.com/internetarchive/openlibrary/commit/$pr.pull_latest_sha">$pr.short_pull_latest</a>
100+
$else:
101+
<a href="https://github.com/internetarchive/openlibrary/commit/$pr.commit">$pr.short_commit</a>
102+
</td>
77103
<td>$head_sha</td>
78104
<td>
79105
$if drift == 0:
@@ -84,7 +110,11 @@ <h2>$_("Testing Environment")</h2>
84110
$drift commit$("s" if drift != 1 else "") behind
85111
</td>
86112
<td>
87-
$if pr.active:
113+
$if pr.pending_active is True:
114+
&#x23F8; &#x2192; &#x2705;
115+
$elif pr.pending_active is False:
116+
&#x2705; &#x2192; &#x23F8;
117+
$elif pr.active:
88118
&#x2705;
89119
$else:
90120
&#x23F8;
@@ -96,22 +126,23 @@ <h2>$_("Testing Environment")</h2>
96126

97127
$if is_maintainer:
98128
<div class="testing-actions">
99-
<button formaction="/status/pull-latest" type="submit">$_("Pull to Latest")</button>
100-
<button formaction="/status/toggle" type="submit">$_("Toggle Active")</button>
129+
<button formaction="/status/pull-latest" type="submit">$_("Fetch Latest")</button>
130+
<button formaction="/status/enable" type="submit">$_("Enable")</button>
131+
<button formaction="/status/disable" type="submit">$_("Disable")</button>
101132
<button formaction="/status/remove" type="submit">$_("Remove")</button>
102133
</div>
103134
</form>
104135

105136
$if is_maintainer:
106-
<form method="POST" action="/status/rebuild" style="margin-top: 0.5em;">
107-
<button type="submit">$_("Force Rebuild")</button>
137+
<form method="POST" action="/status/deploy" style="margin-top: 0.5em;">
138+
<button type="submit">$_("Perform Deploy")</button>
108139
</form>
109140

110141
$else:
111142
<p>$_("No PRs in testing set.")</p>
112143

113144
$if dev_merged_status:
114-
<h2> $_("Staged") </h2>
145+
<h2> $_("Last Build Result") </h2>
115146
<pre>$dev_merged_status.git_status</pre>
116147

117148
<br>

0 commit comments

Comments
 (0)