Public Access
DEVX-91: feat: add pr_status, pr_logs, pr_label tools
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 30s
Build Images / detect-type (push) Successful in 48s
Post-merge / badges (push) Successful in 41s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 4m41s
Build Images / cleanup (push) Successful in 2m23s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 30s
Build Images / detect-type (push) Successful in 48s
Post-merge / badges (push) Successful in 41s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 4m41s
Build Images / cleanup (push) Successful in 2m23s
This commit was merged in pull request #143.
This commit is contained in:
@@ -223,6 +223,20 @@ class GiteaClient:
|
||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||
return r.json()
|
||||
|
||||
def add_pr_label(self, pr_number: str | int, label_names: list[str]) -> None:
|
||||
"""Attach labels to a PR/issue by name.
|
||||
|
||||
Args:
|
||||
pr_number: PR or issue number.
|
||||
label_names: List of label names to attach.
|
||||
"""
|
||||
self._request("POST", f"/issues/{pr_number}/labels", json={"labels": label_names})
|
||||
|
||||
def get_pr_label_names(self, pr_number: str | int) -> list[str]:
|
||||
"""Return label names currently attached to a PR/issue."""
|
||||
r = self._request("GET", f"/issues/{pr_number}/labels")
|
||||
return [label.get("name", "") for label in r.json()]
|
||||
|
||||
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the commits included in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
||||
@@ -301,6 +315,31 @@ class GiteaClient:
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
# -- actions (CI/CD) --
|
||||
|
||||
def list_action_runs(self, **params: Any) -> dict[str, Any]:
|
||||
"""List workflow runs for the repository.
|
||||
|
||||
Returns the raw API response dict (includes ``workflow_runs`` and
|
||||
``total_count`` keys per Gitea API).
|
||||
"""
|
||||
r = self._request("GET", "/actions/runs", params=params)
|
||||
return r.json()
|
||||
|
||||
def get_action_run_jobs(self, run_id: str | int) -> list[dict[str, Any]]:
|
||||
"""List jobs for a specific workflow run."""
|
||||
r = self._request("GET", f"/actions/runs/{run_id}/jobs")
|
||||
data = r.json()
|
||||
return data.get("jobs", [])
|
||||
|
||||
def get_action_job_logs(self, job_id: str | int) -> str:
|
||||
"""Fetch logs for a specific CI job.
|
||||
|
||||
Returns the raw log text. Raises APIError if logs are unavailable.
|
||||
"""
|
||||
r = self._request("GET", f"/actions/jobs/{job_id}/logs")
|
||||
return r.text
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
|
||||
@@ -74,6 +74,7 @@ VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
||||
REPO_NAME = _get("repo_name", "DEVX_REPO_NAME", "")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
||||
|
||||
@@ -63,6 +63,7 @@ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi;
|
||||
$(DEVX_BIN)/pip
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
.PHONY: devx-pr-status devx-pr-logs devx-pr-label
|
||||
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
||||
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||
@@ -94,6 +95,35 @@ devx-check-config:
|
||||
# Push and create PR in one step
|
||||
devx-push-with-pr: devx-push devx-create-pr
|
||||
|
||||
# Check CI status for a PR (auto-detects current branch's PR)
|
||||
# Usage: make devx-pr-status
|
||||
# make devx-pr-status PR=42
|
||||
# make devx-pr-status PR=42 WAIT=1 TIMEOUT=600
|
||||
devx-pr-status:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_status \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
$(if $(WAIT),--wait) \
|
||||
$(if $(TIMEOUT),--timeout $(TIMEOUT))
|
||||
|
||||
# Fetch logs for failed CI jobs on a PR
|
||||
# Usage: make devx-pr-logs
|
||||
# make devx-pr-logs PR=42
|
||||
# make devx-pr-logs PR=42 JOB=quality TAIL=50
|
||||
devx-pr-logs:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_logs \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
$(if $(JOB),--job $(JOB)) \
|
||||
$(if $(TAIL),--tail $(TAIL))
|
||||
|
||||
# Add a label to a PR (default: ready-to-merge)
|
||||
# Usage: make devx-pr-label
|
||||
# make devx-pr-label PR=42
|
||||
# make devx-pr-label PR=42 LABEL=ready-to-merge
|
||||
devx-pr-label:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_label \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
--label $(or $(LABEL),ready-to-merge)
|
||||
|
||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||
|
||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||
|
||||
@@ -34,6 +34,7 @@ from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
@@ -46,15 +47,17 @@ load_dotenv()
|
||||
|
||||
|
||||
def get_repo_name() -> str:
|
||||
"""Auto-detect repository name from env vars or git remote."""
|
||||
"""Auto-detect repository name from env vars, pyproject.toml, or git remote."""
|
||||
name = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if name:
|
||||
return name
|
||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if github_repo and "/" in github_repo:
|
||||
return github_repo.split("/", 1)[1]
|
||||
if REPO_NAME:
|
||||
return REPO_NAME
|
||||
raise click.ClickException(
|
||||
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
|
||||
_("Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a label to a pull request (idempotent).
|
||||
|
||||
Commonly used to add the ``ready-to-merge`` label after CI passes and
|
||||
review is complete. The operation is idempotent — if the label is already
|
||||
attached, it succeeds without error.
|
||||
|
||||
Usage::
|
||||
|
||||
# Add ready-to-merge to PR #42
|
||||
python -m devx.tools.pr_label --pr 42 --label ready-to-merge
|
||||
|
||||
# Add label to current branch's PR
|
||||
python -m devx.tools.pr_label --label ready-to-merge
|
||||
|
||||
# Add multiple labels
|
||||
python -m devx.tools.pr_label --pr 42 --label ready-to-merge --label reviewed
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--label", "labels", multiple=True, required=True, help="Label name(s) to add (can be repeated).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
labels: tuple[str, ...],
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Add one or more labels to a pull request (idempotent)."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
|
||||
label_list = list(labels)
|
||||
existing = client.get_pr_label_names(pr_number)
|
||||
to_add = [lbl for lbl in label_list if lbl not in existing]
|
||||
already = [lbl for lbl in label_list if lbl in existing]
|
||||
|
||||
if already:
|
||||
for lbl in already:
|
||||
click.echo(_("Label '{label}' already on PR #{pr}.", label=lbl, pr=pr_number))
|
||||
|
||||
if to_add:
|
||||
client.add_pr_label(pr_number, to_add)
|
||||
for lbl in to_add:
|
||||
click.echo(_("Added label '{label}' to PR #{pr}.", label=lbl, pr=pr_number))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch logs for failed CI jobs on a pull request.
|
||||
|
||||
Lists CI jobs for the latest workflow run of a PR's branch, then fetches
|
||||
and prints the logs of any failed jobs. Useful for diagnosing CI failures
|
||||
without navigating the web UI.
|
||||
|
||||
Usage::
|
||||
|
||||
# Show failed job logs for PR #42
|
||||
python -m devx.tools.pr_logs --pr 42
|
||||
|
||||
# Show failed job logs for current branch's PR
|
||||
python -m devx.tools.pr_logs
|
||||
|
||||
# Show logs for a specific job (by name)
|
||||
python -m devx.tools.pr_logs --pr 42 --job quality
|
||||
|
||||
# Show last N lines of each failed job's logs
|
||||
python -m devx.tools.pr_logs --pr 42 --tail 50
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import APIError, GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
|
||||
"""Fetch the head SHA of a PR."""
|
||||
pr = client.get_pr(pr_number)
|
||||
return pr.get("head", {}).get("sha", "")
|
||||
|
||||
|
||||
def _find_latest_run_by_sha(client: GiteaClient, sha: str) -> dict | None:
|
||||
"""Find the latest workflow run for a commit SHA.
|
||||
|
||||
Gitea Actions API doesn't set head_branch for pull_request events,
|
||||
so we filter by head_sha instead.
|
||||
"""
|
||||
data = client.list_action_runs(limit=50)
|
||||
for run in data.get("workflow_runs", []):
|
||||
if run.get("head_sha", "").startswith(sha):
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _find_failed_jobs(jobs: list[dict]) -> list[dict]:
|
||||
"""Return jobs with conclusion 'failure'."""
|
||||
return [j for j in jobs if j.get("conclusion") == "failure"]
|
||||
|
||||
|
||||
def _find_job_by_name(jobs: list[dict], name: str) -> dict | None:
|
||||
"""Find a job by name (case-insensitive partial match)."""
|
||||
name_lower = name.lower()
|
||||
for j in jobs:
|
||||
if name_lower in j.get("name", "").lower():
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def _print_job_summary(jobs: list[dict]) -> None:
|
||||
"""Print a summary table of all jobs and their status."""
|
||||
for j in jobs:
|
||||
name = j.get("name", "?")
|
||||
conclusion = j.get("conclusion", "pending")
|
||||
status = j.get("status", "?")
|
||||
symbol = "[FAIL]" if conclusion == "failure" else "[OK]" if conclusion == "success" else f"[{conclusion}]"
|
||||
click.echo(f" {symbol} {name} (status: {status}, conclusion: {conclusion})")
|
||||
|
||||
|
||||
def _print_failed_steps(job: dict) -> list[int]:
|
||||
"""Print failed steps for a job. Returns list of failed step numbers."""
|
||||
failed_steps = []
|
||||
for step in job.get("steps", []):
|
||||
if step.get("conclusion") == "failure":
|
||||
name = step.get("name", "?")
|
||||
num = step.get("number", "?")
|
||||
click.echo(f" FAILED step #{num}: {name}")
|
||||
failed_steps.append(num)
|
||||
return failed_steps
|
||||
|
||||
|
||||
def _print_logs(client: GiteaClient, job_id: int, tail: int = 0) -> None:
|
||||
"""Fetch and print logs for a job. If tail > 0, print only last N lines."""
|
||||
try:
|
||||
logs = client.get_action_job_logs(job_id)
|
||||
except APIError as e:
|
||||
click.echo(_(" Could not fetch logs: {error}", error=str(e)))
|
||||
return
|
||||
|
||||
if tail > 0:
|
||||
lines = logs.strip().split("\n")
|
||||
if len(lines) > tail:
|
||||
click.echo(f" ... (showing last {tail} of {len(lines)} lines)")
|
||||
logs = "\n".join(lines[-tail:])
|
||||
|
||||
for line in logs.split("\n"):
|
||||
click.echo(f" {line}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--job", default=None, help="Job name to show logs for (partial match, case-insensitive).")
|
||||
@click.option("--tail", type=int, default=80, show_default=True, help="Show last N lines of logs (0 = all).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
job: str | None,
|
||||
tail: int,
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Fetch logs for failed CI jobs on a pull request."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
click.echo(_("Fetching logs for PR #{pr_number}...", pr_number=pr_number))
|
||||
|
||||
sha = _get_pr_sha(client, pr_number)
|
||||
if not sha:
|
||||
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
|
||||
|
||||
run = _find_latest_run_by_sha(client, sha)
|
||||
if not run:
|
||||
raise click.ClickException(_("No workflow runs found for SHA {sha}.", sha=sha[:8]))
|
||||
|
||||
run_id = run.get("id", 0)
|
||||
run_status = run.get("status", "?")
|
||||
click.echo(_("Latest run: #{run_id} (status: {status})", run_id=run_id, status=run_status))
|
||||
click.echo("")
|
||||
|
||||
jobs = client.get_action_run_jobs(run_id)
|
||||
if not jobs:
|
||||
click.echo(_("No jobs found for run #{run_id}.", run_id=run_id))
|
||||
return
|
||||
|
||||
_print_job_summary(jobs)
|
||||
click.echo("")
|
||||
|
||||
if job:
|
||||
target = _find_job_by_name(jobs, job)
|
||||
if not target:
|
||||
raise click.ClickException(_("No job matching '{job}' found.", job=job))
|
||||
click.echo(f"Logs for job '{target.get('name', '?')}' (id={target.get('id')}):")
|
||||
_print_failed_steps(target)
|
||||
click.echo("")
|
||||
_print_logs(client, target["id"], tail)
|
||||
else:
|
||||
failed = _find_failed_jobs(jobs)
|
||||
if not failed:
|
||||
click.echo(_("No failed jobs."))
|
||||
return
|
||||
for fj in failed:
|
||||
click.echo(f"Logs for failed job '{fj.get('name', '?')}' (id={fj.get('id')}):")
|
||||
_print_failed_steps(fj)
|
||||
click.echo("")
|
||||
_print_logs(client, fj["id"], tail)
|
||||
click.echo("")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check CI status for a pull request or commit.
|
||||
|
||||
Displays the status of all CI checks for a PR (or a specific commit SHA).
|
||||
Optionally polls until all checks complete (``--wait``).
|
||||
|
||||
Usage::
|
||||
|
||||
# Check status of PR #42
|
||||
python -m devx.tools.pr_status --pr 42
|
||||
|
||||
# Check status of current branch's PR
|
||||
python -m devx.tools.pr_status
|
||||
|
||||
# Wait for all checks to complete (timeout 600s)
|
||||
python -m devx.tools.pr_status --pr 42 --wait --timeout 600
|
||||
|
||||
# Check a specific commit SHA
|
||||
python -m devx.tools.pr_status --sha abc1234
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Status symbols for terminal output
|
||||
_STATUS_SYMBOLS = {
|
||||
"success": "[OK]",
|
||||
"failure": "[FAIL]",
|
||||
"error": "[FAIL]",
|
||||
"pending": "[..]",
|
||||
"skipped": "[SKIP]",
|
||||
"none": "[--]",
|
||||
}
|
||||
|
||||
|
||||
def _get_symbol(status: str) -> str:
|
||||
return _STATUS_SYMBOLS.get(status, f"[{status}]")
|
||||
|
||||
|
||||
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
|
||||
"""Fetch the head SHA of a PR."""
|
||||
pr = client.get_pr(pr_number)
|
||||
return pr.get("head", {}).get("sha", "")
|
||||
|
||||
|
||||
def _get_current_branch_pr(client: GiteaClient) -> int:
|
||||
"""Find the open PR for the current git branch."""
|
||||
result = subprocess.run( # nosec
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(_("Could not detect current branch: {error}", error=result.stderr.strip()))
|
||||
branch = result.stdout.strip()
|
||||
|
||||
prs = client.list_prs(state="open")
|
||||
for pr in prs:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
return int(pr["number"])
|
||||
raise click.ClickException(_("No open PR found for branch '{branch}'.", branch=branch))
|
||||
|
||||
|
||||
def print_status(client: GiteaClient, sha: str) -> str:
|
||||
"""Print CI check statuses for a commit SHA. Returns the overall state."""
|
||||
statuses = client.get_commit_status(sha)
|
||||
if not statuses:
|
||||
click.echo(_("No CI checks found for commit {sha}.", sha=sha[:8]))
|
||||
return "none"
|
||||
|
||||
overall = "success"
|
||||
for s in statuses:
|
||||
context = s.get("context", "?")
|
||||
status = s.get("status", "pending")
|
||||
symbol = _get_symbol(status)
|
||||
click.echo(f" {symbol} {context}")
|
||||
if status in ("failure", "error"):
|
||||
overall = "failure"
|
||||
elif status == "pending" and overall != "failure":
|
||||
overall = "pending"
|
||||
elif status == "skipped" and overall == "success":
|
||||
overall = "success"
|
||||
|
||||
click.echo(f"\n Overall: {_get_symbol(overall)} {overall}")
|
||||
return overall
|
||||
|
||||
|
||||
def wait_for_completion(
|
||||
client: GiteaClient,
|
||||
sha: str,
|
||||
timeout: int = 600,
|
||||
interval: int = 30,
|
||||
) -> str:
|
||||
"""Poll CI status until all checks complete or timeout. Returns final state."""
|
||||
click.echo(_("Waiting for CI checks to complete (timeout: {timeout}s)...", timeout=timeout))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
state = print_status(client, sha)
|
||||
if state in ("success", "failure", "error", "none"):
|
||||
return state
|
||||
click.echo(f" ...still pending, retrying in {interval}s\n")
|
||||
time.sleep(interval)
|
||||
click.echo(_("Timeout reached after {timeout}s.", timeout=timeout))
|
||||
return "pending"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--sha", default=None, help="Commit SHA to check (alternative to --pr).")
|
||||
@click.option("--wait", "do_wait", is_flag=True, help="Poll until all checks complete.")
|
||||
@click.option("--timeout", type=int, default=600, show_default=True, help="Wait timeout in seconds.")
|
||||
@click.option("--interval", type=int, default=30, show_default=True, help="Poll interval in seconds.")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
sha: str | None,
|
||||
do_wait: bool,
|
||||
timeout: int,
|
||||
interval: int,
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Check CI status for a pull request or commit."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if sha is None:
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
click.echo(_("Checking status for PR #{pr_number}...", pr_number=pr_number))
|
||||
pr = client.get_pr(pr_number)
|
||||
sha = pr.get("head", {}).get("sha", "")
|
||||
if not sha:
|
||||
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
|
||||
|
||||
click.echo(_("Commit: {sha}", sha=sha[:12]))
|
||||
click.echo("")
|
||||
|
||||
state = wait_for_completion(client, sha, timeout, interval) if do_wait else print_status(client, sha)
|
||||
|
||||
if state in ("failure", "error"):
|
||||
raise click.ClickException(_("CI checks failed."))
|
||||
if state == "pending" and do_wait:
|
||||
raise click.ClickException(_("CI checks did not complete within timeout."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
+160
-8
@@ -1471,14 +1471,6 @@
|
||||
"ru": "Repository in owner/name format",
|
||||
"zh": "Repository in owner/name format"
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
|
||||
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
|
||||
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
|
||||
},
|
||||
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
||||
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
|
||||
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
||||
@@ -2038,5 +2030,165 @@
|
||||
"pl": "Configuring tea login '{name}' for {url}...",
|
||||
"ru": "Configuring tea login '{name}' for {url}...",
|
||||
"zh": "Configuring tea login '{name}' for {url}..."
|
||||
},
|
||||
" Could not fetch logs: {error}": {
|
||||
"en": " Could not fetch logs: {error}",
|
||||
"bg": " Could not fetch logs: {error}",
|
||||
"de": " Could not fetch logs: {error}",
|
||||
"pl": " Could not fetch logs: {error}",
|
||||
"ru": " Could not fetch logs: {error}",
|
||||
"zh": " Could not fetch logs: {error}"
|
||||
},
|
||||
"Added label '{label}' to PR #{pr}.": {
|
||||
"en": "Added label '{label}' to PR #{pr}.",
|
||||
"bg": "Added label '{label}' to PR #{pr}.",
|
||||
"de": "Added label '{label}' to PR #{pr}.",
|
||||
"pl": "Added label '{label}' to PR #{pr}.",
|
||||
"ru": "Added label '{label}' to PR #{pr}.",
|
||||
"zh": "Added label '{label}' to PR #{pr}."
|
||||
},
|
||||
"CI checks did not complete within timeout.": {
|
||||
"en": "CI checks did not complete within timeout.",
|
||||
"bg": "CI checks did not complete within timeout.",
|
||||
"de": "CI checks did not complete within timeout.",
|
||||
"pl": "CI checks did not complete within timeout.",
|
||||
"ru": "CI checks did not complete within timeout.",
|
||||
"zh": "CI checks did not complete within timeout."
|
||||
},
|
||||
"CI checks failed.": {
|
||||
"en": "CI checks failed.",
|
||||
"bg": "CI checks failed.",
|
||||
"de": "CI checks failed.",
|
||||
"pl": "CI checks failed.",
|
||||
"ru": "CI checks failed.",
|
||||
"zh": "CI checks failed."
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set.": {
|
||||
"en": "CI_GITEA_TOKEN is not set.",
|
||||
"bg": "CI_GITEA_TOKEN is not set.",
|
||||
"de": "CI_GITEA_TOKEN is not set.",
|
||||
"pl": "CI_GITEA_TOKEN is not set.",
|
||||
"ru": "CI_GITEA_TOKEN is not set.",
|
||||
"zh": "CI_GITEA_TOKEN is not set."
|
||||
},
|
||||
"Checking status for PR #{pr_number}...": {
|
||||
"en": "Checking status for PR #{pr_number}...",
|
||||
"bg": "Checking status for PR #{pr_number}...",
|
||||
"de": "Checking status for PR #{pr_number}...",
|
||||
"pl": "Checking status for PR #{pr_number}...",
|
||||
"ru": "Checking status for PR #{pr_number}...",
|
||||
"zh": "Checking status for PR #{pr_number}..."
|
||||
},
|
||||
"Commit: {sha}": {
|
||||
"en": "Commit: {sha}",
|
||||
"bg": "Commit: {sha}",
|
||||
"de": "Commit: {sha}",
|
||||
"pl": "Commit: {sha}",
|
||||
"ru": "Commit: {sha}",
|
||||
"zh": "Commit: {sha}"
|
||||
},
|
||||
"Could not determine head SHA for PR #{pr_number}.": {
|
||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"bg": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"de": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"pl": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"zh": "Could not determine head SHA for PR #{pr_number}."
|
||||
},
|
||||
"Fetching logs for PR #{pr_number}...": {
|
||||
"en": "Fetching logs for PR #{pr_number}...",
|
||||
"bg": "Fetching logs for PR #{pr_number}...",
|
||||
"de": "Fetching logs for PR #{pr_number}...",
|
||||
"pl": "Fetching logs for PR #{pr_number}...",
|
||||
"ru": "Fetching logs for PR #{pr_number}...",
|
||||
"zh": "Fetching logs for PR #{pr_number}..."
|
||||
},
|
||||
"Label '{label}' already on PR #{pr}.": {
|
||||
"en": "Label '{label}' already on PR #{pr}.",
|
||||
"bg": "Label '{label}' already on PR #{pr}.",
|
||||
"de": "Label '{label}' already on PR #{pr}.",
|
||||
"pl": "Label '{label}' already on PR #{pr}.",
|
||||
"ru": "Label '{label}' already on PR #{pr}.",
|
||||
"zh": "Label '{label}' already on PR #{pr}."
|
||||
},
|
||||
"Latest run: #{run_id} (status: {status})": {
|
||||
"en": "Latest run: #{run_id} (status: {status})",
|
||||
"bg": "Latest run: #{run_id} (status: {status})",
|
||||
"de": "Latest run: #{run_id} (status: {status})",
|
||||
"pl": "Latest run: #{run_id} (status: {status})",
|
||||
"ru": "Latest run: #{run_id} (status: {status})",
|
||||
"zh": "Latest run: #{run_id} (status: {status})"
|
||||
},
|
||||
"No CI checks found for commit {sha}.": {
|
||||
"en": "No CI checks found for commit {sha}.",
|
||||
"bg": "No CI checks found for commit {sha}.",
|
||||
"de": "No CI checks found for commit {sha}.",
|
||||
"pl": "No CI checks found for commit {sha}.",
|
||||
"ru": "No CI checks found for commit {sha}.",
|
||||
"zh": "No CI checks found for commit {sha}."
|
||||
},
|
||||
"No failed jobs.": {
|
||||
"en": "No failed jobs.",
|
||||
"bg": "No failed jobs.",
|
||||
"de": "No failed jobs.",
|
||||
"pl": "No failed jobs.",
|
||||
"ru": "No failed jobs.",
|
||||
"zh": "No failed jobs."
|
||||
},
|
||||
"No job matching '{job}' found.": {
|
||||
"en": "No job matching '{job}' found.",
|
||||
"bg": "No job matching '{job}' found.",
|
||||
"de": "No job matching '{job}' found.",
|
||||
"pl": "No job matching '{job}' found.",
|
||||
"ru": "No job matching '{job}' found.",
|
||||
"zh": "No job matching '{job}' found."
|
||||
},
|
||||
"No jobs found for run #{run_id}.": {
|
||||
"en": "No jobs found for run #{run_id}.",
|
||||
"bg": "No jobs found for run #{run_id}.",
|
||||
"de": "No jobs found for run #{run_id}.",
|
||||
"pl": "No jobs found for run #{run_id}.",
|
||||
"ru": "No jobs found for run #{run_id}.",
|
||||
"zh": "No jobs found for run #{run_id}."
|
||||
},
|
||||
"No open PR found for branch '{branch}'.": {
|
||||
"en": "No open PR found for branch '{branch}'.",
|
||||
"bg": "No open PR found for branch '{branch}'.",
|
||||
"de": "No open PR found for branch '{branch}'.",
|
||||
"pl": "No open PR found for branch '{branch}'.",
|
||||
"ru": "No open PR found for branch '{branch}'.",
|
||||
"zh": "No open PR found for branch '{branch}'."
|
||||
},
|
||||
"No workflow runs found for SHA {sha}.": {
|
||||
"en": "No workflow runs found for SHA {sha}.",
|
||||
"bg": "No workflow runs found for SHA {sha}.",
|
||||
"de": "No workflow runs found for SHA {sha}.",
|
||||
"pl": "No workflow runs found for SHA {sha}.",
|
||||
"ru": "No workflow runs found for SHA {sha}.",
|
||||
"zh": "No workflow runs found for SHA {sha}."
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
|
||||
},
|
||||
"Timeout reached after {timeout}s.": {
|
||||
"en": "Timeout reached after {timeout}s.",
|
||||
"bg": "Timeout reached after {timeout}s.",
|
||||
"de": "Timeout reached after {timeout}s.",
|
||||
"pl": "Timeout reached after {timeout}s.",
|
||||
"ru": "Timeout reached after {timeout}s.",
|
||||
"zh": "Timeout reached after {timeout}s."
|
||||
},
|
||||
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
|
||||
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user