Skip to content

Commit ce8d262

Browse files
committed
feat: interactive testing environment dashboard on /status (#11506)
Turns /status into an interactive deploy management dashboard for testing.openlibrary.org, available to /usergroup/maintainers and /usergroup/admin. Non-maintainers see a read-only view. Changes: - status.py: add TestingPR dataclass and _testing-prs.json state file (same pattern as _dev-merged_status.txt) for persistent tracking of deployed PRs with pinned commit SHAs. Add POST endpoints /status/add, /status/remove, /status/toggle, /status/pull-latest, /status/rebuild gated behind maintainer usergroup. GitHub API integration (no token needed — public repo) for live drift detection on page load. Jenkins rebuild trigger via jenkins_token config key (no-op if not set). /status/add accepts multiple PRs as space/comma separated input. - status.html: interactive table with checkboxes, action buttons, drift display, and merged-PR flagging. Actions hidden for non-maintainers. - make-integration-branch.sh: when called with a branches file (e.g. via bookmarklet), PRs are added to _testing-prs.json pinned to their fetched SHA, then the full state file is built. Existing PRs are updated to latest SHA (pull to latest). Falls back to legacy mode only if no state file and no branches file. Closes #11506
1 parent f29e717 commit ce8d262

File tree

4 files changed

+459
-43
lines changed

4 files changed

+459
-43
lines changed

openlibrary/i18n/messages.pot

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,7 +2069,7 @@ msgstr ""
20692069
msgid "history"
20702070
msgstr ""
20712071

2072-
#: diff.html
2072+
#: diff.html status.html
20732073
msgid "Added"
20742074
msgstr ""
20752075

@@ -2437,6 +2437,64 @@ msgstr ""
24372437
msgid "Server status"
24382438
msgstr ""
24392439

2440+
#: status.html
2441+
msgid "Testing Environment"
2442+
msgstr ""
2443+
2444+
#: status.html
2445+
msgid "PR number or GitHub PR URL"
2446+
msgstr ""
2447+
2448+
#: status.html
2449+
msgid "Add PR"
2450+
msgstr ""
2451+
2452+
#: status.html
2453+
msgid "PR"
2454+
msgstr ""
2455+
2456+
#: books/add.html books/edit.html books/edit/edition.html lib/nav_head.html
2457+
#: search/advancedsearch.html status.html type/about/edit.html
2458+
#: type/page/edit.html type/template/edit.html
2459+
msgid "Title"
2460+
msgstr ""
2461+
2462+
#: status.html
2463+
msgid "Pinned Commit"
2464+
msgstr ""
2465+
2466+
#: status.html
2467+
msgid "Branch HEAD"
2468+
msgstr ""
2469+
2470+
#: status.html
2471+
msgid "Drift"
2472+
msgstr ""
2473+
2474+
#: status.html
2475+
msgid "Active"
2476+
msgstr ""
2477+
2478+
#: status.html
2479+
msgid "Pull to Latest"
2480+
msgstr ""
2481+
2482+
#: status.html
2483+
msgid "Toggle Active"
2484+
msgstr ""
2485+
2486+
#: lists/lists.html status.html type/list/edit.html type/series/edit.html
2487+
msgid "Remove"
2488+
msgstr ""
2489+
2490+
#: status.html
2491+
msgid "Force Rebuild"
2492+
msgstr ""
2493+
2494+
#: status.html
2495+
msgid "No PRs in testing set."
2496+
msgstr ""
2497+
24402498
#: status.html
24412499
msgid "Staged"
24422500
msgstr ""
@@ -4759,12 +4817,6 @@ msgid ""
47594817
"those fields."
47604818
msgstr ""
47614819

4762-
#: books/add.html books/edit.html books/edit/edition.html lib/nav_head.html
4763-
#: search/advancedsearch.html type/about/edit.html type/page/edit.html
4764-
#: type/template/edit.html
4765-
msgid "Title"
4766-
msgstr ""
4767-
47684820
#: books/add.html books/edit.html
47694821
msgid "Use <b><i>Title: Subtitle</i></b> to add a subtitle."
47704822
msgstr ""
@@ -6820,10 +6872,6 @@ msgstr ""
68206872
msgid "Remove this seed?"
68216873
msgstr ""
68226874

6823-
#: lists/lists.html type/list/edit.html type/series/edit.html
6824-
msgid "Remove"
6825-
msgstr ""
6826-
68276875
#: lists/lists.html
68286876
#, python-format
68296877
msgid "Are you sure you want to remove <strong>%(title)s</strong> from this list?"

openlibrary/plugins/openlibrary/status.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,151 @@
1+
import contextlib
12
import datetime
23
import functools
4+
import json
35
import re
46
import socket
57
import sys
8+
import urllib.error
9+
import urllib.request
610
from dataclasses import dataclass
711
from pathlib import Path
812
from typing import Any
913
from urllib.parse import urlencode
1014

15+
import web
16+
1117
from infogami import config
1218
from infogami.utils import delegate
1319
from infogami.utils.view import public, render_template
20+
from openlibrary.accounts import get_current_user
1421
from openlibrary.core import stats
1522
from openlibrary.utils import get_software_version
1623

1724
status_info: dict[str, Any] = {}
1825
feature_flagso: dict[str, Any] = {}
1926

27+
TESTING_STATE_FILE = Path('./_testing-prs.json')
28+
_GITHUB_API_BASE = "https://api.github.com/repos/internetarchive/openlibrary"
29+
_JENKINS_URL = "https://jenkins.openlibrary.org/job/testing-deploy/buildWithParameters"
30+
2031

2132
class status(delegate.page):
2233
def GET(self):
34+
testing_prs = _load_testing_state() # None if state file doesn't exist
35+
is_maintainer_user = _is_maintainer()
36+
drift_info = {}
37+
if testing_prs:
38+
# 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
2341
return render_template(
2442
"status",
2543
status_info,
2644
feature_flags,
2745
dev_merged_status=get_dev_merged_status(),
46+
testing_prs=testing_prs or [],
47+
drift_info=drift_info,
48+
is_maintainer=is_maintainer_user,
49+
show_testing=show_testing,
50+
)
51+
52+
53+
class status_add(delegate.page):
54+
path = '/status/add'
55+
56+
def POST(self):
57+
if not _is_maintainer():
58+
raise web.unauthorized()
59+
i = web.input(pr='')
60+
raw = re.split(r'[\s,]+', i.pr.strip())
61+
pr_numbers = []
62+
for val in raw:
63+
if val:
64+
with contextlib.suppress(ValueError, AttributeError):
65+
pr_numbers.append(_parse_pr_number(val))
66+
if not pr_numbers:
67+
raise web.badrequest()
68+
prs = _load_testing_state() or []
69+
existing = {p.pr for p in prs}
70+
user = get_current_user()
71+
for pr_number in pr_numbers:
72+
if pr_number not in existing:
73+
info = _get_pr_info(pr_number)
74+
prs.append(
75+
TestingPR(
76+
pr=pr_number,
77+
commit=info['head_sha'],
78+
active=True,
79+
title=info['title'],
80+
added_at=datetime.datetime.now(datetime.UTC).isoformat(),
81+
added_by=user.key.split('/')[-1] if user else '',
82+
)
83+
)
84+
existing.add(pr_number)
85+
_save_testing_state(prs)
86+
_trigger_rebuild()
87+
raise web.seeother('/status')
88+
89+
90+
class status_remove(delegate.page):
91+
path = '/status/remove'
92+
93+
def POST(self):
94+
if not _is_maintainer():
95+
raise web.unauthorized()
96+
i = web.input(prs=[])
97+
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]
28100
)
101+
_trigger_rebuild()
102+
raise web.seeother('/status')
103+
104+
105+
class status_toggle(delegate.page):
106+
path = '/status/toggle'
107+
108+
def POST(self):
109+
if not _is_maintainer():
110+
raise web.unauthorized()
111+
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()
119+
raise web.seeother('/status')
120+
121+
122+
class status_pull_latest(delegate.page):
123+
path = '/status/pull-latest'
124+
125+
def POST(self):
126+
if not _is_maintainer():
127+
raise web.unauthorized()
128+
i = web.input(prs=[])
129+
to_update = {int(p) for p in i.prs}
130+
prs = _load_testing_state() or []
131+
for p in prs:
132+
if p.pr in to_update:
133+
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()
138+
raise web.seeother('/status')
139+
140+
141+
class status_rebuild(delegate.page):
142+
path = '/status/rebuild'
143+
144+
def POST(self):
145+
if not _is_maintainer():
146+
raise web.unauthorized()
147+
_trigger_rebuild()
148+
raise web.seeother('/status')
29149

