From 61f6343d0d6c167df5e2443ce27d89ae7932ba77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B6rn=20T=C3=B6rnkvist?= Date: Thu, 14 May 2026 13:03:48 +0200 Subject: [PATCH 1/3] Add Jenkins job trigger and improve CI link handling - Add 'J' key binding to start a Jenkins PR pipeline build from the UI, with a confirmation dialog showing job name and branch details - New py/jenkins.py module reads credentials from ~/.ghmanager_tokens (same as ghmanager) and triggers builds via Jenkins buildWithParameters API - Auto-refresh PR after successful trigger to pick up new CI status - Query dashboard API (dashboard.tail-f.com) for the latest build URL when pressing 'b', ensuring running builds are shown immediately - Construct correct dashboard URL format with branch path and build ID - Update help screen, CSS, config template, and README --- README.md | 23 ++++++++++ py/ghapi.py | 66 ++++++++++++++++++++------- py/jenkins.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++ py/prtui.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++---- py/prtui.tcss | 21 +++++++++ 5 files changed, 321 insertions(+), 26 deletions(-) create mode 100644 py/jenkins.py diff --git a/README.md b/README.md index c096091..bcfe798 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,26 @@ regex configurations can be set in the config. Ticket: the PR title is scanned and matched against regex CI: The latest comment from the CI user is scanned and matched against regex + +## Jenkins Integration + +prtui can trigger Jenkins PR pipeline builds directly from the UI. + +### Setup + +Credentials are read from `~/.ghmanager_tokens` (the same file used by +ghmanager). It must contain at least: + +``` +username: +prjenkins: +``` + +The Jenkins username is derived from the `username` field (with `_cisco` +suffix stripped). + +### Usage + +Press `J` on any PR row to trigger a Jenkins build. A confirmation dialog +shows the job name and branch details before starting. After a successful +trigger the PR is automatically refreshed so the CI link updates. diff --git a/py/ghapi.py b/py/ghapi.py index 560e5a9..31ca0a2 100644 --- a/py/ghapi.py +++ b/py/ghapi.py @@ -222,6 +222,39 @@ def get_commits(pr_number, repo): return commits +_DASHBOARD = "http://dashboard.tail-f.com" + + +def _get_dashboard_ci_url(branch): + """Query the dashboard API for the latest build on the given branch. + + Returns the dashboard build URL for the most recent build (including + running ones), or None if unavailable. + """ + try: + branch_param = branch if branch.startswith("origin/") else f"origin/{branch}" + resp = requests.get( + f"{_DASHBOARD}/api/get-branch-data", + params={"branch": branch_param, "show_builds_int": 1, "arch": "linux-x86_64"}, + timeout=10, + ) + if resp.status_code != 200: + return None + builds = resp.json() + if builds and isinstance(builds, list) and builds[0].get("build"): + build_id = builds[0]["build"] + # URL format: /nso/linux-x86_64/origin:user:branch-name/build_id/ + branch_path = branch_param.replace("/", ":") + return f"{_DASHBOARD}/nso/linux-x86_64/{branch_path}/{build_id}/" + except Exception: + return None + return None + + +# Public alias for use by the UI layer. +get_dashboard_ci_url = _get_dashboard_ci_url + + def _get_pr_details(pr_number, repo): """Fetch mergeable status, CI URL, and SHAs for a PR in one API call sequence. @@ -229,7 +262,6 @@ def _get_pr_details(pr_number, repo): if Jenkins hasn't run on the current HEAD (caller should preserve any previously stored values). """ - import re data = requests.get(f"{API}/repos/{repo}/pulls/{pr_number}", headers=HEADERS) data.raise_for_status() data = data.json() @@ -242,30 +274,30 @@ def _get_pr_details(pr_number, repo): else: mergeable = data.get("mergeable_state") != "blocked" - # CI URL from commit statuses on the current HEAD - # Prefer a pending status (active run) over completed ones. + # CI URL — query the dashboard API for the latest build on this branch. + # Falls back to GitHub commit statuses if the dashboard is unavailable. ci_url = None ci_sha = None sha = data["head"]["sha"] - if _CI_URL_PATTERN: + head_ref = data["head"]["ref"] + + # Try dashboard API first — it always has the latest build, including running ones. + _dashboard_url = _get_dashboard_ci_url(head_ref) + if _dashboard_url: + ci_url = _dashboard_url + ci_sha = sha + elif _CI_URL_PATTERN: + import re statuses = requests.get( f"{API}/repos/{repo}/commits/{sha}/statuses", headers=HEADERS) statuses.raise_for_status() - pending_url = None - completed_url = None for s in statuses.json(): # newest first - match = re.search(_CI_URL_PATTERN, s.get("target_url", "")) + target = s.get("target_url", "") + match = re.search(_CI_URL_PATTERN, target) if match: - if s["state"] == "pending" and pending_url is None: - pending_url = match.group(0) - elif s["state"] != "pending" and completed_url is None: - completed_url = match.group(0) - if pending_url and completed_url: - break - chosen = pending_url or completed_url - if chosen: - ci_url = chosen - ci_sha = sha + ci_url = match.group(0) + ci_sha = sha + break return (mergeable, ci_url, sha, ci_sha, data.get("draft", False), data["head"]["ref"], data["base"]["ref"]) diff --git a/py/jenkins.py b/py/jenkins.py new file mode 100644 index 0000000..e62f834 --- /dev/null +++ b/py/jenkins.py @@ -0,0 +1,115 @@ +"""Jenkins integration — trigger PR pipeline builds.""" + +import base64 +import urllib.parse +from pathlib import Path +import requests +import config + +_cfg = config.read_config() +_JENKINS_URL = "https://scuti.lab.tail-f.com:8443" +_USERNAME = _cfg.get("username", "").strip() + + +def _read_jenkins_credentials(): + """Read Jenkins username and token from ~/.ghmanager_tokens.""" + token_path = Path.home() / ".ghmanager_tokens" + if not token_path.exists(): + return None, None + jenkins_user = None + jenkins_token = None + with open(token_path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split(":", 1) + if len(parts) != 2: + continue + key, value = parts[0].strip(), parts[1].strip() + if key == "username": + jenkins_user = value.removesuffix("_cisco") + if jenkins_user.endswith("-gen"): + jenkins_user = jenkins_user.replace("-gen", ".gen") + elif key == "prjenkins": + jenkins_token = value + return jenkins_user, jenkins_token + + +_JENKINS_USER, _JENKINS_TOKEN = _read_jenkins_credentials() + + +def is_configured(): + """Return True if Jenkins integration is fully configured.""" + return bool(_JENKINS_TOKEN and _JENKINS_USER) + + +def _get_auth_header(): + """Build HTTP Basic Auth header for Jenkins.""" + credentials = f"{_JENKINS_USER}:{_JENKINS_TOKEN}" + auth = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") + return {"Authorization": f"Basic {auth}", "X-Atlassian-Token": "no-check"} + + +def _get_jenkins_crumb(session): + """Fetch a Jenkins crumb (CSRF token) and return updated headers.""" + parsed = urllib.parse.urlparse(_JENKINS_URL) + crumb_url = urllib.parse.urlunparse( + (parsed.scheme, parsed.netloc, "/crumbIssuer/api/json", "", "", "") + ) + headers = _get_auth_header() + resp = session.get(crumb_url, headers=headers, timeout=15) + resp.raise_for_status() + data = resp.json() + headers[data["crumbRequestField"]] = data["crumb"] + return headers + + +def start_test(pr_number, repo, head_ref, base_ref, pr_url=None): + """Trigger a Jenkins PR pipeline for the given PR. + + Args: + pr_number: The PR number (int or str). + repo: The GitHub repo slug (e.g. "org/repo"). + head_ref: Source branch name. + base_ref: Target/base branch name. + pr_url: Full GitHub PR URL (optional, constructed if missing). + + Returns: + (success: bool, message: str) + """ + if not is_configured(): + return False, "Jenkins not configured (need ~/.ghmanager_tokens with prjenkins token)" + + if pr_url is None: + pr_url = f"https://github.com/{repo}/pull/{pr_number}" + + job_name = f"jjb_pr_start_linux-64_{base_ref}" + start_job_url = f"{_JENKINS_URL}/job/{job_name}/buildWithParameters" + + params = { + "FROM_REF": head_ref, + "STASH_BUTTON_TRIGGER_TITLE": "PRTUI", + "STASH_PULL_REQUEST_USER_NAME": _USERNAME, + "PIPE_DRYRUN": "NO", + "TAILF_ENV_VARS": "", + "STASH_PULL_REQUEST_ID": str(pr_number), + "STASH_PULL_REQUEST_URL": pr_url, + } + + try: + session = requests.Session() + headers = _get_jenkins_crumb(session) + encoded_params = urllib.parse.urlencode(params, quote_via=urllib.parse.quote) + resp = session.post(start_job_url, headers=headers, params=encoded_params, timeout=30) + + if resp.status_code == 201: + return True, f"Job {job_name} scheduled" + else: + return False, f"Jenkins returned {resp.status_code}: {resp.text[:200]}" + except requests.exceptions.ConnectionError: + return False, "Connection failed — check VPN connection" + except requests.exceptions.HTTPError as e: + return False, f"HTTP error: {e}" + except Exception as e: + return False, f"Failed: {e}" diff --git a/py/prtui.py b/py/prtui.py index cbf74d7..6e3efb7 100644 --- a/py/prtui.py +++ b/py/prtui.py @@ -16,6 +16,7 @@ import store import ghapi import config +import jenkins from navigation import NavigationMixin import comments import theme_listener @@ -116,6 +117,7 @@ def compose(self) -> ComposeResult: " r Mark PR as read\n" " b Open CI build in browser\n" " t Open linked ticket in browser\n" + " J Start Jenkins build\n" "\n" "[b]Columns[/b]\n" " [red]●[/red] / [dim]●[/dim] Unread / read\n" @@ -205,6 +207,60 @@ def action_dismiss(self) -> None: self.dismiss() +class JenkinsConfirmScreen(ModalScreen[bool]): + """Confirm starting a Jenkins build for a PR.""" + BINDINGS = [ + Binding("escape", "cancel", show=False), + Binding("enter", "confirm", show=False), + Binding("h", "next", show=False), + Binding("l", "next", show=False), + Binding("right", "next", show=False), + Binding("left", "next", show=False), + ] + + def __init__(self, pr_number, head_ref, base_ref): + super().__init__() + self._pr_number = pr_number + self._head_ref = head_ref + self._base_ref = base_ref + + def compose(self) -> ComposeResult: + job_name = f"jjb_pr_start_linux-64_{self._base_ref}" + yield Grid( + Label( + f"Start Jenkins build?\n\n" + f"PR: #{self._pr_number}\n" + f"From: {self._head_ref}\n" + f"To: {self._base_ref}\n" + f"Job: {job_name}", + id="jenkins-question"), + Button("Start", variant="warning", id="jenkins-start"), + Button("Cancel", variant="primary", id="jenkins-cancel"), + id="jenkins-dialog", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "jenkins-start": + self.dismiss(True) + else: + self.dismiss(False) + + def action_cancel(self) -> None: + self.dismiss(False) + + def action_confirm(self) -> None: + self.dismiss(True) + + def action_next(self): + panel = self.query_one("#jenkins-dialog", Grid) + buttons = list(panel.query(Button)) + node = self.focused + if isinstance(node, Button) and node in buttons: + idx = buttons.index(node) + target = buttons[(idx + 1) % len(buttons)] + target.focus() + + class GhMail(NavigationMixin, App): CSS_PATH = "prtui.tcss" @@ -219,6 +275,7 @@ class GhMail(NavigationMixin, App): Binding("t", "open_ticket", "Open Ticket"), Binding("c", "open_comments", "Open Comments"), Binding("u", "refresh_pr", "Refresh PR"), + Binding("J", "start_jenkins", "Start Jenkins"), Binding("question_mark", "help", "Help"), Binding("tab", "focus_next_table", "Next Table", show=True), Binding("shift+tab", "focus_prev_table", "Prev Table", show=True), @@ -560,18 +617,19 @@ def action_open_ci(self) -> None: if not key: return repo, number = key - url = store.get_ci_url(repo, int(number)) + table = self._focused_table() + pr = self.prs.get(table.id or "", [])[table.cursor_row] + # Try the dashboard API for the freshest build URL + head_ref = pr.get("head_ref") + url = None + if isinstance(head_ref, str) and head_ref: + url = ghapi.get_dashboard_ci_url(head_ref) + if not url: + url = store.get_ci_url(repo, int(number)) if not url: self.notify("No CI link found", severity="warning") return - table = self._focused_table() - pr = self.prs.get(table.id or "", [])[table.cursor_row] - # Warn if the CI ran on an older commit than the current HEAD - if pr.get("head_sha") and pr.get("ci_sha") and pr["head_sha"] != pr["ci_sha"]: - self.push_screen(CiWarningScreen(pr["head_sha"], pr["ci_sha"]), - callback=lambda _: webbrowser.open(url)) - else: - webbrowser.open(url) + webbrowser.open(url) def action_open_ticket(self) -> None: table = self._focused_table() @@ -606,6 +664,52 @@ def worker(): severity="error") threading.Thread(target=worker, daemon=True).start() + def action_start_jenkins(self) -> None: + if not jenkins.is_configured(): + self.notify("Jenkins not configured (need ~/.ghmanager_tokens)", + severity="warning") + return + table = self._focused_table() + if table.row_count == 0: + return + prs = self.prs.get(table.id or "", []) + pr = prs[table.cursor_row] + head_ref = pr.get("head_ref") + base_ref = pr.get("base_ref") + if not head_ref or not base_ref: + self.notify("Branch info missing — try refreshing the PR first", severity="warning") + return + self.push_screen( + JenkinsConfirmScreen(pr["number"], head_ref, base_ref), + callback=lambda confirmed: self._do_start_jenkins(confirmed, pr), + ) + + def _do_start_jenkins(self, confirmed, pr) -> None: + if not confirmed: + return + self.notify(f"Starting Jenkins for #{pr['number']}…") + def worker(): + success, msg = jenkins.start_test( + pr["number"], pr["repo"], pr["head_ref"], pr["base_ref"], + ) + severity = "information" if success else "error" + self.call_from_thread(self.notify, msg, severity=severity) + if success: + # Refresh the PR so the new pending CI status is picked up + import time + time.sleep(5) + try: + ghapi.refresh_pr(pr["repo"], int(pr["number"])) + self.prs = { + "prs": store.get_pull_requests("mine"), + "reviewer": store.get_pull_requests("reviewer"), + "requested": store.get_pull_requests("requested"), + } + self.call_from_thread(self._populate_tables, True) + except Exception: + pass + threading.Thread(target=worker, daemon=True).start() + def _handle_quit(self, confirmed: bool) -> None: if confirmed: self.exit() diff --git a/py/prtui.tcss b/py/prtui.tcss index 631f096..bfe197a 100644 --- a/py/prtui.tcss +++ b/py/prtui.tcss @@ -142,6 +142,27 @@ CiWarningScreen { content-align: center middle; } +/* Jenkins confirm dialog */ +JenkinsConfirmScreen { + align: center middle; +} +#jenkins-dialog { + grid-size: 2; + grid-gutter: 1 2; + grid-rows: 1fr 3; + padding: 0 1; + width: 60; + height: 14; + border: thick $background 80%; + background: $surface; +} +#jenkins-question { + column-span: 2; + height: 1fr; + width: 1fr; + content-align: center middle; +} + /* Quit dialog */ QuitScreen { align: center middle; From a943aa1e5bd976d4087fb2ac1b7e563ee74a5ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B6rn=20T=C3=B6rnkvist?= Date: Wed, 20 May 2026 09:01:23 +0200 Subject: [PATCH 2/3] Only getting tokens from the prtui config file --- README.md | 10 ++++------ config.tmpl | 1 + py/jenkins.py | 35 ++++++----------------------------- 3 files changed, 11 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index bcfe798..2b569c0 100644 --- a/README.md +++ b/README.md @@ -95,16 +95,14 @@ prtui can trigger Jenkins PR pipeline builds directly from the UI. ### Setup -Credentials are read from `~/.ghmanager_tokens` (the same file used by -ghmanager). It must contain at least: +Add your Jenkins API token to the prtui `config` file: ``` -username: -prjenkins: +jenkins-token: ``` -The Jenkins username is derived from the `username` field (with `_cisco` -suffix stripped). +The Jenkins username is derived automatically from the `username` field +(with `_cisco` suffix stripped). ### Usage diff --git a/config.tmpl b/config.tmpl index 630a12a..60f4335 100644 --- a/config.tmpl +++ b/config.tmpl @@ -3,6 +3,7 @@ team: token: repos:, jenkins-user: +jenkins-token: # optional # db-path:/tmp/prtui.db # poll-interval:120 diff --git a/py/jenkins.py b/py/jenkins.py index e62f834..c5d3661 100644 --- a/py/jenkins.py +++ b/py/jenkins.py @@ -2,41 +2,18 @@ import base64 import urllib.parse -from pathlib import Path import requests import config _cfg = config.read_config() _JENKINS_URL = "https://scuti.lab.tail-f.com:8443" _USERNAME = _cfg.get("username", "").strip() +_JENKINS_TOKEN = _cfg.get("jenkins-token", "").strip() - -def _read_jenkins_credentials(): - """Read Jenkins username and token from ~/.ghmanager_tokens.""" - token_path = Path.home() / ".ghmanager_tokens" - if not token_path.exists(): - return None, None - jenkins_user = None - jenkins_token = None - with open(token_path) as f: - for line in f: - line = line.strip() - if not line or line.startswith("#"): - continue - parts = line.split(":", 1) - if len(parts) != 2: - continue - key, value = parts[0].strip(), parts[1].strip() - if key == "username": - jenkins_user = value.removesuffix("_cisco") - if jenkins_user.endswith("-gen"): - jenkins_user = jenkins_user.replace("-gen", ".gen") - elif key == "prjenkins": - jenkins_token = value - return jenkins_user, jenkins_token - - -_JENKINS_USER, _JENKINS_TOKEN = _read_jenkins_credentials() +# Derive the Jenkins username from the GitHub username +_JENKINS_USER = _USERNAME.removesuffix("_cisco") +if _JENKINS_USER.endswith("-gen"): + _JENKINS_USER = _JENKINS_USER.replace("-gen", ".gen") def is_configured(): @@ -79,7 +56,7 @@ def start_test(pr_number, repo, head_ref, base_ref, pr_url=None): (success: bool, message: str) """ if not is_configured(): - return False, "Jenkins not configured (need ~/.ghmanager_tokens with prjenkins token)" + return False, "Jenkins not configured (set jenkins-token in config)" if pr_url is None: pr_url = f"https://github.com/{repo}/pull/{pr_number}" From b3299d6a26f6205c99b7e0953986dc2e3b4de3c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torbj=C3=B6rn=20T=C3=B6rnkvist?= Date: Wed, 20 May 2026 09:40:51 +0200 Subject: [PATCH 3/3] Add CI state indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CI state column tracking: fetch combined commit status from GitHub to show ✓ (passed), ✗ (failed), or ◆ (running) in the CI column - Add ci_state column to DB with migration for existing databases - Re-fetch PRs with missing ci_state on poll to backfill existing rows - Fix crumb URL path (add leading slash) - Guard against None head_ref when opening CI links --- py/ghapi.py | 15 ++++++++++++--- py/prdb.py | 20 ++++++++++++++++---- py/prtui.py | 10 +++++++++- py/store.py | 1 + 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/py/ghapi.py b/py/ghapi.py index 31ca0a2..aebb4ba 100644 --- a/py/ghapi.py +++ b/py/ghapi.py @@ -278,6 +278,7 @@ def _get_pr_details(pr_number, repo): # Falls back to GitHub commit statuses if the dashboard is unavailable. ci_url = None ci_sha = None + ci_state = None sha = data["head"]["sha"] head_ref = data["head"]["ref"] @@ -299,8 +300,14 @@ def _get_pr_details(pr_number, repo): ci_sha = sha break + # Fetch combined commit status to determine CI state (pending/success/failure). + combined = requests.get( + f"{API}/repos/{repo}/commits/{sha}/status", headers=HEADERS) + if combined.status_code == 200: + ci_state = combined.json().get("state") # "pending", "success", "failure", "error" + return (mergeable, ci_url, sha, ci_sha, data.get("draft", False), - data["head"]["ref"], data["base"]["ref"]) + data["head"]["ref"], data["base"]["ref"], ci_state) def _fetch_pr_details(pr): @@ -312,7 +319,7 @@ def _fetch_pr_details(pr): pr["approvals"] = ",".join(approvers) (pr["mergeable"], pr["ci_url"], pr["head_sha"], pr["ci_sha"], pr["draft"], - pr["head_ref"], pr["base_ref"]) = _get_pr_details(pr["number"], pr["repo"]) + pr["head_ref"], pr["base_ref"], pr["ci_state"]) = _get_pr_details(pr["number"], pr["repo"]) return pr, comments @@ -335,6 +342,8 @@ def progress(msg): prdb.create_pr_table(cursor) prdb.create_comments_table(cursor) old = prdb.pr_get_updated_at(cursor) + # PRs missing ci_state need re-fetching regardless of updated_at + missing_ci = prdb.pr_get_missing_ci_state(cursor) current_keys = set() changed = [] @@ -342,7 +351,7 @@ def progress(msg): key = (pr["repo"], pr["number"]) current_keys.add(key) old_ts = old.get(key) - if old_ts is None or pr["updated_at"] > old_ts: + if old_ts is None or pr["updated_at"] > old_ts or key in missing_ci: changed.append(pr) if _CUSTOM_QUERY: diff --git a/py/prdb.py b/py/prdb.py index eb314e5..9128d7b 100644 --- a/py/prdb.py +++ b/py/prdb.py @@ -88,12 +88,17 @@ def create_pr_table(cursor): cursor.execute("ALTER TABLE PRS ADD COLUMN base_ref TEXT") except Exception: pass + # Migrate existing DBs that predate the ci_state column. + try: + cursor.execute("ALTER TABLE PRS ADD COLUMN ci_state TEXT") + except Exception: + pass def pr_insert(cursor, pr): read_at = pr["updated_at"] if pr["type"] == "mine" else None cursor.execute( - "INSERT INTO PRS (number, repo, type, author, title, updated_at, read_at, approvals, mergeable, ci_url, head_sha, ci_sha, draft, head_ref, base_ref)" - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO PRS (number, repo, type, author, title, updated_at, read_at, approvals, mergeable, ci_url, head_sha, ci_sha, draft, head_ref, base_ref, ci_state)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" " ON CONFLICT(repo, number) DO UPDATE SET" " type=excluded.type, author=excluded.author," " title=excluded.title, updated_at=excluded.updated_at," @@ -104,17 +109,19 @@ def pr_insert(cursor, pr): " draft=excluded.draft," " head_ref=excluded.head_ref," " base_ref=excluded.base_ref," + " ci_state=COALESCE(excluded.ci_state, ci_state)," " read_at=COALESCE(read_at, excluded.read_at)", (pr["number"], pr["repo"], pr["type"], pr["author"], pr["title"], pr["updated_at"], read_at, pr.get("approvals", ""), pr.get("mergeable"), pr.get("ci_url"), pr.get("head_sha"), pr.get("ci_sha"), - int(bool(pr.get("draft"))), pr.get("head_ref"), pr.get("base_ref")) + int(bool(pr.get("draft"))), pr.get("head_ref"), pr.get("base_ref"), + pr.get("ci_state")) ) def pr_get_all(cursor, type): cursor.execute( "SELECT number, repo, type, author, title, updated_at, read_at," - " approvals, mergeable, ci_url, head_sha, ci_sha, draft, head_ref, base_ref FROM PRS WHERE type=?", (type,) + " approvals, mergeable, ci_url, head_sha, ci_sha, draft, head_ref, base_ref, ci_state FROM PRS WHERE type=?", (type,) ) return [dict(r) for r in cursor.fetchall()] @@ -151,6 +158,11 @@ def pr_get_updated_at(cursor): cursor.execute("SELECT repo, number, updated_at FROM PRS") return {(r["repo"], r["number"]): r["updated_at"] for r in cursor.fetchall()} +def pr_get_missing_ci_state(cursor): + """Return set of (repo, number) for PRs with NULL ci_state.""" + cursor.execute("SELECT repo, number FROM PRS WHERE ci_state IS NULL") + return {(r["repo"], r["number"]) for r in cursor.fetchall()} + def pr_delete(cursor, repo, number): """Delete a PR and its comments.""" cursor.execute("DELETE FROM COMMENTS WHERE pr_repo = ? AND pr_number = ?", diff --git a/py/prtui.py b/py/prtui.py index 6e3efb7..cfaccea 100644 --- a/py/prtui.py +++ b/py/prtui.py @@ -471,7 +471,15 @@ def _populate_tables(self, preserve_focus=False) -> None: table.zebra_stripes = True table.add_columns("", "#", "Repo", "Title", "Author", "App", "CI", "Mrg", "Rdy") for pr in prs: - ci = "✓" if pr["jenkins_approved"] else "" + ci_state = pr.get("ci_state") or "" + if pr["jenkins_approved"]: + ci = "✓" + elif ci_state in ("failure", "error"): + ci = "✗" + elif ci_state == "pending": + ci = "◆" + else: + ci = "" approvals = str(pr["approval_count"]) if pr["approval_count"] else "" if pr.get("my_approved"): approvals = f"✓ {approvals}".strip() diff --git a/py/store.py b/py/store.py index 001059c..6ec08d4 100644 --- a/py/store.py +++ b/py/store.py @@ -42,6 +42,7 @@ def get_pull_requests(type): "approval_count": len(others), "jenkins_approved": bool(jenkins), "my_approved": USER in others, + "ci_state": pr.get("ci_state"), }) return prs