"""线程等待与移交操作(对照 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 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}