feat: 线程等待与状态读取 8 项(wait/read/message/handoff/worktree/pr-checks/automation/sleep)

对照 Codex app:wait_threads 首变+afterCursor+8上限、
read_thread 详情摘要、send_message_to_thread 复活失败任务、
handoff 登记与上报轮询、worktree 派生状态、PR checks JSON+
check-runs 日志与注释、automation GET/PATCH、clock.sleep
(API+host)。诚实边界见 docs/thread-wait-ops.md:无
worktree 阶段流、无 automation 变化静默、无独立测试结果
记录、handoff 服务端不执行迁移、wait 仅数据库级变化检测。

三套件:cloud 940 OK(4 跳过)、host 91 OK、deploy 29 OK
This commit is contained in:
host committed 2026-10-28 04:33:20 +08:00
1 parent 38c0797cb3
commit 755029f220
8 files changed
+2535

No files matched your search

+707
View File
@@ -0,0 +1,707 @@
"""线程等待与移交操作(对照 codex app 的 8 项工具面)。
本模块只做控制面纯逻辑,不跑模型、不联网、不 import cloud 其它
模块(与 automations / peer_messages 同款,避免循环 import);
server.py 只负责鉴权、路由与错误码映射。
8 项在控制面的落点(详见 docs/thread-wait-ops.md 对照表):
- read_thread:server 用任务行 + 链路快照 + 尽力 thread-read
合成摘要,本模块不重复造 thread 解析。
- get_handoff_status:task_handoffs 表(本模块)。移交本身
不是本模块发明的传输通道:记录只登记“某任务被交到某主机”
的意图与对方回报的进度;真正的执行仍在目标主机的 worker
上,状态靠 report 回写,绝不凭登记就谎称已交接。
- get_worktree_creation_status:派生状态(本模块
worktree_status)。云端任务的 worktree 由 worker 在认领后
同步建,没有独立的异步创建任务可轮询——本函数如实报告
目录存在与否、任务状态与环境状态,不编造百分比进度。
- read_pull_request_check_diagnostics:本模块只解析
`gh pr checks` 的输出;日志原文 gh 本身不给,失败原因
即未通过的 check 名单,见 parse_pr_checks。
- automation_update:cloud/automations.py 的 update_automation
(本批新增),本模块不另起一套自动化定义。
- send_message_to_thread:server 复用 followup 投递(queued /
direct),本模块不另建队列。
- clock.sleep:interruptible_sleep(步进睡眠,可被 stop_event
打断;server 的 /sleep 端点与 host.py 的 sleep 命令共用它)。
- wait_threads:poll_task_statuses(轮询回调取状态,直到
全部终态或超时;server 的 /wait 端点用 DB 回读作回调)。
表迁移:task_handoffs 是 SQLite 懒建表(与 pr_jobs /
automations 同款),不在 POSTGRES_SCHEMA 里,因此必须同步
进 cloud/migrations.py 的 UNMIGRATED_TABLES(test_p2_migrations
会实测“源码里真有 DDL 且不在 PG schema 里”)。
"""
import base64
import json
import os
import re
import time
import uuid
HANDOFF_STATUSES = ("pending", "accepted", "running",
"completed", "failed")
# report 允许的状态转移:终态(completed / failed)不可再动;
# pending 可进 accepted / running / 终态,accepted 可进 running /
# 终态,running 只能进终态。跳过中间态不算错(对方可能直接报
# completed),但终态回退、终态重报一律拒绝。
_HANDOFF_TRANSITIONS = {
"pending": ("accepted", "running", "completed", "failed"),
"accepted": ("running", "completed", "failed"),
"running": ("completed", "failed"),
"completed": (),
"failed": (),
}
HANDOFF_COLUMNS = (
"id", "task_id", "project_id", "source_host", "target_host",
"status", "progress", "detail", "created_by", "created_at",
"updated_at")
TERMINAL_TASK_STATUSES = frozenset(
{"completed", "failed", "interrupted", "merged", "merge-failed"})
MAX_SLEEP_SECONDS = 60
MAX_WAIT_SECONDS = 120
WAIT_POLL_SECONDS = 0.5
MAX_WAIT_THREADS = 8
# wait_threads 的重要变化只认这几类:终态、待审批、待用户输入、
# 待继续处理。running / queued 之间的流转、链路条目增加都
# 不算——否则一次等待会被每条进度刷屏,协调方永远等不到
# “第一个需要我处理”的线程。状态映射见 detect_significant_change。
SIGNIFICANT_KINDS = ("terminal", "needs_approval", "needs_input",
"needs_continue")
def row_get(row, name):
"""行取值容错:sqlite3.Row 缺列报 IndexError、dict 缺键报
KeyError、空行报 TypeError,一律回 None(与 automations
的 row_get 同款,老库行缺列时读移交列表不能因此炸掉)。"""
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
def _row_dict(row, columns):
if row is None or isinstance(row, dict):
return row
try:
row["id"]
return row
except TypeError:
return dict(zip(columns, row))
def ensure_handoffs_table(conn):
"""幂等建 task_handoffs 表。调用方在写成功后 commit
(server 的 handler 与 peer 模式同款:建表后由业务写
操作一并 commit;只读的 GET 也调它建表再读,空库回空)。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS task_handoffs("
"id TEXT PRIMARY KEY, task_id TEXT NOT NULL, "
"project_id TEXT NOT NULL, source_host TEXT, "
"target_host TEXT NOT NULL, status TEXT NOT NULL, "
"progress INTEGER NOT NULL DEFAULT 0, detail TEXT, "
"created_by TEXT, created_at REAL, updated_at REAL)"
)
def handoff_to_dict(row, now=None):
"""行 -> 对外 dict;progress 一律钳到 0-100,读侧也钳,
防止旧脏行把进度写成 120 或 -1 后原样漏到客户端。"""
r = _row_dict(row, HANDOFF_COLUMNS)
progress = row_get(r, "progress")
try:
progress = int(progress)
except (TypeError, ValueError):
progress = 0
progress = max(0, min(100, progress))
return {
"id": row_get(r, "id"),
"task_id": row_get(r, "task_id"),
"project_id": row_get(r, "project_id"),
"source_host": row_get(r, "source_host"),
"target_host": row_get(r, "target_host"),
"status": row_get(r, "status"),
"progress": progress,
"detail": row_get(r, "detail") or "",
"created_by": row_get(r, "created_by"),
"created_at": row_get(r, "created_at"),
"updated_at": row_get(r, "updated_at"),
}
def create_handoff(conn, task_id, project_id, target_host, created_by,
source_host=None, detail="", now=None):
"""登记一次移交:同一任务同时只能有一个未终态移交
(pending / accepted / running),否则抛 ValueError——
两个进行中的移交指向不同主机时,轮询方分不清该信哪一个。
成功时 commit 并返回 dict;校验失败抛 ValueError。"""
if not target_host or not isinstance(target_host, str) \
or not target_host.strip():
raise ValueError("target_host required")
target_host = target_host.strip()
if len(target_host) > 200:
raise ValueError("target_host too long")
if detail is not None and not isinstance(detail, str):
raise ValueError("detail must be a string")
detail = (detail or "").strip()
if len(detail) > 2000:
raise ValueError("detail too long")
if now is None:
now = time.time()
ensure_handoffs_table(conn)
existing = conn.execute(
"SELECT id FROM task_handoffs WHERE task_id = ?"
" AND status IN ('pending', 'accepted', 'running')"
" LIMIT 1",
(task_id,)).fetchone()
if existing is not None:
raise ValueError("task already has an open handoff")
handoff_id = uuid.uuid4().hex
conn.execute(
"INSERT INTO task_handoffs(id, task_id, project_id,"
" source_host, target_host, status, progress, detail,"
" created_by, created_at, updated_at)"
" VALUES(?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)",
(handoff_id, task_id, project_id, source_host, target_host,
detail, created_by, now, now))
conn.commit()
row = conn.execute(
"SELECT * FROM task_handoffs WHERE id = ?", (handoff_id,)
).fetchone()
return handoff_to_dict(row)
def get_handoff(conn, handoff_id):
"""按 id 取移交(跨项目时调用方自己比对 project_id 后
回 404,不在本函数里泄露跨项目存在性)。"""
ensure_handoffs_table(conn)
row = conn.execute(
"SELECT * FROM task_handoffs WHERE id = ?", (handoff_id,)
).fetchone()
if row is None:
return None
return handoff_to_dict(row)
def latest_handoff(conn, task_id):
"""某任务最新的一次移交(按 created_at、id 排序,时间
戳相同时 id 兜底,保证同一库两次读到同一条“最新”)。"""
ensure_handoffs_table(conn)
row = conn.execute(
"SELECT * FROM task_handoffs WHERE task_id = ?"
" ORDER BY created_at DESC, id DESC LIMIT 1",
(task_id,)).fetchone()
if row is None:
return None
return handoff_to_dict(row)
def report_handoff(conn, handoff_id, status, progress=None, detail=None,
now=None):
"""对方主机回报进度:返回 (ok, handoff_or_error)。
ok 为 False 时第二个值是错误文本(调用方按 400 / 409
映射:非法状态与非法进度是 400,非法转移是 409)。
progress 不传时保持旧值;传了则钳到 0-100 且终态时
progress 不强制改 100(对方可能以 failed 终态报 40%,
那是真实进度,不是“完成度”)。"""
if status not in HANDOFF_STATUSES:
return False, "status must be one of %s" % (
", ".join(HANDOFF_STATUSES),)
if progress is not None:
if isinstance(progress, bool) or not isinstance(progress, int):
return False, "progress must be an integer"
progress = max(0, min(100, progress))
if detail is not None and not isinstance(detail, str):
return False, "detail must be a string"
if detail is not None and len(detail) > 2000:
return False, "detail too long"
if now is None:
now = time.time()
ensure_handoffs_table(conn)
row = conn.execute(
"SELECT * FROM task_handoffs WHERE id = ?", (handoff_id,)
).fetchone()
if row is None:
return False, "handoff not found"
current = handoff_to_dict(row)
if status not in _HANDOFF_TRANSITIONS[current["status"]] \
and status != current["status"]:
# 同状态重报(例如 running 再报 running + 新进度)允许,
# 其它非法转移拒绝。
return False, ("cannot move handoff from %s to %s"
% (current["status"], status))
new_progress = current["progress"] if progress is None else progress
new_detail = current["detail"] if detail is None else detail.strip()
conn.execute(
"UPDATE task_handoffs SET status = ?, progress = ?,"
" detail = ?, updated_at = ? WHERE id = ?",
(status, new_progress, new_detail, now, handoff_id))
conn.commit()
row = conn.execute(
"SELECT * FROM task_handoffs WHERE id = ?", (handoff_id,)
).fetchone()
return True, handoff_to_dict(row)
def parse_pr_checks(output):
"""解析 `gh pr checks` 输出(制表符与人类可读两种格式,
与 host.py 的 parse_pr_checks 同规则,server 不能 import
host.py——host 不是包,server 侧只能自带一份解析)。
返回 (checks, all_pass, failed, pending):checks 为
[{name, state}],state 统一成 pass / fail / pending /
skipped / cancelled / neutral;没有任何 check 时 all_pass
为 False(空 PR 不能算全过);failed 是未通过的 check 名,
即“失败原因”的全部内容——gh checks 本身不给日志原文,
日志要到 GitHub 上该 check 的详情页看,本函数不编造
日志链接(check 行里没有 run id,拼不出可靠链接)。"""
checks = []
for raw in (output or "").splitlines():
line = raw.strip()
if not line or "no checks" in line.lower():
continue
if line.lower().startswith("name"):
continue
if "\t" in line:
parts = [p.strip() for p in line.split("\t") if p.strip()]
if len(parts) < 2:
continue
name, state_raw = parts[0], parts[1].lower()
else:
m = re.search(r"\b(pass|passing|fail|failing|failed|pending|"
r"queued|waiting|in_progress|skipped|cancelled|"
r"neutral)\b", line.lower())
if not m:
continue
state_raw = m.group(1)
name = line[:m.start()].strip(" \u2713\u2717\u25cf\u25cb-").strip()
if not name:
name = line
state = {"passing": "pass", "failing": "fail", "failed": "fail",
"queued": "pending", "waiting": "pending",
"in_progress": "pending"}.get(state_raw, state_raw)
checks.append({"name": name, "state": state})
all_pass = bool(checks) and all(c["state"] == "pass" for c in checks)
failed = [c["name"] for c in checks if c["state"] == "fail"]
pending = [c["name"] for c in checks if c["state"] == "pending"]
return checks, all_pass, failed, pending
def worktree_status(task_status, worktree_path, exists, is_git,
environment_status=None, remote_host=None):
"""派生 worktree 创建状态(纯函数,server 负责把文件系统
事实传进来,单测不用真的建目录)。
status 取值:
- remote:worktree 在别机,本机查不到,exists 一律 None。
- ready:目录在且含 .git(git worktree 或仓库检出)。
- present_no_git:目录在但无 .git(非 git 任务的独立目录
拷贝,host.py make_worktree 的退化形态,不是失败)。
- creating:任务还在 queued / running 且目录未出现——
worker 尚未建出来或正在建,轮询方应继续等。
- missing:终态任务且目录不在——merged 后 worktree 已删
是预期行为,不是“创建失败”;queued / running 之外的
终态也同此理,调用方看 task_status 区分。
- unknown:其它组合(例如目录不在且任务状态不在上述
两类),不硬编一个结论。
environment_status(任务绑 environment 时)原样透传,
它是真正异步的创建过程(creating -> ready / failed)。"""
if remote_host:
return {"status": "remote", "path": worktree_path,
"exists": None, "is_git": None,
"task_status": task_status,
"environment_status": environment_status,
"note": "worktree is on host %s; this host cannot"
" see it" % remote_host}
if exists and is_git:
status, note = "ready", ""
elif exists:
status, note = "present_no_git", (
"directory exists without .git (non-git task copy)")
elif task_status in ("queued", "running"):
status, note = "creating", (
"worktree not created yet; worker creates it on claim")
elif task_status in TERMINAL_TASK_STATUSES:
status, note = "missing", (
"worktree not present for a terminal task"
" (merged tasks delete it by design)")
else:
status, note = "unknown", ""
return {"status": status, "path": worktree_path,
"exists": bool(exists), "is_git": bool(is_git),
"task_status": task_status,
"environment_status": environment_status,
"note": note}
def interruptible_sleep(seconds, stop_event=None, step=0.5):
"""步进睡眠(clock.sleep 的控制面原语):seconds 钳到
[0, MAX_SLEEP_SECONDS];stop_event 被置位时提前返回
(slept, True),否则睡满返回 (slept, False)。server 的
/sleep 端点不传 stop_event(客户端断开不中断 handler,
上限 60 秒);host.py 的 sleep 命令用 SIGINT 转 stop。"""
try:
seconds = float(seconds)
except (TypeError, ValueError):
seconds = 0.0
seconds = max(0.0, min(float(MAX_SLEEP_SECONDS), seconds))
slept = 0.0
while slept < seconds:
if stop_event is not None and stop_event.is_set():
return slept, True
chunk = min(step, seconds - slept)
time.sleep(chunk)
slept += chunk
return slept, False
def poll_task_statuses(fetch_statuses, timeout_seconds=30.0,
poll_seconds=WAIT_POLL_SECONDS, now=None):
"""wait_threads 的轮询核:fetch_statuses() 回一个
{task_id: status},直到全部终态或超时。
返回 (all_terminal, statuses, waited_seconds, timed_out):
空任务集立即回 all_terminal=True(没有可等的东西,
不是错误);fetch 抛异常时不吞——调用方(server)按
502 处理,轮询核绝不能把 DB 故障伪装成“超时”。"""
try:
timeout_seconds = float(timeout_seconds)
except (TypeError, ValueError):
timeout_seconds = 30.0
timeout_seconds = max(0.0, min(float(MAX_WAIT_SECONDS),
timeout_seconds))
start = time.time() if now is None else now()
statuses = fetch_statuses()
if all(s in TERMINAL_TASK_STATUSES for s in statuses.values()):
return True, statuses, 0.0, False
while True:
elapsed = (time.time() - start) if now is None else (now() - start)
if elapsed >= timeout_seconds:
return False, statuses, elapsed, True
time.sleep(min(poll_seconds, max(0.0, timeout_seconds - elapsed)))
statuses = fetch_statuses()
if all(s in TERMINAL_TASK_STATUSES for s in statuses.values()):
elapsed = (time.time() - start) if now is None \
else (now() - start)
return True, statuses, elapsed, False
# -- wait_threads:首变 + 游标 ------------------------------------
# 摘要形态(server 的 fetch 回调产出,键名固定):
# {"status": str, "pending_approvals": int,
# "pending_input": int, "pending_followups": int}
# pending_input 是 kind=elicitation 的待审批(向用户要输入),
# 其余 kind(command / file_change / permissions 等)计入
# pending_approvals(要审批)。server 不读链路文件做变化
# 检测:每 0.5 秒全量读 8 个任务的 jsonl 是浪费,状态、
# 审批、后续消息三张表已足够判断“需不需要人处理”。
def encode_wait_cursor(summaries):
"""摘要 -> 不透明游标(base64 的 JSON,调用方原样回传,
不解析、不拼接)。游标只含状态计数,不含任何正文。"""
raw = json.dumps(summaries, ensure_ascii=False,
sort_keys=True).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii")
def decode_wait_cursor(cursor):
"""游标 -> 摘要;非法游标抛 ValueError(调用方转 400,
绝不静默当成“无游标”——那样会把旧变化再报一遍,
违背 afterCursor“只取新变化”的契约)。"""
if not isinstance(cursor, str) or not cursor:
raise ValueError("after_cursor must be a non-empty string")
try:
raw = base64.urlsafe_b64decode(cursor.encode("ascii"))
data = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
raise ValueError("after_cursor is not a valid cursor")
if not isinstance(data, dict):
raise ValueError("after_cursor is not a valid cursor")
for value in data.values():
if not isinstance(value, dict):
raise ValueError("after_cursor is not a valid cursor")
return data
def _summary_numbers(summary):
"""摘要里的计数统一成 int(缺键、脏值一律按 0 计,
不让旧脏行把等待逻辑炸掉)。"""
def _int(value):
try:
return int(value)
except (TypeError, ValueError):
return 0
return (_int(summary.get("pending_approvals")),
_int(summary.get("pending_input")),
_int(summary.get("pending_followups")))
def significant_now(summary):
"""当前摘要是否本身就是“重要状态”(无游标首次等待时用):
终态、待审批、待输入、待继续处理任一成立即算。"""
status = (summary or {}).get("status")
approvals, inputs, followups = _summary_numbers(summary or {})
if status in TERMINAL_TASK_STATUSES:
return "terminal"
if inputs > 0:
return "needs_input"
if approvals > 0:
return "needs_approval"
if followups > 0:
return "needs_continue"
return None
def detect_significant_change(baseline, current, task_ids):
"""按 task_ids 顺序找第一个重要变化,返回
(task_id, kind, detail) 或 None。
- baseline 为 None:只认 significant_now(首次等待,
已终态 / 已阻塞的线程应立即返回,不用等一轮)。
- baseline 为 dict:只认相对 baseline 的新增变化;
baseline 里没有的任务(调用方在新一轮里加进来的)
按“无变化”处理,不把它的当前状态误报成新变化。
- 优先级按检查顺序:终态 > 待输入 > 待审批 > 待继续,
同一任务多类变化时只报最先的一类;不同任务时按
task_ids 顺序,第一个有变化的胜出(“第一个发生
重要变化的线程”按列表顺序定先后,轮询本身没有
跨任务的精确时间戳可比)。"""
for task_id in task_ids:
cur = current.get(task_id)
if not isinstance(cur, dict):
continue
if baseline is None:
kind = significant_now(cur)
if kind is not None:
return task_id, kind, {"status": cur.get("status")}
continue
base = baseline.get(task_id)
if not isinstance(base, dict):
continue
cur_status = cur.get("status")
base_status = base.get("status")
if cur_status in TERMINAL_TASK_STATUSES \
and cur_status != base_status:
return task_id, "terminal", {"status": cur_status}
_ba, bi, _bf = _summary_numbers(base)
ca, ci, cf = _summary_numbers(cur)
if ci > bi:
return task_id, "needs_input", {
"pending_input": ci, "status": cur_status}
if ca > _ba:
return task_id, "needs_approval", {
"pending_approvals": ca, "status": cur_status}
if cf > _bf:
return task_id, "needs_continue", {
"pending_followups": cf, "status": cur_status}
return None
def poll_for_first_change(fetch_summaries, task_ids, baseline=None,
timeout_seconds=30.0,
poll_seconds=WAIT_POLL_SECONDS):
"""wait_threads 的轮询核:fetch_summaries() 回
{task_id: 摘要},直到第一个重要变化或超时。
返回 (changed, summaries, waited_seconds, timed_out):
changed 为 None 表示超时(此时 summaries 是超时那一刻
的进度,调用方用它 + 新游标继续等);changed 非空时
为 {"task_id": ..., "kind": ..., "detail": {...}}。
fetch 抛异常不吞——调用方按 502 处理,不能把 DB 故障
伪装成“超时无变化”。"""
try:
timeout_seconds = float(timeout_seconds)
except (TypeError, ValueError):
timeout_seconds = 30.0
timeout_seconds = max(0.0, min(float(MAX_WAIT_SECONDS),
timeout_seconds))
start = time.time()
summaries = fetch_summaries()
found = detect_significant_change(baseline, summaries, task_ids)
if found is not None:
task_id, kind, detail = found
return {"task_id": task_id, "kind": kind,
"detail": detail}, summaries, 0.0, False
if baseline is None:
baseline = summaries
while True:
elapsed = time.time() - start
if elapsed >= timeout_seconds:
return None, summaries, elapsed, True
time.sleep(min(poll_seconds,
max(0.0, timeout_seconds - elapsed)))
summaries = fetch_summaries()
found = detect_significant_change(baseline, summaries, task_ids)
if found is not None:
task_id, kind, detail = found
return {"task_id": task_id, "kind": kind,
"detail": detail}, summaries, time.time() - start, False
# -- read_pull_request_check_diagnostics:check-runs --------------
# gh pr checks --json 给出的字段:name / state / bucket /
# workflow / link / description / event / startedAt /
# completedAt。state 与 bucket 都可能出现,统一成
# pass / fail / pending / skipped / cancelled / neutral
# (bucket 的 skipping -> skipped、cancel -> cancelled)。
def _normalize_check_state(raw):
state = str(raw or "").lower()
return {"passing": "pass", "failing": "fail", "failed": "fail",
"queued": "pending", "waiting": "pending",
"in_progress": "pending", "skipping": "skipped",
"cancel": "cancelled"}.get(state, state)
def _normalize_json_checks(payload):
"""gh pr checks --json 的输出(list)-> checks 列表。
非 list、缺 name 的行直接跳过,不因此让整批诊断失败。"""
checks = []
if not isinstance(payload, list):
return checks
for item in payload:
if not isinstance(item, dict) or not item.get("name"):
continue
state = _normalize_check_state(
item.get("bucket") or item.get("state"))
checks.append({
"name": item["name"], "state": state,
"workflow": item.get("workflow") or "",
"link": item.get("link") or "",
"description": item.get("description") or "",
"event": item.get("event") or "",
})
return checks
def build_pr_diagnostics(run, pr_number, owner_repo=None, head_sha=None):
"""PR 诊断的组装核:run(cmd) -> (returncode, stdout, stderr),
server 用 cloud.pr._run 适配它(cwd=worktree,runner 可注入)。
步骤(任一步失败都不让已拿到的 checks 白费):
1. `gh pr checks --json ...` 拿 workflow / link / description;
JSON 解析失败时回落制表符解析(老版 gh / 假 runner)。
两路都失败才抛 RuntimeError(调用方转 502)。
2. 有 owner_repo + head_sha 且有失败项时,走 check-runs API
拿失败日志原文与文件行号注释:先 list 找失败 run 的 id,
再逐个(上限 5 个)调单条 check-run 取 output.text 与
annotations(path / start_line / message,上限 20 条,
text 截 4000 字)。list 或单条失败时 check_runs 回
available=False + note,不因此 502——checks 本身已够
回答“哪个检查失败”,日志是加分项不是前置条件。
返回 dict:checks / all_pass / failed / diagnostics /
check_runs(list 或 {available: False, note})。"""
fields = ("bucket,completedAt,description,event,link,name,"
"startedAt,state,workflow")
rc, out, err = run(["gh", "pr", "checks", str(pr_number),
"--json", fields])
checks = []
if rc == 0:
try:
checks = _normalize_json_checks(json.loads(out or ""))
except ValueError:
checks = []
if not checks:
rc2, out2, err2 = run(["gh", "pr", "checks", str(pr_number)])
if rc2 != 0:
raise RuntimeError((err or err2 or "gh pr checks failed")[:500])
parsed, _ap, _f, _p = parse_pr_checks(out2 or "")
checks = [{"name": c["name"], "state": c["state"],
"workflow": "", "link": "",
"description": "", "event": ""}
for c in parsed]
all_pass = bool(checks) and all(
c["state"] == "pass" for c in checks)
failed = [c["name"] for c in checks if c["state"] == "fail"]
diagnostics = ("all checks pass" if all_pass else
("failed checks: %s" % ", ".join(failed) if failed
else "no checks reported"))
check_runs = {"available": False,
"note": "check-runs unavailable: "
"no owner/repo or head sha"}
if owner_repo and head_sha and failed:
try:
check_runs = _fetch_failed_check_runs(
run, owner_repo, head_sha, failed)
except Exception as exc:
check_runs = {"available": False,
"note": "check-runs fetch failed: %s"
% str(exc)[:200]}
return {"checks": checks, "all_pass": all_pass,
"failed": failed, "diagnostics": diagnostics,
"check_runs": check_runs}
def _fetch_failed_check_runs(run, owner_repo, head_sha, failed_names):
"""check-runs API 的两步取数(只查失败项,上限 5 个)。
list 端点回 {check_runs: [...]};单条回 {id, name,
conclusion, output: {title, summary, text, annotations}}。
annotations 缺省为空列表,不因缺字段而失败。"""
rc, out, _err = run(["gh", "api",
"repos/%s/commits/%s/check-runs"
% (owner_repo, head_sha)])
if rc != 0:
raise RuntimeError("gh api check-runs list failed")
try:
payload = json.loads(out or "")
except ValueError:
raise RuntimeError("gh api check-runs list is not JSON")
runs = payload.get("check_runs") if isinstance(payload, dict) else None
if not isinstance(runs, list):
raise RuntimeError("gh api check-runs list has no check_runs")
by_name = {}
for item in runs:
if isinstance(item, dict) and item.get("name"):
by_name.setdefault(item["name"], item)
details = []
for name in failed_names[:5]:
item = by_name.get(name)
if item is None or not item.get("id"):
details.append({"name": name, "available": False,
"note": "no check-run found for this check"})
continue
rc2, out2, _e2 = run(["gh", "api",
"repos/%s/check-runs/%s"
% (owner_repo, item["id"])])
if rc2 != 0:
details.append({"name": name, "available": False,
"note": "check-run fetch failed"})
continue
try:
single = json.loads(out2 or "")
except ValueError:
details.append({"name": name, "available": False,
"note": "check-run is not JSON"})
continue
output = single.get("output") if isinstance(single, dict) else None
if not isinstance(output, dict):
output = {}
annotations = []
for ann in output.get("annotations") or []:
if not isinstance(ann, dict):
continue
annotations.append({
"path": ann.get("path") or "",
"start_line": ann.get("start_line"),
"end_line": ann.get("end_line"),
"level": ann.get("annotation_level") or "",
"message": (ann.get("message") or "")[:500],
})
if len(annotations) >= 20:
break
details.append({
"name": name, "available": True,
"check_run_id": item["id"],
"conclusion": single.get("conclusion") or "",
"title": output.get("title") or "",
"summary": (output.get("summary") or "")[:1000],
"log_text": (output.get("text") or "")[:4000],
"annotations": annotations,
})
return {"available": True, "runs": details}