diff --git a/README.md b/README.md index c096091..2b569c0 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,24 @@ 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 + +Add your Jenkins API token to the prtui `config` file: + +``` +jenkins-token: +``` + +The Jenkins username is derived automatically 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/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/ghapi.py b/py/ghapi.py index 560e5a9..aebb4ba 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,33 +274,40 @@ 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 + ci_state = 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 + + # 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): @@ -280,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 @@ -303,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 = [] @@ -310,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/jenkins.py b/py/jenkins.py new file mode 100644 index 0000000..c5d3661 --- /dev/null +++ b/py/jenkins.py @@ -0,0 +1,92 @@ +"""Jenkins integration — trigger PR pipeline builds.""" + +import base64 +import urllib.parse +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() + +# 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(): + """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 (set jenkins-token in config)" + + 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/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 cbf74d7..cfaccea 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), @@ -414,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() @@ -560,18 +625,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 +672,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; 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