✨ feat(gitea-fix-ci): add authenticated log collector
This commit is contained in:
@@ -0,0 +1,699 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect Gitea Actions CI evidence: version, runs, jobs, and job logs.
|
||||
|
||||
This is an evidence-collection helper for the gitea-fix-ci skill. It automates
|
||||
the programmatic parts of the skill's Procedure (probe version, list failing
|
||||
runs, list jobs, download a failing job's log) so the agent does not hand-build
|
||||
API paths or blindly pick job index 0. It stops at evidence: classification,
|
||||
fix plans, and code edits stay with the agent.
|
||||
|
||||
Scope decisions (see SKILL.md):
|
||||
- Evidence only. No classification, no fix, no edits.
|
||||
- Gitea Actions REST API only. No legacy web-scraping fallback; when the API is
|
||||
absent the tool points the agent back to SKILL.md's manual/web path.
|
||||
- Auth via GITEA_TOKEN (preferred) or an explicitly requested `git credential`
|
||||
lookup; base URL/owner/repo are derived from `git remote -v`. Secrets are
|
||||
never placed on argv, logs, or exception text.
|
||||
|
||||
Zero third-party dependencies (stdlib urllib only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
TOKEN_ENV = "GITEA_TOKEN"
|
||||
DEFAULT_REMOTE = "origin"
|
||||
DEFAULT_LOG_TAIL = 40
|
||||
REQUEST_TIMEOUT = 30 # seconds; a hung Gitea/proxy must not block the session
|
||||
CREDENTIAL_TIMEOUT = 10 # seconds; helpers must not block on an interactive UI
|
||||
USER_AGENT = "gitea-fix-ci/fetch_ci_logs"
|
||||
|
||||
# https://host/owner/repo(.git) or git@host:owner/repo(.git) or
|
||||
# ssh://git@host[:port]/owner/repo(.git)
|
||||
_HTTP_REMOTE_RE = re.compile(
|
||||
r"^(?P<scheme>https?)://(?:[^@/]+@)?(?P<host>[^/]+)/"
|
||||
r"(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
|
||||
)
|
||||
_SCP_REMOTE_RE = re.compile(
|
||||
r"^(?:ssh://)?(?:[^@]+@)?(?P<host>[^:/]+)(?::\d+)?[:/]"
|
||||
r"(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Missing or malformed configuration (remote, token, arguments)."""
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
"""The Gitea API returned an error or unexpected payload."""
|
||||
|
||||
|
||||
def _eprint(*args: object) -> None:
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
def _origin(url: str) -> tuple[str, str, int]:
|
||||
"""Return a normalized (scheme, hostname, effective port) tuple."""
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
hostname = parsed.hostname
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
raise ConfigError("invalid Gitea URL") from None
|
||||
|
||||
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
|
||||
raise ConfigError("Gitea URL must use http:// or https://")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ConfigError("Gitea URL must not contain embedded credentials")
|
||||
if not hostname:
|
||||
raise ConfigError("Gitea URL must include a host")
|
||||
|
||||
scheme = parsed.scheme.lower()
|
||||
effective_port = port if port is not None else (443 if scheme == "https" else 80)
|
||||
return scheme, hostname.rstrip(".").lower(), effective_port
|
||||
|
||||
|
||||
def _normalize_base_url(value: str) -> str:
|
||||
"""Validate and normalize the configured API base URL."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ConfigError("Gitea base URL is required")
|
||||
value = value.strip().rstrip("/")
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
except ValueError:
|
||||
raise ConfigError("invalid Gitea base URL") from None
|
||||
if parsed.query or parsed.fragment:
|
||||
raise ConfigError("Gitea base URL must not contain a query or fragment")
|
||||
_origin(value)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RepoTarget:
|
||||
base_url: str # e.g. https://git.example.com
|
||||
owner: str
|
||||
repo: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "base_url", _normalize_base_url(self.base_url))
|
||||
if not self.owner or not self.repo:
|
||||
raise ConfigError("Gitea owner and repository are required")
|
||||
|
||||
@property
|
||||
def api_root(self) -> str:
|
||||
return f"{self.base_url}/api/v1"
|
||||
|
||||
def repo_path(self, suffix: str) -> str:
|
||||
owner = urllib.parse.quote(self.owner, safe="")
|
||||
repo = urllib.parse.quote(self.repo, safe="")
|
||||
return f"{self.api_root}/repos/{owner}/{repo}{suffix}"
|
||||
|
||||
@property
|
||||
def origin(self) -> tuple[str, str, int]:
|
||||
return _origin(self.base_url)
|
||||
|
||||
@property
|
||||
def credential_host(self) -> str:
|
||||
parsed = urllib.parse.urlsplit(self.base_url)
|
||||
hostname = parsed.hostname or ""
|
||||
if ":" in hostname and not hostname.startswith("["):
|
||||
hostname = f"[{hostname}]"
|
||||
if parsed.port is not None:
|
||||
hostname = f"{hostname}:{parsed.port}"
|
||||
return hostname
|
||||
|
||||
@property
|
||||
def credential_path(self) -> str:
|
||||
base_path = urllib.parse.urlsplit(self.base_url).path.strip("/")
|
||||
owner = urllib.parse.quote(self.owner, safe="")
|
||||
repo = urllib.parse.quote(self.repo, safe="")
|
||||
repository_path = f"{owner}/{repo}.git"
|
||||
return f"{base_path}/{repository_path}" if base_path else repository_path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiAuth:
|
||||
"""An authorization header whose secret is deliberately absent from repr."""
|
||||
|
||||
source: str
|
||||
authorization: str = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
scheme, separator, credential = self.authorization.partition(" ")
|
||||
has_control = any(
|
||||
ord(character) < 32 or ord(character) == 127
|
||||
for character in self.authorization
|
||||
)
|
||||
try:
|
||||
self.authorization.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
raise ConfigError("invalid authorization value") from None
|
||||
if (
|
||||
not self.source
|
||||
or not separator
|
||||
or not scheme
|
||||
or not credential
|
||||
or has_control
|
||||
):
|
||||
raise ConfigError("invalid authorization value")
|
||||
|
||||
|
||||
def _require_https(target: RepoTarget) -> None:
|
||||
if target.origin[0] != "https":
|
||||
raise ConfigError("credentials require an HTTPS Gitea base URL")
|
||||
|
||||
|
||||
def _run_git(args: list[str]) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ConfigError(f"cannot run git {' '.join(args)}: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip()
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise ConfigError(f"git {' '.join(args)} failed{suffix}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _remote_url(remote: str) -> str:
|
||||
url = _run_git(["remote", "get-url", remote])
|
||||
if not url:
|
||||
raise ConfigError(f"remote '{remote}' has no URL")
|
||||
return url
|
||||
|
||||
|
||||
def parse_remote_url(url: str) -> tuple[str, str, str]:
|
||||
"""Return (base_url, owner, repo) parsed from a git remote URL.
|
||||
|
||||
Only http(s) remotes yield a usable API base URL. SSH remotes give host and
|
||||
path but no scheme, so we assume https for the API base.
|
||||
"""
|
||||
http_match = _HTTP_REMOTE_RE.match(url)
|
||||
if http_match:
|
||||
base = f"{http_match.group('scheme')}://{http_match.group('host')}"
|
||||
return base, http_match.group("owner"), http_match.group("repo")
|
||||
|
||||
scp_match = _SCP_REMOTE_RE.match(url)
|
||||
if scp_match:
|
||||
# No scheme in an SSH remote; the API is reached over https by default.
|
||||
base = f"https://{scp_match.group('host')}"
|
||||
return base, scp_match.group("owner"), scp_match.group("repo")
|
||||
|
||||
raise ConfigError(
|
||||
"cannot parse owner/repo from the configured git remote. Gitea remotes "
|
||||
"are expected as <host>/<owner>/<repo>; for nested or "
|
||||
"non-standard paths, pass --base-url/--owner/--repo explicitly."
|
||||
)
|
||||
|
||||
|
||||
def resolve_target(args: argparse.Namespace) -> RepoTarget:
|
||||
base_url = args.base_url
|
||||
owner = args.owner
|
||||
repo = args.repo
|
||||
if not (base_url and owner and repo):
|
||||
url = _remote_url(args.remote)
|
||||
parsed_base, parsed_owner, parsed_repo = parse_remote_url(url)
|
||||
base_url = base_url or parsed_base
|
||||
owner = owner or parsed_owner
|
||||
repo = repo or parsed_repo
|
||||
return RepoTarget(base_url=base_url, owner=owner, repo=repo)
|
||||
|
||||
|
||||
def _token() -> str | None:
|
||||
token = os.getenv(TOKEN_ENV)
|
||||
if token and token.strip():
|
||||
return token.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _git_credential_auth(target: RepoTarget) -> ApiAuth:
|
||||
"""Resolve repository-scoped HTTP Basic credentials without prompting."""
|
||||
_require_https(target)
|
||||
credential_query = (
|
||||
"protocol=https\n"
|
||||
f"host={target.credential_host}\n"
|
||||
f"path={target.credential_path}\n\n"
|
||||
)
|
||||
helper_env = os.environ.copy()
|
||||
helper_env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
helper_env["GCM_INTERACTIVE"] = "Never"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "credential", "fill"],
|
||||
input=credential_query,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=CREDENTIAL_TIMEOUT,
|
||||
env=helper_env,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ConfigError("git credential lookup timed out") from None
|
||||
except OSError:
|
||||
raise ConfigError("cannot run git credential lookup") from None
|
||||
|
||||
if result.returncode != 0:
|
||||
# stdout/stderr may contain credentials or helper-specific secret data.
|
||||
raise ConfigError("git credential lookup failed or requires interaction")
|
||||
|
||||
fields: dict[str, str] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
key, separator, value = line.partition("=")
|
||||
if separator:
|
||||
fields[key] = value
|
||||
|
||||
username = fields.get("username", "")
|
||||
password = fields.get("password", "")
|
||||
if not username or not password or ":" in username:
|
||||
raise ConfigError("git credential lookup returned unusable credentials")
|
||||
|
||||
encoded = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
||||
return ApiAuth(source="git credential", authorization=f"Basic {encoded}")
|
||||
|
||||
|
||||
def resolve_auth(args: argparse.Namespace, target: RepoTarget) -> ApiAuth | None:
|
||||
"""Resolve authentication once, with environment tokens taking precedence."""
|
||||
token = _token()
|
||||
if token:
|
||||
_require_https(target)
|
||||
return ApiAuth(source=TOKEN_ENV, authorization=f"token {token}")
|
||||
if getattr(args, "use_git_credential", False):
|
||||
return _git_credential_auth(target)
|
||||
return None
|
||||
|
||||
|
||||
class SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Follow redirects only while they stay on the configured Gitea origin."""
|
||||
|
||||
def __init__(self, target: RepoTarget) -> None:
|
||||
super().__init__()
|
||||
self._origin = target.origin
|
||||
|
||||
def redirect_request(
|
||||
self,
|
||||
req: urllib.request.Request,
|
||||
fp: Any,
|
||||
code: int,
|
||||
msg: str,
|
||||
headers: Any,
|
||||
newurl: str,
|
||||
) -> urllib.request.Request | None:
|
||||
absolute_url = urllib.parse.urljoin(req.full_url, newurl)
|
||||
try:
|
||||
redirect_origin = _origin(absolute_url)
|
||||
except ConfigError:
|
||||
raise ConfigError("refusing an invalid Gitea redirect") from None
|
||||
if redirect_origin != self._origin:
|
||||
raise ConfigError("refusing a redirect outside the configured origin")
|
||||
return super().redirect_request(req, fp, code, msg, headers, absolute_url)
|
||||
|
||||
|
||||
class ApiClient:
|
||||
"""Small, origin-bound client for read-only Gitea API requests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: RepoTarget,
|
||||
*,
|
||||
auth: ApiAuth | None = None,
|
||||
opener: Any | None = None,
|
||||
) -> None:
|
||||
if auth:
|
||||
_require_https(target)
|
||||
self.target = target
|
||||
self.auth = auth
|
||||
self._opener = opener or urllib.request.build_opener(
|
||||
SameOriginRedirectHandler(target)
|
||||
)
|
||||
|
||||
def request(self, url: str, *, accept_json: bool = True) -> tuple[int, bytes, str]:
|
||||
"""Perform an origin-bound GET and return status, body, content type."""
|
||||
try:
|
||||
request_origin = _origin(url)
|
||||
except ConfigError:
|
||||
raise ConfigError("refusing an invalid Gitea API URL") from None
|
||||
if request_origin != self.target.origin:
|
||||
raise ConfigError("refusing a request outside the configured origin")
|
||||
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if accept_json:
|
||||
headers["Accept"] = "application/json"
|
||||
if self.auth:
|
||||
headers["Authorization"] = self.auth.authorization
|
||||
|
||||
request = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with self._opener.open(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
body = response.read()
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
return response.status, body, content_type
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read() if hasattr(exc, "read") else b""
|
||||
content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
|
||||
return exc.code, body, content_type
|
||||
except urllib.error.URLError as exc:
|
||||
detail = str(exc.reason)
|
||||
if self.auth:
|
||||
authorization = self.auth.authorization
|
||||
secret = authorization.partition(" ")[2]
|
||||
detail = detail.replace(authorization, "<redacted>")
|
||||
if secret:
|
||||
detail = detail.replace(secret, "<redacted>")
|
||||
raise ApiError(f"request to Gitea failed: {detail}") from None
|
||||
|
||||
def get_json(self, url: str) -> Any:
|
||||
status, body, _ = self.request(url, accept_json=True)
|
||||
if status != 200:
|
||||
raise ApiError(_explain_status(status, self.target, self.auth))
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ApiError(f"cannot parse JSON from {url}: {exc}") from None
|
||||
|
||||
|
||||
def _explain_status(
|
||||
status: int, target: RepoTarget, auth: ApiAuth | None = None
|
||||
) -> str:
|
||||
if status == 401:
|
||||
if auth is None:
|
||||
return (
|
||||
f"authentication required (HTTP 401). Set {TOKEN_ENV} or rerun "
|
||||
"with --use-git-credential; do not paste credentials into chat."
|
||||
)
|
||||
return (
|
||||
f"authentication rejected (HTTP 401) for {auth.source}. Check that "
|
||||
"the credential is valid for this Gitea instance and repository."
|
||||
)
|
||||
if status == 403:
|
||||
if auth is None:
|
||||
return (
|
||||
f"permission denied (HTTP 403) without authentication. Set "
|
||||
f"{TOKEN_ENV} or rerun with --use-git-credential."
|
||||
)
|
||||
return (
|
||||
f"permission denied (HTTP 403) using {auth.source}. Grant repository "
|
||||
"and Actions read permission to the selected credential."
|
||||
)
|
||||
if status == 404:
|
||||
return (
|
||||
"endpoint not found (HTTP 404). Check the repository coordinates and "
|
||||
"Gitea version; older Gitea (1.21 and earlier) lacks the Actions "
|
||||
"run/job/log API. Fall back to the web UI or pasted logs per SKILL.md."
|
||||
)
|
||||
return f"unexpected HTTP {status} from {target.base_url}"
|
||||
|
||||
|
||||
def cmd_version(
|
||||
args: argparse.Namespace,
|
||||
target: RepoTarget,
|
||||
client: ApiClient,
|
||||
) -> int:
|
||||
payload = client.get_json(f"{target.api_root}/version")
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
if not version:
|
||||
raise ApiError("version endpoint returned no 'version' field")
|
||||
if args.json:
|
||||
print(json.dumps({"version": version}, ensure_ascii=False))
|
||||
else:
|
||||
print(f"gitea version: {version}")
|
||||
print(f"instance: {target.base_url}")
|
||||
return 0
|
||||
|
||||
|
||||
def _as_list(payload: Any, key: str) -> list[dict[str, Any]]:
|
||||
"""Gitea may wrap collections in an object or return a bare list."""
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
if isinstance(payload, dict):
|
||||
inner = payload.get(key)
|
||||
if isinstance(inner, list):
|
||||
return [item for item in inner if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _outcome(item: dict[str, Any]) -> str:
|
||||
"""Resolve the effective result of a run/job.
|
||||
|
||||
Gitea's Actions objects follow the GitHub-compatible two-field model: a
|
||||
lifecycle `status` (queued/in_progress/completed) plus a terminal
|
||||
`conclusion` (success/failure/cancelled/...). A completed job reports
|
||||
`status=completed, conclusion=failure`, so a naive `status or conclusion`
|
||||
would stop at "completed" and miss the failure. Prefer `conclusion`; fall
|
||||
back to `status` only when there is no conclusion yet.
|
||||
"""
|
||||
conclusion = (item.get("conclusion") or "").strip()
|
||||
status = (item.get("status") or "").strip()
|
||||
return (conclusion or status).lower()
|
||||
|
||||
|
||||
def _run_row(run: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": run.get("id"),
|
||||
"name": run.get("name") or run.get("workflow_id") or "",
|
||||
"outcome": _outcome(run),
|
||||
"event": run.get("event") or "",
|
||||
"branch": run.get("head_branch") or run.get("branch") or "",
|
||||
"sha": (run.get("head_sha") or run.get("commit_sha") or "")[:12],
|
||||
"url": run.get("html_url") or run.get("url") or "",
|
||||
}
|
||||
|
||||
|
||||
def cmd_runs(
|
||||
args: argparse.Namespace,
|
||||
target: RepoTarget,
|
||||
client: ApiClient,
|
||||
) -> int:
|
||||
query: dict[str, str] = {}
|
||||
if args.branch:
|
||||
query["branch"] = args.branch
|
||||
if args.event:
|
||||
query["event"] = args.event
|
||||
if args.status:
|
||||
query["status"] = args.status
|
||||
if args.sha:
|
||||
query["head_sha"] = args.sha
|
||||
if args.limit:
|
||||
query["limit"] = str(args.limit)
|
||||
suffix = "/actions/runs"
|
||||
if query:
|
||||
suffix += "?" + urllib.parse.urlencode(query)
|
||||
payload = client.get_json(target.repo_path(suffix))
|
||||
runs = [_run_row(run) for run in _as_list(payload, "workflow_runs")]
|
||||
if args.sha:
|
||||
runs = [row for row in runs if row["sha"].startswith(args.sha[:12])]
|
||||
runs = runs[: args.limit] if args.limit else runs
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(runs, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if not runs:
|
||||
print("no matching workflow runs")
|
||||
return 1
|
||||
print(f"{len(runs)} run(s):")
|
||||
for row in runs:
|
||||
print(
|
||||
f" run {row['id']} [{row['outcome']}] {row['name']} "
|
||||
f"{row['event']} {row['branch']} {row['sha']}"
|
||||
)
|
||||
if row["url"]:
|
||||
print(f" {row['url']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _job_row(job: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"index": index,
|
||||
"id": job.get("id"),
|
||||
"name": job.get("name") or "",
|
||||
"outcome": _outcome(job),
|
||||
}
|
||||
|
||||
|
||||
def _is_failed(outcome: str) -> bool:
|
||||
return outcome.lower() in {"failure", "failed", "error"}
|
||||
|
||||
|
||||
def cmd_jobs(
|
||||
args: argparse.Namespace,
|
||||
target: RepoTarget,
|
||||
client: ApiClient,
|
||||
) -> int:
|
||||
payload = client.get_json(target.repo_path(f"/actions/runs/{args.run_id}/jobs"))
|
||||
jobs = [_job_row(job, index) for index, job in enumerate(_as_list(payload, "jobs"))]
|
||||
failed = [job for job in jobs if _is_failed(job["outcome"])]
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{"jobs": jobs, "failed_job_ids": [j["id"] for j in failed]},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
if not jobs:
|
||||
print(f"run {args.run_id} has no jobs (or endpoint unavailable)")
|
||||
return 1
|
||||
print(f"run {args.run_id} jobs:")
|
||||
for job in jobs:
|
||||
marker = " <== FAILED" if _is_failed(job["outcome"]) else ""
|
||||
print(
|
||||
f" job {job['id']} index={job['index']} "
|
||||
f"[{job['outcome']}] {job['name']}{marker}"
|
||||
)
|
||||
if failed:
|
||||
ids = ", ".join(str(job["id"]) for job in failed)
|
||||
print(f"failed job id(s): {ids}")
|
||||
print("fetch a failed job's log with: logs <job-id>")
|
||||
else:
|
||||
print("no failed jobs on this run")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_logs(
|
||||
args: argparse.Namespace,
|
||||
target: RepoTarget,
|
||||
client: ApiClient,
|
||||
) -> int:
|
||||
status, body, _ = client.request(
|
||||
target.repo_path(f"/actions/jobs/{args.job_id}/logs"),
|
||||
accept_json=False,
|
||||
)
|
||||
if status != 200:
|
||||
raise ApiError(_explain_status(status, target, client.auth))
|
||||
|
||||
text = body.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
|
||||
if args.out:
|
||||
out_path = Path(args.out).expanduser()
|
||||
out_path.write_text(text, encoding="utf-8")
|
||||
else:
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
prefix=f"gitea-job-{args.job_id}-",
|
||||
suffix=".log",
|
||||
delete=False,
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
)
|
||||
handle.write(text)
|
||||
handle.close()
|
||||
out_path = Path(handle.name)
|
||||
|
||||
tail = args.tail if args.tail is not None else DEFAULT_LOG_TAIL
|
||||
tail_lines = lines[-tail:] if tail > 0 else lines
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"job_id": args.job_id,
|
||||
"log_path": str(out_path),
|
||||
"total_lines": len(lines),
|
||||
"tail": tail_lines,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
print(f"job {args.job_id} log saved to: {out_path}")
|
||||
print(f"total lines: {len(lines)} (showing last {len(tail_lines)})")
|
||||
print("-" * 60)
|
||||
for line in tail_lines:
|
||||
print(line)
|
||||
return 0
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Collect Gitea Actions CI evidence (version/runs/jobs/logs).",
|
||||
)
|
||||
parser.add_argument("--remote", default=DEFAULT_REMOTE, help="git remote name")
|
||||
parser.add_argument("--base-url", help="Gitea base URL override")
|
||||
parser.add_argument("--owner", help="repository owner override")
|
||||
parser.add_argument("--repo", help="repository name override")
|
||||
parser.add_argument(
|
||||
"--use-git-credential",
|
||||
action="store_true",
|
||||
help=(
|
||||
"explicitly use repository-scoped git credentials over HTTPS "
|
||||
f"when {TOKEN_ENV} is unset"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", action="store_true", help="emit machine-readable JSON"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("version", help="probe /api/v1/version")
|
||||
|
||||
runs = sub.add_parser("runs", help="list workflow runs")
|
||||
runs.add_argument("--branch")
|
||||
runs.add_argument("--event")
|
||||
runs.add_argument("--status", default="failure")
|
||||
runs.add_argument("--sha")
|
||||
runs.add_argument("--limit", type=int, default=20)
|
||||
|
||||
jobs = sub.add_parser("jobs", help="list jobs of a run, flag failed ones")
|
||||
jobs.add_argument("run_id")
|
||||
|
||||
logs = sub.add_parser("logs", help="download a job log, print its tail")
|
||||
logs.add_argument("job_id")
|
||||
logs.add_argument("--out", help="write full log here instead of a temp file")
|
||||
logs.add_argument(
|
||||
"--tail", type=int, help=f"tail lines (default {DEFAULT_LOG_TAIL})"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"version": cmd_version,
|
||||
"runs": cmd_runs,
|
||||
"jobs": cmd_jobs,
|
||||
"logs": cmd_logs,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
args = _build_parser().parse_args(argv)
|
||||
try:
|
||||
target = resolve_target(args)
|
||||
auth = resolve_auth(args, target)
|
||||
client = ApiClient(target, auth=auth)
|
||||
return _COMMANDS[args.command](args, target, client)
|
||||
except ConfigError as exc:
|
||||
_eprint(f"ERROR: {exc}")
|
||||
return 2
|
||||
except ApiError as exc:
|
||||
_eprint(f"ERROR: {exc}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user