30150

31151
@functools.cache
@@ -99,6 +219,126 @@ def from_output(output: str) -> 'PRStatus':
99219
return PRStatus(pull_line=lines[0], status=lines[-1], body='\n'.join(lines[1:]))
100220

101221

222+
@dataclass
223+
class TestingPR:
224+
pr: int
225+
commit: str # pinned commit SHA (full)
226+
active: bool
227+
title: str
228+
added_at: str # ISO timestamp
229+
added_by: str # OL username
230+
231+
@property
232+
def short_commit(self) -> str:
233+
return self.commit[:7]
234+
235+
@property
236+
def added_date(self) -> str:
237+
return self.added_at[:10] if self.added_at else ''
238+
239+
def to_dict(self) -> dict:
240+
return {
241+
'pr': self.pr,
242+
'commit': self.commit,
243+
'active': self.active,
244+
'title': self.title,
245+
'added_at': self.added_at,
246+
'added_by': self.added_by,
247+
}
248+
249+
@classmethod
250+
def from_dict(cls, d: dict) -> 'TestingPR':
251+
return cls(
252+
pr=d['pr'],
253+
commit=d['commit'],
254+
active=d.get('active', True),
255+
title=d.get('title', f"PR #{d['pr']}"),
256+
added_at=d.get('added_at', ''),
257+
added_by=d.get('added_by', ''),
258+
)
259+
260+
261+
def _load_testing_state() -> 'list[TestingPR] | None':
262+
"""Returns list of TestingPRs if state file exists, None otherwise."""
263+
if TESTING_STATE_FILE.exists():
264+
return [
265+
TestingPR.from_dict(d) for d in json.loads(TESTING_STATE_FILE.read_text())
266+
]
267+
return None
268+
269+
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))
272+
get_dev_merged_status.cache_clear()
273+
274+
275+
def _is_maintainer() -> bool:
276+
user = get_current_user()
277+
return bool(
278+
user and user.is_member_of_any(['/usergroup/maintainers', '/usergroup/admin'])
279+
)
280+
281+
282+
def _github_get(path: str) -> dict:
283+
url = f"{_GITHUB_API_BASE}/{path}"
284+
req = urllib.request.Request(url, headers={'Accept': 'application/vnd.github+json'})
285+
with urllib.request.urlopen(req, timeout=5) as resp:
286+
return json.loads(resp.read())
287+
288+
289+
def _get_pr_info(pr_number: int) -> dict:
290+
"""Fetch title and current HEAD SHA for a PR from GitHub."""
291+
try:
292+
pr = _github_get(f"pulls/{pr_number}")
293+
return {
294+
'title': pr.get('title', f'PR #{pr_number}'),
295+
'head_sha': pr['head']['sha'],
296+
}
297+
except (urllib.error.URLError, KeyError, ValueError, json.JSONDecodeError):
298+
return {'title': f'PR #{pr_number}', 'head_sha': ''}
299+
300+
301+
def _get_pr_drift(pr: TestingPR) -> dict:
302+
"""Fetch live drift info for a PR in the testing state."""
303+
try:
304+
gh = _github_get(f"pulls/{pr.pr}")
305+
head_sha = gh['head']['sha']
306+
merged = gh.get('merged') or gh.get('state') == 'closed'
307+
if head_sha.startswith(pr.commit) or pr.commit.startswith(head_sha[:7]):
308+
drift = 0
309+
else:
310+
try:
311+
cmp = _github_get(f"compare/{pr.short_commit}...{head_sha[:7]}")
312+
drift = cmp.get('ahead_by', -1)
313+
except (urllib.error.URLError, ValueError, json.JSONDecodeError):
314+
drift = -1
315+
return {'head_sha': head_sha[:7], 'drift': drift, 'merged': merged}
316+
except (urllib.error.URLError, KeyError, ValueError, json.JSONDecodeError):
317+
return {'head_sha': '', 'drift': -1, 'merged': False}
318+
319+
320+
def _parse_pr_number(value: str) -> int:
321+
value = value.strip()
322+
if m := re.search(r'/pull/(\d+)', value):
323+
return int(m.group(1))
324+
return int(value.lstrip('#'))
325+
326+
327+
def _trigger_rebuild() -> bool:
328+
"""Call Jenkins to trigger a rebuild. No-op if jenkins_token is not configured."""
329+
token = getattr(config, 'jenkins_token', None)
330+
if not token:
331+
return False
332+
prs = _load_testing_state() or []
333+
lines = '\n'.join(f"origin pull/{p.pr}/head # {p.title}" for p in prs if p.active)
334+
url = f"{_JENKINS_URL}?{urlencode({'token': token, 'GH_REPO_AND_BRANCH': lines})}"
335+
try:
336+
urllib.request.urlopen(url, timeout=10)
337+
return True
338+
except (urllib.error.URLError, ValueError):
339+
return False
340+
341+
102342
@public
103343
def get_git_revision_short_hash():
104344
return (

0 commit comments

Comments
 (0)