All checks were successful
Tests / Declarative: Post Actions passed: 1192
A multibranch project is a folder, not a job: its /api/json carries no lastBuild at all, only a jobs array with one child per branch. Detection read lastBuild, so hermes-code-demo-branches was returned as skipped on every tick since it was created and no branch could ever be triaged. Expand a folder into its branch jobs and process each. Jenkins addresses them as parent/job/branch, which the existing fetch already builds correctly, and branch names stay URL-encoded because re-encoding them yields a 404. Expansion is capped by ARIADNE_HERMES_MAX_BRANCHES (default 5): a repository with many active branches would otherwise multiply the watched job count without limit and could open one incident per failing branch in a single tick. A name containing a slash is refused outright, since it would escape the parent and address an unrelated job. The Jenkins transport moves to its own module so the orchestrator reads as decision logic, and jobs[name] rides along on the existing request rather than costing a second call per job per tick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
79 lines
2.6 KiB
Python
79 lines
2.6 KiB
Python
"""Read-only Jenkins access for the auto-triage tick.
|
|
|
|
Kept apart from the orchestrator so the HTTP concerns - credentials, the field
|
|
selector, and failure handling - stay in one place, and so the orchestrator
|
|
reads as decision logic rather than transport.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from ..settings import settings
|
|
from ..utils.logging import get_logger
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# `jobs[name]` is requested alongside the build fields so a multibranch folder,
|
|
# which has no lastBuild of its own, can be recognised from the same call
|
|
# rather than costing a second request per job per tick.
|
|
JOB_TREE = "lastBuild[number,result,building,timestamp,duration,url],jobs[name]"
|
|
|
|
|
|
def base_url() -> str:
|
|
"""Return the configured Jenkins base URL without a trailing slash."""
|
|
|
|
return settings.jenkins_base_url.strip().rstrip("/")
|
|
|
|
|
|
def client_kwargs() -> dict[str, Any]:
|
|
"""Build httpx client kwargs carrying Ariadne's Jenkins read credential."""
|
|
|
|
kwargs: dict[str, Any] = {
|
|
"timeout": settings.jenkins_api_timeout_sec,
|
|
"follow_redirects": True,
|
|
}
|
|
username = settings.jenkins_api_user.strip()
|
|
token = settings.jenkins_api_token.strip()
|
|
if username and token:
|
|
kwargs["auth"] = (username, token)
|
|
return kwargs
|
|
|
|
|
|
def fetch_job_payload(job: str) -> dict[str, Any] | None:
|
|
"""Fetch one job's build summary, or None when Jenkins cannot be read.
|
|
|
|
Inputs: a job name, which may be a nested path such as `parent/job/branch`
|
|
because Jenkins addresses branch jobs that way. Outputs: the parsed
|
|
payload, or None on any transport, status, or parse failure - a tick must
|
|
survive Jenkins being briefly unavailable.
|
|
"""
|
|
|
|
root = base_url()
|
|
if not root:
|
|
return None
|
|
try:
|
|
with httpx.Client(**client_kwargs()) as client:
|
|
response = client.get(f"{root}/job/{job}/api/json", params={"tree": JOB_TREE})
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
except Exception as exc:
|
|
logger.info(
|
|
"hermes autotriage jenkins fetch failed",
|
|
extra={"event": "hermes_autotriage", "status": "jenkins_error", "job": job, "detail": str(exc)},
|
|
)
|
|
return None
|
|
return payload if isinstance(payload, dict) else None
|
|
|
|
|
|
def last_build(payload: Any) -> dict[str, Any] | None:
|
|
"""Return the lastBuild object from a job payload, if it has one."""
|
|
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
build = payload.get("lastBuild")
|
|
return build if isinstance(build, dict) else None
|