Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<your-jenkins-api-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.
1 change: 1 addition & 0 deletions config.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ team:<org/team-slug>
token:<github-personal-access-token>
repos:<owner/repo1>,<owner/repo2>
jenkins-user:<jenkins-bot-username>
jenkins-token:<jenkins-api-token>
# optional
# db-path:/tmp/prtui.db
# poll-interval:120
Expand Down
81 changes: 61 additions & 20 deletions py/ghapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,14 +222,46 @@ 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.

Returns (mergeable, ci_url, head_sha, ci_sha) where ci_url/ci_sha are None
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()
Expand All @@ -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):
Expand All @@ -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


Expand All @@ -303,14 +342,16 @@ 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 = []
for pr in prs:
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:
Expand Down
92 changes: 92 additions & 0 deletions py/jenkins.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
etnt marked this conversation as resolved.
return False, f"HTTP error: {e}"
except Exception as e:
return False, f"Failed: {e}"
20 changes: 16 additions & 4 deletions py/prdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,"
Expand All @@ -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()]

Expand Down Expand Up @@ -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 = ?",
Expand Down
Loading