feat(gitea-fix-ci): add authenticated log collector

This commit is contained in:
csh
2026-07-22 17:28:22 +08:00
parent 38ea8caaeb
commit 3117ca5ae4
3 changed files with 1135 additions and 103 deletions
+162 -103
View File
@@ -1,151 +1,210 @@
--- ---
name: gitea-fix-ci name: gitea-fix-ci
description: Use when a user asks to debug or fix failing Gitea Actions, Gitea PR checks, or CI workflow runs for a Gitea-hosted repository. description: "Use when a user asks to debug or fix failing Gitea Actions, Gitea PR checks, or CI workflow runs for a Gitea-hosted repository. Also triggers on Chinese phrasing such as CI 挂了/流水线红了/构建失败/工作流失败."
--- ---
# Gitea Fix CI # Gitea Fix CI
## Overview ## 概述
Diagnose failing Gitea Actions from the pull request or workflow run, extract the pull request workflow run 诊断失败的 Gitea Actions,提取最小可用的失败上下文,
smallest useful failure context, then propose a fix plan before changing code. 然后在改代码之前先给出修复计划。核心原则:CI 日志是证据,不要仅凭红色状态臆测原因。
Core principle: CI logs are evidence; do not guess from the red status alone.
This is not a standalone executor. It guides use of `tea`, local `git`, and the 本 skill 不是独立执行器。它指导你在当前工作区中使用随附的取证脚本
Gitea API from the current workspace. `scripts/fetch_ci_logs.py``tea`、本地 `git` 和 Gitea API。
## When to Use 取证阶段(step 2-4)优先使用随附脚本,把「探测版本 → 找失败 run → 列 job →
下载失败 job 日志」这段易记错 API 路径的逻辑交给它。脚本仅取证到日志,分类、
修复计划与改代码仍由你按本文档执行。将 `<skill-dir>` 替换为包含本 `SKILL.md`
的目录:
- A Gitea-hosted repository has failing Gitea Actions or PR checks ```bash
- The user asks to inspect a failed workflow run, job, or CI status # 方式一(优先):通过环境变量提供 API token
- The user asks to fix CI after a push, branch update, or pull request update export GITEA_TOKEN=<token>
- Local tests pass but remote Gitea Actions fail python <skill-dir>/scripts/fetch_ci_logs.py version # 探测实例版本并确认连通性
python <skill-dir>/scripts/fetch_ci_logs.py runs --status failure --branch <branch>
python <skill-dir>/scripts/fetch_ci_logs.py jobs <run-id> # 标注 failure 的 job
python <skill-dir>/scripts/fetch_ci_logs.py logs <job-id> # 日志存临时文件并回显尾部
## When Not to Use # 方式二:显式复用当前仓库的 Git HTTP 凭据
python <skill-dir>/scripts/fetch_ci_logs.py --use-git-credential version
python <skill-dir>/scripts/fetch_ci_logs.py --use-git-credential runs --status failure
```
- The repository is not hosted on Gitea or Forgejo-compatible infrastructure `base-url`、owner 和 repo 默认从 git remote 推导。认证规则如下:
- The failure belongs to an external CI provider and only links out from Gitea
- The user only wants a local test or lint run
- Credentials, tokens, or network access are unavailable and the user has not
provided the failing log text
## Inputs - 设置了 `GITEA_TOKEN` 时始终优先使用它,即使同时传入 `--use-git-credential`
- 只有显式传入 `--use-git-credential` 才会非交互调用 `git credential fill`,并将
当前仓库对应的用户名和密码用于 HTTP Basic 认证;脚本不会静默读取 Git 凭据。
- 未设置 token 且未传入该参数时按匿名方式访问,私有仓库通常会返回 401。
- 所有携带凭据的请求必须使用 HTTPS;认证 header 只发送到配置的 Gitea 同源地址,
跨源请求和跨源重定向会被拒绝。凭据不会出现在命令参数、日志或异常文本中。
- Repository path, defaulting to the current workspace 脚本不可用(未装 Python、旧版 Gitea 无 job-log API、脱离 Gitea 环境)时,回退到
- Gitea base URL and repository owner/name, from `git remote -v` when possible 下文的手动 `tea`/API/web 路径。
- Pull request number, branch, commit SHA, or workflow run ID
- Authentication method: `tea` login profile, `GITEA_TOKEN` for API requests,
or an existing git credential for the Gitea web fallback
- Any pasted CI log if remote access is unavailable
## Procedure ## 适用场景
1. **Baseline local state** - Gitea 托管的仓库出现失败的 Gitea Actions 或 PR checks
- 用户要求检查某个失败的 workflow run、job 或 CI 状态
- 用户要求在 push、更新分支或更新 pull request 之后修复 CI
- 本地测试通过,但远端 Gitea Actions 失败
- Record `git status --short`, current branch, and latest commit SHA. ## 不适用场景
- Identify the Gitea remote URL and owner/repo.
- Do not modify files while gathering CI evidence.
2. **Verify Gitea access** - 仓库并非托管在 Gitea 或 Forgejo 兼容的基础设施上
- 失败属于外部 CI 服务,只是从 Gitea 链接出去
- 用户只想在本地跑测试或 lint
- 没有凭据、token 或网络访问权限,且用户也未提供失败的日志文本
- Prefer `tea` if it is installed and authenticated: ## 输入
- 仓库路径,默认为当前工作区
- Gitea base URL 和仓库 owner/name,尽量从 `git remote -v` 获取
- Pull request 编号、分支、commit SHA 或 workflow run ID
- 认证方式:`tea` 登录 profile、用于 API 请求的 `GITEA_TOKEN`,或通过
`--use-git-credential` 显式读取的当前仓库 Git HTTP 凭据
- 若无法远端访问,则由用户粘贴的 CI 日志
## 流程
1. **基线本地状态**
- 记录 `git status --short`、当前分支和最新 commit SHA。
-`git remote -v` 识别 remote URL 和 owner/repo。
- 继续之前先确认 remote 是 Gitea/Forgejo:对照已知的 Gitea 实例核对 host
或探测 `/api/v1/version`。若 remote 是 GitHub、GitLab 或其他服务,按"不适用场景"停止。
- 收集 CI 证据期间不要修改文件。
2. **验证 Gitea 访问**
- 优先运行 `fetch_ci_logs.py version` 探测实例版本并确认脚本能连通实例。脚本从
remote 推导 base URL,认证按 `GITEA_TOKEN` → 显式 `--use-git-credential`
匿名的顺序选择。注意:`version` 只读 `/api/v1/version`,成功仅代表连通与鉴权
可用,**不代表 Actions API 一定可用**。Actions 端点是否存在需在后续
`runs`/`jobs` 步骤中实际验证;旧版 Gitea(见下)会在此才暴露 404。
- 脚本不可用时,回退到手动方式。优先使用已安装并已认证的 `tea`
- `tea login list` - `tea login list`
- 使用前先运行 `tea actions --help` 确认已安装的 `tea` 版本存在 `actions`
子命令;并非所有版本都带这些子命令。
- `tea actions runs list --status failure --branch <branch>` - `tea actions runs list --status failure --branch <branch>`
- `tea actions runs view <run-id>` - `tea actions runs view <run-id>`
- `tea actions runs logs <run-id> --job <job-id>` - `tea actions runs logs <run-id> --job <job-id>`
- `tea pulls view <pr>` - `tea pulls view <pr>`
- If `tea` is unavailable or lacks an Actions command for the installed - `tea` 不可用,或已安装版本缺少 Actions 命令,则直接使用 Gitea API。
version, use the Gitea API directly. - 当 API 行为不明确时,检查 `/api/v1/version``/swagger.v1.json`。较旧的
- Check `/api/v1/version` and `/swagger.v1.json` when API behavior is Gitea1.21 及更早)在 web UI 中提供 Actions 页面,但不提供 workflow
unclear. Gitea 1.21 exposes Actions pages but not workflow run/job/log API run/job/log API 端点;应对照上报的版本确认可用性,而不是想当然。
endpoints. - 绝不打印 token、用户名或密码。API token 只通过环境变量传递;Git 凭据只通过
- Never print tokens. Pass API tokens through environment variables. `git credential fill` 的标准输入/输出在脚本进程内传递,不放入 argv。
3. **Find failing workflow runs** 3. **定位失败的 workflow run**
- For a known run ID, fetch that run directly. - 若入口是 PR 而非 run ID,先把 PR 解析成 run:取 PR 的 head 分支与 head SHA
- Otherwise list recent workflow runs filtered by branch, event, status, or `fetch_ci_logs.py``runs` 支持 `--branch`/`--sha`;手动则
commit SHA. `GET /api/v1/repos/<owner>/<repo>/pulls/<pr>``head.ref`/`head.sha`),
- API patterns: 再按该 SHA 过滤 runs。PR 页面的 checks 列表可能聚合多个 workflow,逐一定位到
具体失败 run,不要假设只有一个。
- 优先用脚本:`fetch_ci_logs.py runs --status failure --branch <branch>`
(也支持 `--sha`/`--event`/`--limit`),已知 run ID 时用
`fetch_ci_logs.py jobs <run-id>` 直接列出各 job 并标注失败项。
- 脚本不可用时回退到手动方式。已知 run ID 时,直接获取该 run。
- 否则按分支、事件、状态或 commit SHA 过滤,列出最近的 workflow runs。
- API 模式:
- `GET /api/v1/repos/<owner>/<repo>/actions/runs` - `GET /api/v1/repos/<owner>/<repo>/actions/runs`
- `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>` - `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>`
- `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>/jobs` - `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>/jobs`
- Web fallback for older Gitea: - 较旧 Gitea 的 web 回退:
- `GET /<owner>/<repo>/actions` - `GET /<owner>/<repo>/actions`
- Parse `/<owner>/<repo>/actions/runs/<run>` links and status labels. - 解析 `/<owner>/<repo>/actions/runs/<run>` 链接和状态标签。
- Treat `failure`, `cancelled`, and missing required checks differently. - 区别对待 `failure``cancelled` 和缺失的必需 checks。被取消(cancelled)的
Cancelled jobs may require rerun or queue investigation rather than code job 可能需要重跑或排查队列,而非改代码。
changes.
4. **Fetch job logs** 4. **获取 job 日志**
- For each failed job, download the job logs: - 优先用脚本:`fetch_ci_logs.py logs <job-id>`。它把日志存到受跟踪源码路径
之外的临时文件、只回显尾部若干行,并打印临时文件路径供进一步查看
`--tail N` 调整行数,`--json` 输出结构化结果)。
- 脚本不可用时回退到手动方式。对每个失败的 job,下载其日志:
- `GET /api/v1/repos/<owner>/<repo>/actions/jobs/<job_id>/logs` - `GET /api/v1/repos/<owner>/<repo>/actions/jobs/<job_id>/logs`
- On Gitea 1.21 web fallback, download logs by UI job index: - 在较旧 Gitea web 回退(无 job-log API)下,按 UI job index 下载日志:
- `GET /<owner>/<repo>/actions/runs/<run>/jobs/<job-index>/logs` - `GET /<owner>/<repo>/actions/runs/<run>/jobs/<job-index>/logs`
- First open the run page and read each job's status; take the index of a - 先打开 run 页面读取每个 job 的状态;取状态为 `failure` 的 job 的 index
job whose status is `failure`, not index `0` by default. The failing job 不要默认用 index `0`。失败的 job 很少是第一个,盲目用 index `0` 通常会返回
is rarely the first one, so a blind index `0` usually returns a passing 某个通过的 job 的日志。只有当 run 页面不暴露每个 job 的状态时,才回退到逐一
job's log. Only fall back to scanning indices when the run page does not 扫描 index。
expose per-job status. - 大日志保存到受跟踪源码路径之外的临时文件。
- Save large logs to a temporary file outside tracked source paths. - 提取首个可操作的错误块、其上下文命令、job 名、workflow 名、run URL、分支和 SHA。
- Extract the first actionable error block, surrounding command, job name, - 若日志缺失,明确报告,而不是编造原因。
workflow name, run URL, branch, and SHA.
- If logs are missing, report that explicitly instead of inventing causes.
5. **Classify the failure** 5. **分类失败**
- Code/test failure: failing assertion, compile error, lint error, type error - 代码/测试失败:断言失败、编译错误、lint 错误、类型错误
- Environment failure: missing secret, runner image, dependency install, - 环境失败:缺失 secretrunner 镜像、依赖安装、网络、缓存、权限或服务启动
network, cache, permission, or service startup - 工作流失败:YAML 非法、语法不支持、触发条件错误、路径错误、分支/ref 假设错误
- Workflow failure: invalid YAML, unsupported syntax, wrong trigger, bad path, - 基础设施失败:runner 离线、队列卡住、run 被取消、超时
wrong branch/ref assumption
- Infrastructure failure: offline runner, stuck queue, cancelled run, timeout
6. **Create a fix plan** 6. **制定修复计划**
- Summarize the failure evidence with exact job/run identifiers. - 用确切的 job/run 标识总结失败证据。
- Propose the smallest code or workflow change that matches the evidence. - 提出与证据匹配的最小代码或工作流改动。
- Include local verification commands and the remote recheck path. - 包含本地验证命令和远端复检路径。
- Do not implement before the user approves the fix plan. - 在用户批准修复计划之前不要实施。
7. **Implement after approval** 7. **批准后实施**
- Apply only the approved fix. - 只应用已批准的修复。
- Run the local command that most closely reproduces the failed job. - 运行最接近复现该失败 job 的本地命令。
- If the failure is workflow-only, validate the workflow file syntax and any - 若失败仅涉及工作流,验证工作流文件语法及其引用的路径或脚本。
referenced paths or scripts.
8. **Recheck** 8. **复检**
- Tell the user what must be pushed or rerun in Gitea. - 告诉用户需要在 Gitea 中 push 或重跑什么。
- If permitted, use the Gitea API to inspect the rerun status. - 若获准,使用 Gitea API 检查重跑状态。
- Final output must distinguish local verification from remote CI status. - 最终输出必须区分本地验证与远端 CI 状态。
## Output Contract ## 输出约定
- `Target:` repo, branch/SHA, PR or run ID - `Target:` 仓库、分支/SHAPR run ID
- `Failed CI:` workflow, job, status, run URL or API path - `Failed CI:` workflowjob、状态、run URL API 路径
- `Evidence:` concise log snippet and classification - `Evidence:` 精简的日志片段与分类
- `Plan:` proposed fix, local verification, remote recheck - `Plan:` 提出的修复、本地验证、远端复检
- `Changes:` files changed after approval - `Changes:` 批准后改动的文件
- `Result:` local checks run and remaining remote status - `Result:` 已运行的本地检查与剩余的远端状态
## Success Criteria ## 成功标准
- Failure analysis is based on Gitea Actions run/job data or pasted logs - 失败分析基于 Gitea Actions run/job 数据或粘贴的日志
- The fix plan names the exact workflow run or job it addresses - 修复计划指明其针对的确切 workflow run job
- No code or workflow edits happen before plan approval - 计划批准前不发生任何代码或工作流改动
- Verification distinguishes local commands from remote Gitea Actions results - 验证区分本地命令与远端 Gitea Actions 结果
- Tokens and private log content are not echoed unnecessarily - 不回显 token;私有日志只保留可操作的片段,不整段外泄
## Failure Handling ## 危险信号(Red Flags
- If authentication fails, ask the user to authenticate `tea` or provide a token 出现以下情况说明流程走偏,停下纠正而非继续:
through the environment; do not request secrets in chat
- If the Gitea version lacks Actions API endpoints, ask for the relevant log text - **凭红色状态臆测原因**:还没下载 job 日志就断言失败原因或动手改代码。
or a browser-copied job log - **误读双字段结果**:把 `status`(生命周期)当成结果判定。Gitea Actions 沿用
- If an external CI provider owns the failing check, report the external URL and GitHub 兼容的双字段模型——`status=completed` 只表示跑完了,真正的成败在
stop at evidence collection `conclusion``failure`/`success`/`cancelled`)。判定失败必须看有效结果,
- If the failure is infrastructure-only, recommend rerun/runner investigation 而非 `status=completed` 就当通过。
instead of editing code - **盲取 job index `0`**:在 web 回退下不看每个 job 状态就用 index `0` 下载日志;
失败的 job 很少是第一个,通常会误取到某个通过 job 的日志。
- **把 `version` 成功当作 Actions API 可用**`version` 只探连通与鉴权,Actions
端点可能仍返回 404(旧版 Gitea)。
- **把 `cancelled`/基础设施问题当代码 bug 修**:被取消、runner 离线、队列卡住、
超时应重跑或排查环境,不是改代码。
- **回显 token 或整段私有日志**:只保留可操作的最小片段。
- **静默读取或降级传输凭据**:Git 凭据必须由 `--use-git-credential` 显式启用;
token 和 Git 凭据都不得通过 HTTP 或跨源重定向发送。
## 失败处理
- 401 且未使用认证时,通过环境提供 `GITEA_TOKEN` 或显式传入
`--use-git-credential`;401 且已使用认证时检查凭据是否有效,不要在对话中索要 secret
- 403 表示所选凭据缺少仓库或 Actions 读取权限;补足权限,而不是切换到匿名访问
- 404 优先核对 owner/repo、端点和 Gitea 版本;旧版本缺少 Actions API 时使用 web 回退
- 若 Gitea 版本缺少 Actions API 端点,请用户提供相关日志文本或从浏览器复制的 job 日志
- 若失败的 check 归属外部 CI 服务,报告外部 URL 并在证据收集处停止
- 若失败仅为基础设施问题,建议重跑/排查 runner,而非改代码
@@ -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())
+274
View File
@@ -0,0 +1,274 @@
from __future__ import annotations
import base64
import importlib.util
import os
import subprocess
import sys
import unittest
import urllib.request
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
SKILL_ROOT = ROOT / "skills" / "gitea-fix-ci"
SCRIPT = SKILL_ROOT / "scripts" / "fetch_ci_logs.py"
def load_module():
spec = importlib.util.spec_from_file_location("fetch_ci_logs", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class FakeResponse:
status = 200
headers = {"Content-Type": "application/json"}
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self) -> bytes:
return b'{"ok": true}'
class RecordingOpener:
def __init__(self) -> None:
self.requests = []
def open(self, request, *, timeout):
self.requests.append((request, timeout))
return FakeResponse()
class GiteaFixCiSkillTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.module = load_module()
def setUp(self) -> None:
self.target = self.module.RepoTarget(
base_url="https://git.example.test",
owner="team",
repo="project",
)
def test_token_auth_takes_precedence_without_reading_git_credentials(self):
args = SimpleNamespace(use_git_credential=True)
with mock.patch.dict(os.environ, {"GITEA_TOKEN": "token-secret"}, clear=True):
with mock.patch.object(
self.module,
"_git_credential_auth",
side_effect=AssertionError("git credential must not run"),
):
auth = self.module.resolve_auth(args, self.target)
self.assertEqual(auth.source, "GITEA_TOKEN")
self.assertEqual(auth.authorization, "token token-secret")
self.assertNotIn("token-secret", repr(auth))
def test_git_credential_auth_is_explicit_scoped_and_non_interactive(self):
credential = subprocess.CompletedProcess(
args=["git", "credential", "fill"],
returncode=0,
stdout=(
"protocol=https\n"
"host=git.example.test\n"
"username=ci-user\n"
"password=basic-secret\n"
),
stderr="",
)
args = SimpleNamespace(use_git_credential=True)
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(
self.module.subprocess, "run", return_value=credential
) as run:
auth = self.module.resolve_auth(args, self.target)
self.assertEqual(auth.source, "git credential")
expected = base64.b64encode(b"ci-user:basic-secret").decode("ascii")
self.assertEqual(auth.authorization, f"Basic {expected}")
self.assertNotIn("basic-secret", repr(auth))
call = run.call_args
self.assertEqual(call.args[0], ["git", "credential", "fill"])
self.assertIn("protocol=https\n", call.kwargs["input"])
self.assertIn("host=git.example.test\n", call.kwargs["input"])
self.assertIn("path=team/project.git\n", call.kwargs["input"])
self.assertEqual(call.kwargs["env"]["GIT_TERMINAL_PROMPT"], "0")
self.assertEqual(call.kwargs["env"]["GCM_INTERACTIVE"], "Never")
def test_git_credential_scope_includes_gitea_base_path(self):
target = self.module.RepoTarget(
base_url="https://git.example.test/gitea",
owner="team",
repo="project",
)
self.assertEqual(target.credential_path, "gitea/team/project.git")
def test_git_credentials_are_not_read_without_explicit_flag(self):
args = SimpleNamespace(use_git_credential=False)
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(
self.module.subprocess,
"run",
side_effect=AssertionError("git credential must not run"),
):
auth = self.module.resolve_auth(args, self.target)
self.assertIsNone(auth)
def test_all_credentials_require_https(self):
target = self.module.RepoTarget(
base_url="http://git.example.test",
owner="team",
repo="project",
)
with mock.patch.dict(os.environ, {"GITEA_TOKEN": "secret"}, clear=True):
with self.assertRaisesRegex(self.module.ConfigError, "HTTPS"):
self.module.resolve_auth(
SimpleNamespace(use_git_credential=False), target
)
with mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(self.module.ConfigError, "HTTPS"):
self.module.resolve_auth(
SimpleNamespace(use_git_credential=True), target
)
auth = self.module.ApiAuth(
source="GITEA_TOKEN",
authorization="token sensitive-value",
)
with self.assertRaisesRegex(self.module.ConfigError, "HTTPS"):
self.module.ApiClient(target, auth=auth, opener=RecordingOpener())
def test_invalid_token_header_is_rejected_without_echoing_secret(self):
secret = "token-secret\ninjected-header"
with mock.patch.dict(os.environ, {"GITEA_TOKEN": secret}, clear=True):
with self.assertRaises(self.module.ConfigError) as raised:
self.module.resolve_auth(
SimpleNamespace(use_git_credential=False), self.target
)
self.assertNotIn("token-secret", str(raised.exception))
self.assertNotIn("injected-header", str(raised.exception))
def test_missing_git_credential_fails_without_echoing_helper_output(self):
credential = subprocess.CompletedProcess(
args=["git", "credential", "fill"],
returncode=1,
stdout="",
stderr="helper diagnostic containing basic-secret",
)
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(
self.module.subprocess, "run", return_value=credential
):
with self.assertRaises(self.module.ConfigError) as raised:
self.module.resolve_auth(
SimpleNamespace(use_git_credential=True), self.target
)
self.assertNotIn("basic-secret", str(raised.exception))
def test_git_credential_timeout_does_not_echo_helper_output(self):
timeout = subprocess.TimeoutExpired(
cmd=["git", "credential", "fill"],
timeout=10,
output="username=ci-user\npassword=basic-secret\n",
stderr="helper diagnostic containing basic-secret",
)
with mock.patch.dict(os.environ, {}, clear=True):
with mock.patch.object(self.module.subprocess, "run", side_effect=timeout):
with self.assertRaises(self.module.ConfigError) as raised:
self.module.resolve_auth(
SimpleNamespace(use_git_credential=True), self.target
)
self.assertNotIn("basic-secret", str(raised.exception))
def test_api_client_sends_auth_only_to_the_configured_origin(self):
opener = RecordingOpener()
auth = self.module.ApiAuth(
source="git credential",
authorization="Basic sensitive-value",
)
client = self.module.ApiClient(self.target, auth=auth, opener=opener)
status, body, _ = client.request(
self.target.repo_path("/actions/runs"), accept_json=True
)
self.assertEqual(status, 200)
self.assertEqual(body, b'{"ok": true}')
request, timeout = opener.requests[0]
self.assertEqual(request.get_header("Authorization"), "Basic sensitive-value")
self.assertEqual(timeout, self.module.REQUEST_TIMEOUT)
with self.assertRaisesRegex(self.module.ConfigError, "origin"):
client.request("https://evil.example/actions/runs", accept_json=True)
self.assertEqual(len(opener.requests), 1)
def test_api_client_rejects_cross_origin_redirects(self):
auth = self.module.ApiAuth(
source="git credential",
authorization="Basic sensitive-value",
)
handler = self.module.SameOriginRedirectHandler(self.target)
request = urllib.request.Request(
self.target.repo_path("/actions/runs"),
headers={"Authorization": auth.authorization},
)
with self.assertRaises(self.module.ConfigError) as raised:
handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://evil.example/actions/runs",
)
self.assertNotIn("sensitive-value", str(raised.exception))
def test_401_diagnostics_distinguish_missing_and_rejected_auth(self):
anonymous = self.module._explain_status(401, self.target, None)
self.assertIn("GITEA_TOKEN", anonymous)
self.assertIn("--use-git-credential", anonymous)
auth = self.module.ApiAuth(
source="git credential",
authorization="Basic sensitive-value",
)
rejected = self.module._explain_status(401, self.target, auth)
self.assertIn("git credential", rejected)
self.assertNotIn("sensitive-value", rejected)
def test_403_and_404_diagnostics_have_distinct_actions(self):
auth = self.module.ApiAuth(
source="GITEA_TOKEN",
authorization="token sensitive-value",
)
forbidden = self.module._explain_status(403, self.target, auth)
self.assertIn("permission", forbidden)
self.assertIn("GITEA_TOKEN", forbidden)
missing = self.module._explain_status(404, self.target, auth)
self.assertIn("endpoint", missing)
self.assertIn("version", missing)
self.assertNotIn("sensitive-value", forbidden + missing)
def test_skill_documents_explicit_git_credential_auth(self):
skill = (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("--use-git-credential", skill)
self.assertIn("GITEA_TOKEN", skill)
self.assertIn("HTTPS", skill)
if __name__ == "__main__":
unittest.main()