Merge pull request #595: Add remote triage receipts and the PR base gate
ci / svc-realcombo (pull_request) Successful in 1m52s
ci / test (pull_request) Successful in 7m6s
ci / test-pg (pull_request) Successful in 8m18s

This commit was merged in pull request #595.
This commit is contained in:
2026-10-16 17:12:30 +00:00
10 changed files with 2126 additions and 95 deletions
+2 -1
View File
@@ -1,7 +1,6 @@
name: ci
on:
pull_request:
branches: [main]
push:
branches: [main]
@@ -156,6 +155,8 @@ jobs:
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: PR base gate
run: python3 scripts/task_pr_check.py ci
- name: Install Go 1.27.1
run: |
set -euo pipefail
File diff suppressed because one or more lines are too long
+15 -4
View File
@@ -13,13 +13,13 @@ PLUGINS := plugins
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-start task-resume task-sync task-close task-finish facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-triage-decide task-start task-resume task-sync task-close task-finish task-pr-check facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit
version:
@echo "agent-system $(VERSION)"
TASK := scripts/task.sh
export PRECHECK_RECEIPT RECEIPT IMPORT_RECEIPT TASK_WRITERS_STOPPED
export PRECHECK_RECEIPT RECEIPT IMPORT_RECEIPT TASK_WRITERS_STOPPED TRIAGE_RECEIPT
TASK_WORK_MODE ?= delivery
export TASK_WORK_MODE
TASK_PARENT_BRANCH ?= $(PARENT)
@@ -39,10 +39,13 @@ precheck:
@bash $(TASK) precheck
task-triage:
@bash $(TASK) triage $(REQ)
@bash $(TASK) triage "$(REQ)"
task-triage-decide:
@VERDICT="$(VERDICT)" INITIATIVE="$(INITIATIVE)" REASON="$(REASON)" bash $(TASK) triage-decide
task-start:
@if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/<slug>"; exit 2; fi
@if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path>"; exit 2; fi
@bash $(TASK) start "$(NAME)"
task-resume:
@@ -57,6 +60,10 @@ task-close:
task-finish:
@bash $(TASK) finish $(ABANDON)
# PR base/head 门禁(两级分支):本地分支卡片期望目标 vs Gitea PR 与 origin;FINAL=1 用于 initiative 收口。
task-pr-check:
python3 scripts/task_pr_check.py local $(if $(PR),--pr $(PR)) $(if $(FINAL),--final)
# 证据写入:执行命令并把结果行追加进当前任务卡(AGENTS.md「证据与验证门禁」)。
# 本地执行只记 SELF;RECEIPT 捕获启动收据,IMPORT_RECEIPT 导入原始收据而不重跑。
# 用法:CMD="<单行命令>" make evidence [TAIL=15];退出码随被执行命令。
@@ -87,6 +94,10 @@ help:
@echo "make agent-exec-validate 校验 AGENT-EXECUTION.md(agent_exec validate)"
@echo "make agent-exec-apply RECEIPT=<path> 快进当前分支到成功收据的 transport"
@echo "make agent-exec-admit 校验暂存路径具备 transport 来源或属于 host 豁免"
@echo "make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> 以远端为准发现接纳中的 initiative 并写分流收据(候选存在时 exit 5,待决策)"
@echo "make task-triage-decide TRIAGE_RECEIPT=<path> VERDICT=JOIN|NEW [INITIATIVE=<branch>] REASON='<理由>' 记录显式分流决策(不按数量自动 JOIN)"
@echo "make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path> [KIND=...] [INITIATIVE=...] 建任务前核验分流收据(失败不建分支)"
@echo "make task-pr-check [PR=N] [FINAL=1] 核验当前任务分支 PR 的 base/head 门禁(远端为准;FINAL=1 用于 initiative 收口)"
agent-exec-validate:
PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py validate --repo .
+40 -21
View File
@@ -74,7 +74,7 @@ cmd_precheck() {
[ -n "$done_open" ] && echo "[cards] 已完成但未归档:$(echo $done_open)"
local initiatives
initiatives=$(active_initiatives | cut -d'|' -f1)
echo "[initiatives] active: $([ -n "$initiatives" ] && echo $initiatives || echo none)"
echo "[initiatives] local cards (not authoritative): $([ -n "$initiatives" ] && echo $initiatives || echo none)"
now=$(date +%s)
while read -r ref ts; do
[ -n "$ref" ] || continue
@@ -152,10 +152,28 @@ cmd_start() {
echo "$initiative" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent initiative branch"
case "${name#*/}" in "${initiative#*/}"-?*) ;; *) die "child slug must start with parent initiative slug plus '-'" ;; esac
fi
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> and decide first (no branch created)"
fetch_target
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first"
PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT")
VALIDATE_RECEIPT="$PRECHECK_RECEIPT" VALIDATE_RECEIPT_PURPOSE=start bash "${SCRIPT_DIR}/evidence.sh"
# 分流核验必须先于任何 worktree/分支创建:收据与当前远端候选不一致则无副作用拒绝。
[ -f "$TRIAGE_RECEIPT" ] || die "triage receipt missing: ${TRIAGE_RECEIPT} (no branch created)"
TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT")
local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind")
if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi
local triage_out triage_line triage_verdict triage_init triage_rsha triage_digest triage_note
if ! triage_out=$(python3 "${SCRIPT_DIR}/task_triage.py" "${triage_args[@]}"); then
printf '%s\n' "$triage_out" >&2
die "triage receipt rejected; no worktree or branch was created"
fi
triage_line=$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-verified\] //p')
triage_verdict=$(triage_field verdict "$triage_line")
triage_init=$(triage_field selected_initiative "$triage_line")
triage_rsha=$(triage_field requirement_sha256 "$triage_line")
triage_digest=$(triage_field receipt_sha256 "$triage_line")
[ -n "$triage_verdict" ] && [ -n "$triage_rsha" ] && [ -n "$triage_digest" ] || die "triage verification output unparsable; no branch created"
triage_note="- 需求分流:verdict=${triage_verdict};selected_initiative=${triage_init:-无};requirement_sha256=${triage_rsha};triage_receipt=${TRIAGE_RECEIPT};receipt_sha256=${triage_digest}"
check_worktree before-sync
# 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出
# 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。
@@ -220,6 +238,7 @@ cmd_start() {
- 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
- 工作区:${wt}(worktree);分支:${name}
${branch_meta}
${triage_note}
${parent_note}
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
@@ -473,28 +492,27 @@ resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验
fi
}
# 需求分流以 origin 为准(scripts/task_triage.py):远端 open PR + 远端分支卡片;本地 .task/ 不参与发现。
# 计数不自动选择 JOIN;候选存在时必须经 triage-decide 记录显式决策。
cmd_triage() {
need_repo
local req="${*:-}" list n line b id c
[ -n "$req" ] && echo "[triage] requirement: ${req}"
list=$(active_initiatives)
n=$(printf '%s' "$list" | grep -c '|' || true)
echo "[triage] active initiatives: ${n}"
while IFS= read -r line; do
[ -n "$line" ] || continue
IFS='|' read -r b id c <<<"$line"
echo "[initiative] branch=${b} initiative_id=${id:-无} card=${c}"
done <<<"$list"
if [ "$n" -eq 0 ]; then
echo "[verdict] NEW — no active initiative; start one with make task-start NAME=feature/<slug> KIND=initiative (or KIND=standalone)"
elif [ "$n" -eq 1 ]; then
b=${list%%|*}
echo "[verdict] JOIN — join initiative ${b}: make task-start NAME=${b}-<topic> KIND=child INITIATIVE=${b}"
else
echo "[verdict] DECISION_REQUIRED — ${n} active initiatives; the user must choose which one receives this requirement (or approve a new one)"
return 5
fi
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=/tmp/<name>.json"
local req="${*:-}"
[ -n "$req" ] || die "requirement text required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path>"
python3 "${SCRIPT_DIR}/task_triage.py" discover --receipt "$TRIAGE_RECEIPT" --requirement "$req"
}
cmd_triage_decide() {
need_repo
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage-decide TRIAGE_RECEIPT=<path> VERDICT=JOIN|NEW REASON='<理由>'"
local -a args=(decide --receipt "$TRIAGE_RECEIPT" --verdict "${VERDICT:-}" --reason "${REASON:-}")
if [ -n "${INITIATIVE:-}" ]; then args+=(--initiative "$INITIATIVE"); fi
python3 "${SCRIPT_DIR}/task_triage.py" "${args[@]}"
}
# triage_field KEY "key=value key2=value2" → value(值不含空格)
triage_field() { printf '%s\n' "$2" | tr ' ' '\n' | sed -n "s/^$1=//p"; }
card_merge() { # $1=branch → "sha|subject|pr"(该分支合入 origin/<target> 的记录;兼容 merge 提交与 squash 落地)
local hit pr
hit=$(git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
@@ -769,10 +787,11 @@ cmd_finish() {
case "${1:-}" in
precheck) shift; cmd_precheck "$@" ;;
triage) shift; cmd_triage "$@" ;;
triage-decide) shift; cmd_triage_decide "$@" ;;
start) shift; cmd_start "$@" ;;
resume) shift; cmd_resume "$@" ;;
sync) shift; cmd_sync "$@" ;;
close) shift; cmd_close "$@" ;;
finish) shift; cmd_finish "$@" ;;
*) echo "usage: task.sh <precheck|triage|start|resume|sync|close|finish> [args]"; exit 2 ;;
*) echo "usage: task.sh <precheck|triage|triage-decide|start|resume|sync|close|finish> [args]"; exit 2 ;;
esac
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""PR base/head 门禁(两级分支模型:child → initiative → main)。
子命令:
local [--pr N] [--final]
核验当前检出的任务分支及其 PR(事实来源是 Gitea 与 origin):PR 处于开放状态、base 为卡片期望目标、
PR head 与本地 HEAD 一致、origin 分支头与本地 HEAD 一致、期望目标分支存在;child 的父 initiative
须仍有开放 PR 指向其 root_target。--final(仅 initiative):accepting_requirements=false,且
`## 子任务登记` 中「必需=是」的子任务为「已合入」(PR 已合并入本 initiative)或「已延期」。
ci
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片,核对 GITHUB_BASE_REF。
不联网、不需要 token;非 pull_request 事件跳过(退出 0)。
期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。
退出码:0 通过;1 门禁拒绝(PR_CHECK_FAILED);2 用法;6 远端无法核验(REMOTE_UNVERIFIED,fail closed)。
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
"""
import argparse
import os
import re
import subprocess
import sys
import urllib.parse
from pathlib import Path
from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, http_get_json,
open_pulls, read_token, repo_slug, target_branch)
EXIT_FAILED = 1
EXIT_USAGE = 2
CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR")
DONE_STATES = ("已合入", "已延期")
def sh(*args):
return subprocess.run(["git", *args], capture_output=True, text=True,
encoding="utf-8", errors="replace")
def standalone(target):
return {"kind": "standalone", "initiative_id": "", "root_target": target,
"immediate_target": target, "accepting": "false"}
def card_meta(text, target):
"""两级分支元数据行;无该行即旧卡,按 standalone 处理(与 task.sh 的 card_branch_kind 一致)。"""
match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M)
if not match:
return standalone(target)
line = match.group(1)
def field(name, default):
found = re.search(";" + name + r":([^;]*)", line)
return found.group(1).strip() if found else default
return {"kind": line.split(";", 1)[0].strip(), "initiative_id": field("initiative_id", ""),
"root_target": field("root_target", target),
"immediate_target": field("immediate_target", target),
"accepting": field("accepting_requirements", "false")}
def expected_base(meta):
return meta["immediate_target"] if meta["kind"] == "child" else meta["root_target"]
def find_card(branch):
"""只看检出树的 .task/ 顶层(archive 不参与);返回 (路径, 文本),无卡返回 (None, None)。"""
found = []
for path in sorted(Path(".task").glob("*.md")):
text = path.read_text(encoding="utf-8", errors="replace")
if card_branch(text) == branch:
found.append((path, text))
if len(found) > 1:
raise Stop(EXIT_FAILED, f"ambiguous task cards for {branch}: {', '.join(str(p) for p, _ in found)}")
return found[0] if found else (None, None)
def branch_meta(branch, target):
_, text = find_card(branch)
return (standalone(target) if text is None else card_meta(text, target)), text
def current_branch():
proc = sh("symbolic-ref", "--quiet", "--short", "HEAD")
if proc.returncode != 0 or not proc.stdout.strip():
raise Stop(EXIT_USAGE, "detached HEAD: check out the task branch first")
return proc.stdout.strip()
def local_head():
proc = sh("rev-parse", "--verify", "-q", "HEAD^{commit}")
if proc.returncode != 0:
raise Stop(EXIT_USAGE, "cannot resolve local HEAD")
return proc.stdout.strip()
def remote_head(branch):
proc = sh("ls-remote", "origin", f"refs/heads/{branch}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "git ls-remote origin failed")
for line in proc.stdout.splitlines():
sha, _, ref = line.partition("\t")
if ref == f"refs/heads/{branch}":
return sha
return None
def remote_context():
origin = sh("remote", "get-url", "origin")
if origin.returncode != 0:
raise Stop(EXIT_REMOTE, "origin remote is not configured")
slug = repo_slug(origin.stdout.strip())
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
return api_base(), slug, token
def pull_request(api, slug, token, number):
owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/"))
pr = http_get_json(f"{api}/repos/{owner}/{name}/pulls/{number}", token)
if not isinstance(pr, dict):
raise Stop(EXIT_REMOTE, f"Gitea PR #{number} response is malformed")
return pr
def base_ref(pr):
return as_dict(pr.get("base")).get("ref")
def head_ref(pr):
return as_dict(pr.get("head")).get("ref")
def head_sha(pr):
return as_dict(pr.get("head")).get("sha")
def parent_pr_open(api, slug, token, parent, root):
return any(isinstance(pr, dict) and pr.get("state") == "open"
and head_ref(pr) == parent and base_ref(pr) == root
for pr in open_pulls(api, slug, token))
def pr_number(cell):
for pattern in (r"pulls/(\d+)", r"#(\d+)", r"^(\d+)$"):
found = re.search(pattern, cell.strip())
if found:
return int(found.group(1))
return None
def child_registry(text):
"""解析 `## 子任务登记` 表(列:子任务分支 | 必需 | 状态 | PR);缺失即拒绝(fail closed)。"""
lines = text.splitlines()
starts = [i for i, line in enumerate(lines) if line.strip() == "## 子任务登记"]
if not starts:
raise Stop(EXIT_FAILED, "initiative card has no ## 子任务登记 section")
rows, columns = [], None
for line in lines[starts[0] + 1:]:
if line.startswith("## "):
break
if not line.strip().startswith("|"):
continue
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if columns is None:
if not all(name in cells for name in CHILD_COLUMNS):
raise Stop(EXIT_FAILED, "子任务登记 table must have columns 子任务分支 | 必需 | 状态 | PR")
columns = {name: cells.index(name) for name in CHILD_COLUMNS}
continue
if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
continue
if len(cells) <= max(columns.values()):
raise Stop(EXIT_FAILED, f"malformed 子任务登记 row: {line.strip()}")
rows.append({name: cells[index] for name, index in columns.items()})
if columns is None:
raise Stop(EXIT_FAILED, "子任务登记 section has no table")
return rows
def final_problems(api, slug, token, branch, meta, text):
problems = []
if meta["accepting"] != "false":
problems.append(f"initiative {branch} still accepts requirements "
f"(accepting_requirements={meta['accepting']}); set false before --final")
for row in child_registry(text):
name, required, status, pr_cell = row["子任务分支"], row["必需"], row["状态"], row["PR"]
if required == "否":
continue
if required != "是":
problems.append(f"child {name} has invalid 必需 value {required!r}")
elif status == "已合入":
number = pr_number(pr_cell)
if number is None:
problems.append(f"child {name} is 已合入 but PR cell {pr_cell!r} names no PR")
continue
pr = pull_request(api, slug, token, number)
if pr.get("merged") is not True or base_ref(pr) != branch:
problems.append(f"child {name} PR #{number} is not merged into {branch}")
elif status not in DONE_STATES:
problems.append(f"required child {name} is {status!r}; must be 已合入 or 已延期")
return problems
def verdict(problems, kind, base, head):
if problems:
for problem in problems:
print(f"[pr-check] FAIL {problem}")
print("[verdict] PR_CHECK_FAILED")
return EXIT_FAILED
print(f"[verdict] PR_CHECK_OK base={base} head={head} kind={kind}")
return 0
def cmd_local(args):
if args.pr is not None and args.pr < 1:
raise Stop(EXIT_USAGE, "--pr must be a positive PR number")
branch = current_branch()
head = local_head()
meta, text = branch_meta(branch, target_branch())
kind, expected = meta["kind"], expected_base(meta)
api, slug, token = remote_context()
if args.pr is not None:
pr = pull_request(api, slug, token, args.pr)
else:
pulls = [pr for pr in open_pulls(api, slug, token) if isinstance(pr, dict) and head_ref(pr) == branch]
if not pulls:
raise Stop(EXIT_FAILED, f"no open PR has head {branch}")
if len(pulls) > 1:
raise Stop(EXIT_FAILED, f"{len(pulls)} open PRs have head {branch}; pass --pr N")
pr = pulls[0]
number = pr.get("number")
problems = []
if pr.get("merged") is True:
problems.append(f"PR #{number} is already merged")
elif pr.get("state") != "open":
problems.append(f"PR #{number} is not open (state={pr.get('state')})")
if base_ref(pr) != expected:
problems.append(f"PR #{number} base is {base_ref(pr)!r}; expected {expected!r} for {kind} branch {branch}")
if head_sha(pr) != head:
problems.append(f"PR #{number} head {head_sha(pr) or '(none)'} != local HEAD {head}")
remote = remote_head(branch)
if remote is None:
problems.append(f"origin has no branch {branch}")
elif remote != head:
problems.append(f"origin/{branch} is {remote}, local HEAD is {head}")
if remote_head(expected) is None:
problems.append(f"base branch {expected} is absent on origin")
if kind == "child" and not parent_pr_open(api, slug, token, expected, meta["root_target"]):
problems.append(f"parent initiative {expected} has no open PR into {meta['root_target']}")
if args.final and kind == "initiative":
problems.extend(final_problems(api, slug, token, branch, meta, text))
return verdict(problems, kind, expected, head)
def cmd_ci(_args):
event = os.environ.get("GITHUB_EVENT_NAME", "")
if event != "pull_request":
print(f"[pr-check] skip: event {event or '(none)'} is not pull_request")
return 0
base = os.environ.get("GITHUB_BASE_REF", "").strip()
head = os.environ.get("GITHUB_HEAD_REF", "").strip()
if not base or not head:
raise Stop(EXIT_FAILED, "pull_request event lacks GITHUB_BASE_REF or GITHUB_HEAD_REF")
meta, _ = branch_meta(head, target_branch())
expected = expected_base(meta)
if base != expected:
raise Stop(EXIT_FAILED, f"PR base {base!r} differs from expected {expected!r} "
f"for {meta['kind']} branch {head}")
return verdict([], meta["kind"], base, head)
def main(argv=None):
parser = argparse.ArgumentParser(prog="task_pr_check.py", description="PR base/head 门禁(两级分支模型)")
sub = parser.add_subparsers(dest="cmd", required=True)
local = sub.add_parser("local")
local.add_argument("--pr", type=int)
local.add_argument("--final", action="store_true")
sub.add_parser("ci")
args = parser.parse_args(argv)
try:
return cmd_local(args) if args.cmd == "local" else cmd_ci(args)
except Stop as stop:
print(f"[pr-check] FAIL {stop.message}")
print("[verdict] REMOTE_UNVERIFIED" if stop.code == EXIT_REMOTE else "[verdict] PR_CHECK_FAILED")
return stop.code
if __name__ == "__main__":
sys.exit(main())
+505
View File
@@ -0,0 +1,505 @@
#!/usr/bin/env python3
"""远端需求分流收据(task.sh triage / triage-decide / start 共用的唯一分流依据)。
事实来源是 origin:`git ls-remote` 取分支头,Gitea open PR 取 base/head 关系,候选分支提交中的
任务卡取 initiative 元数据。本地 `.task/` 不参与发现。
子命令:
discover --receipt PATH --requirement TEXT
查询远端并写收据;无候选 -> NEW;有候选 -> DECISION_REQUIRED(退出 5,不自动选择 JOIN)。
decide --receipt PATH --verdict JOIN|NEW [--initiative BRANCH] --reason TEXT
复查远端;候选集合或任一候选的 HEAD/卡片/PR 字段变化则拒绝(退出 7,收据不改)。
verify --receipt PATH --kind standalone|initiative|child [--initiative BRANCH] [--max-age-seconds N]
复查远端并核对收据;供 task.sh start 在创建任何 worktree/分支之前调用(拒绝退出 8)。
退出码:0 成功;1 本地错误;2 用法;5 DECISION_REQUIRED;6 REMOTE_UNVERIFIED;7 决策被拒;8 核验被拒。
仅使用标准库。token 只读自环境变量 GITEA_TOKEN 或 ~/.secrets/gitea 的 ACCESS_TOKEN=,不打印、不写入收据。
"""
import argparse
import datetime as dt
import hashlib
import http.client
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
SCHEMA = "agent-system/task-triage/v1"
DEFAULT_API = "https://git-home.u1680832.nyat.app:30319/api/v1"
DEFAULT_TARGET = "main"
DEFAULT_MAX_AGE = 14400
PAGE_LIMIT = 50
MAX_PAGES = 100
SECRETS_FILE = "~/.secrets/gitea"
CARD_RE = re.compile(r"^\.task/[^/]+\.md$")
CANDIDATE_KEYS = ("branch", "head_sha", "card_path", "card_sha256", "pr_number", "pr_state", "pr_base", "pr_head_sha")
RECEIPT_KEYS = ("repo", "root_target", "root_target_sha", "requirement", "discovered_at",
"candidates", "discovery_verdict", "final_verdict", "decision")
EXIT_LOCAL = 1
EXIT_USAGE = 2
EXIT_DECISION_REQUIRED = 5
EXIT_REMOTE = 6
EXIT_DECIDE = 7
EXIT_VERIFY = 8
class Stop(Exception):
"""受控失败:退出码 + 不含 token 的说明。"""
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def utc_now():
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def parse_time(text):
value = dt.datetime.fromisoformat(text)
if value.tzinfo is None:
value = value.replace(tzinfo=dt.timezone.utc)
return value
def sha256_bytes(data):
return hashlib.sha256(data).hexdigest()
def as_dict(value):
return value if isinstance(value, dict) else {}
def target_branch():
return os.environ.get("TASK_TARGET_BRANCH", "").strip() or DEFAULT_TARGET
def git(*args):
return subprocess.run(["git", *args], capture_output=True)
def redact_url(url):
"""去掉 URL 中的 userinfo(https://user:pw@host 与 user@host:path 两种形式)。"""
if "://" in url:
parts = urllib.parse.urlsplit(url)
host = parts.netloc.rsplit("@", 1)[-1]
return urllib.parse.urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
scp = re.match(r"^[^/@]+@([^/]+:.*)$", url)
return scp.group(1) if scp else url
def repo_slug(origin_url):
slug = os.environ.get("GITEA_REPO", "").strip()
if not slug:
if "://" in origin_url:
path = urllib.parse.urlsplit(origin_url).path
else:
path = origin_url.split(":", 1)[1] if re.match(r"^[^/]+:", origin_url) else origin_url
segments = [s for s in path.split("/") if s]
if len(segments) < 2:
raise Stop(EXIT_REMOTE, "cannot derive owner/repo from origin; set GITEA_REPO")
name = segments[-1][:-4] if segments[-1].endswith(".git") else segments[-1]
slug = f"{segments[-2]}/{name}"
parts = slug.split("/")
if len(parts) != 2 or not all(parts) or any(p in (".", "..") for p in parts):
raise Stop(EXIT_REMOTE, "invalid owner/repo slug")
return slug
def api_base():
return (os.environ.get("GITEA_API_URL", "").strip() or DEFAULT_API).rstrip("/")
def read_token():
token = os.environ.get("GITEA_TOKEN", "").strip()
if token:
return token
try:
with open(os.path.expanduser(SECRETS_FILE), encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if line.startswith("- "):
line = line[2:].strip()
if line.startswith("ACCESS_TOKEN="):
return line.split("=", 1)[1].strip() or None
except (OSError, UnicodeDecodeError):
return None
return None
def remote_heads():
proc = git("ls-remote", "--heads", "origin")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "git ls-remote --heads origin failed")
heads = {}
for line in proc.stdout.decode("utf-8", "replace").splitlines():
sha, _, ref = line.partition("\t")
if ref.startswith("refs/heads/"):
heads[ref[len("refs/heads/"):]] = sha
return heads
def http_get_json(url, token):
request = urllib.request.Request(
url, headers={"Accept": "application/json", "Authorization": f"token {token}"})
try:
with urllib.request.urlopen(request, timeout=20) as response:
body = response.read()
except urllib.error.HTTPError as exc:
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}")
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})")
try:
return json.loads(body.decode("utf-8"))
except ValueError:
raise Stop(EXIT_REMOTE, "Gitea API returned invalid JSON")
def open_pulls(api, slug, token):
owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/"))
pulls = []
for page in range(1, MAX_PAGES + 1):
url = f"{api}/repos/{owner}/{name}/pulls?state=open&limit={PAGE_LIMIT}&page={page}"
batch = http_get_json(url, token)
if not isinstance(batch, list):
raise Stop(EXIT_REMOTE, "Gitea pulls response is not a list")
if not batch:
return pulls
pulls.extend(batch)
raise Stop(EXIT_REMOTE, f"open pull requests exceed {MAX_PAGES} pages")
def fetch_head(branch):
proc = git("fetch", "-q", "origin", f"refs/heads/{branch}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, f"cannot fetch origin/{branch}")
rev = git("rev-parse", "--verify", "-q", "FETCH_HEAD^{commit}")
if rev.returncode != 0:
raise Stop(EXIT_REMOTE, f"fetched origin/{branch} has no commit")
return rev.stdout.decode("utf-8", "replace").strip()
def task_card_paths(commit):
proc = git("ls-tree", "-r", "-z", "--name-only", commit, "--", ".task")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "cannot list task cards at candidate head")
names = proc.stdout.decode("utf-8", "replace").split("\0")
return sorted(name for name in names if CARD_RE.match(name))
def blob_bytes(commit, path):
proc = git("cat-file", "blob", f"{commit}:{path}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, f"cannot read {path} at candidate head")
return proc.stdout
def card_branch(text):
for line in text.splitlines():
match = re.match(r"^- 工作区:.*分支:([A-Za-z0-9._/-]+)", line)
if match:
return match.group(1)
return None
def card_status(text):
match = re.search(r";状态:([^;\n]*)", text)
return match.group(1).strip() if match else ""
def branch_meta(text, target):
"""分支元数据行 '- 分支类型:…;field:value…';旧卡缺省为 standalone、不接收需求。"""
match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M)
if not match:
return {"kind": "standalone", "initiative_id": "", "root_target": target, "accepting": "false"}
line = match.group(1)
def field(name, default):
found = re.search(";" + name + r":([^;]*)", line)
return found.group(1).strip() if found else default
return {
"kind": line.split(";", 1)[0].strip(),
"initiative_id": field("initiative_id", ""),
"root_target": field("root_target", target),
"accepting": field("accepting_requirements", "false"),
}
def goal_excerpt(text):
lines, inside = [], False
for line in text.splitlines():
if line.startswith("## "):
if inside:
break
inside = line.strip() == "## 目标与非目标"
continue
if inside and line.strip():
lines.append(line.strip())
if len(lines) == 5:
break
return " / ".join(lines)[:300]
def inspect_branch(target, branch, head_sha, pr):
fetched = fetch_head(branch)
if fetched != head_sha:
raise Stop(EXIT_REMOTE, f"{branch} moved during discovery; retry")
matches = []
for path in task_card_paths(head_sha):
raw = blob_bytes(head_sha, path)
text = raw.decode("utf-8", "replace")
if card_branch(text) != branch:
continue
meta = branch_meta(text, target)
if meta["kind"] != "initiative" or meta["accepting"] != "true" or meta["root_target"] != target:
continue
if card_status(text).startswith("已完成"):
continue
matches.append((path, raw, text, meta))
if not matches:
return None
if len(matches) > 1:
raise Stop(EXIT_REMOTE, f"ambiguous initiative cards on {branch}")
path, raw, text, meta = matches[0]
head = as_dict(pr.get("head"))
return {
"branch": branch,
"head_sha": head_sha,
"pr_number": pr["number"],
"pr_state": pr.get("state"),
"pr_base": as_dict(pr.get("base")).get("ref"),
"pr_head_sha": head.get("sha") or "",
"card_path": path,
"card_sha256": sha256_bytes(raw),
"initiative_id": meta["initiative_id"],
"goal_excerpt": goal_excerpt(text),
}
def candidates_from(target, api, slug, token, heads):
by_branch = {}
for pr in open_pulls(api, slug, token):
if not isinstance(pr, dict) or pr.get("state") != "open":
continue
base = as_dict(pr.get("base")).get("ref")
head = as_dict(pr.get("head")).get("ref")
if base != target or not isinstance(head, str) or head == target or head not in heads:
continue
if not isinstance(pr.get("number"), int):
raise Stop(EXIT_REMOTE, "malformed pull request number")
by_branch.setdefault(head, []).append(pr)
found = []
for branch in sorted(by_branch):
if len(by_branch[branch]) != 1:
raise Stop(EXIT_REMOTE, f"ambiguous: several open PRs target {target} from {branch}")
candidate = inspect_branch(target, branch, heads[branch], by_branch[branch][0])
if candidate:
found.append(candidate)
return found
def snapshot(target):
origin = git("remote", "get-url", "origin")
if origin.returncode != 0:
raise Stop(EXIT_REMOTE, "origin remote is not configured")
origin_url = origin.stdout.decode("utf-8", "replace").strip()
heads = remote_heads()
if target not in heads:
raise Stop(EXIT_REMOTE, f"origin has no {target} branch")
slug = repo_slug(origin_url)
api = api_base()
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
return {
"repo": {"origin_url": redact_url(origin_url), "api_base": redact_url(api), "repo_slug": slug},
"root_target": target,
"root_target_sha": heads[target],
"candidates": candidates_from(target, api, slug, token, heads),
}
def fingerprint(candidates):
return sorted(tuple(c[key] for key in CANDIDATE_KEYS) for c in candidates)
def check_shape(receipt):
missing = [key for key in RECEIPT_KEYS if key not in receipt]
if missing:
raise ValueError(f"missing {missing[0]}")
if not isinstance(receipt["candidates"], list):
raise ValueError("candidates")
for cand in receipt["candidates"]:
if not isinstance(cand, dict) or any(key not in cand for key in CANDIDATE_KEYS):
raise ValueError("candidate")
if not isinstance(as_dict(receipt["requirement"]).get("sha256"), str):
raise ValueError("requirement")
parse_time(receipt["discovered_at"])
def load_receipt(path, code):
try:
with open(path, "rb") as fh:
raw = fh.read()
except OSError:
raise Stop(code, f"receipt missing or unreadable: {path}")
try:
receipt = json.loads(raw.decode("utf-8"))
except ValueError:
raise Stop(code, "receipt is not valid JSON")
if not isinstance(receipt, dict) or receipt.get("schema") != SCHEMA:
raise Stop(code, f"receipt schema is not {SCHEMA}")
try:
check_shape(receipt)
except (ValueError, TypeError):
raise Stop(code, "receipt is malformed")
return receipt, raw
def write_receipt(path, receipt):
data = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
tmp = f"{path}.tmp-{os.getpid()}"
try:
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(data)
os.replace(tmp, path)
except OSError as exc:
raise Stop(EXIT_LOCAL, f"cannot write receipt {path}: {exc.strerror}")
def describe(cand):
return (f"[candidate] branch={cand['branch']} head_sha={cand['head_sha']} pr=#{cand['pr_number']} "
f"base={cand['pr_base']} card={cand['card_path']} card_sha256={cand['card_sha256']} "
f"initiative_id={cand['initiative_id'] or '无'}")
def cmd_discover(args):
text = args.requirement.strip()
if not text:
raise Stop(EXIT_USAGE, "requirement text is required")
snap = snapshot(target_branch())
now = utc_now()
receipt = {
"schema": SCHEMA,
**snap,
"requirement": {"sha256": sha256_bytes(text.encode("utf-8")),
"summary": " ".join(text.split())[:200]},
"discovered_at": now,
}
path = os.path.realpath(args.receipt)
if not snap["candidates"]:
receipt.update(discovery_verdict="NEW", final_verdict="NEW", decision={
"verdict": "NEW", "selected_initiative": None,
"rationale": "no accepting initiative", "decided_at": now})
write_receipt(args.receipt, receipt)
print(f"[verdict] NEW — 远端没有接纳中的 initiative;收据:{path}")
return 0
receipt.update(discovery_verdict="DECISION_REQUIRED", final_verdict="DECISION_REQUIRED", decision=None)
write_receipt(args.receipt, receipt)
print(f"[triage] 远端接纳中的 initiative 候选: {len(snap['candidates'])}(以 origin 为准,本地 .task 不参与)")
for cand in snap["candidates"]:
print(describe(cand))
if cand["goal_excerpt"]:
print(f"[goal] branch={cand['branch']} {cand['goal_excerpt']}")
print(f"[verdict] DECISION_REQUIRED — 须人工决策:JOIN 其一,或 NEW 并说明不适配理由;收据:{path}")
for cand in snap["candidates"]:
print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=JOIN "
f"INITIATIVE={cand['branch']} REASON='<加入理由>'")
print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=NEW REASON='<不适配理由>'")
return EXIT_DECISION_REQUIRED
def cmd_decide(args):
if args.verdict not in ("JOIN", "NEW"):
raise Stop(EXIT_DECIDE, "verdict must be JOIN or NEW")
receipt, _ = load_receipt(args.receipt, EXIT_DECIDE)
if receipt["discovery_verdict"] != "DECISION_REQUIRED":
raise Stop(EXIT_DECIDE, f"no decision needed: discovery verdict is {receipt['discovery_verdict']}")
reason = args.reason.strip()
if not reason:
raise Stop(EXIT_DECIDE, "--reason is required")
initiative = args.initiative.strip()
if args.verdict == "JOIN" and not initiative:
raise Stop(EXIT_DECIDE, "JOIN requires --initiative")
if args.verdict == "NEW" and initiative:
raise Stop(EXIT_DECIDE, "NEW must not select an initiative")
current = snapshot(receipt["root_target"])
if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]):
raise Stop(EXIT_DECIDE, "candidate set changed since discovery; re-run make task-triage")
if args.verdict == "JOIN" and initiative not in {c["branch"] for c in receipt["candidates"]}:
raise Stop(EXIT_DECIDE, f"{initiative} is not a discovered candidate")
receipt["decision"] = {"verdict": args.verdict, "selected_initiative": initiative or None,
"rationale": reason, "decided_at": utc_now()}
receipt["final_verdict"] = args.verdict
write_receipt(args.receipt, receipt)
print(f"[decided] verdict={args.verdict} selected_initiative={initiative or '无'} "
f"receipt={os.path.realpath(args.receipt)}")
return 0
def cmd_verify(args):
receipt, raw = load_receipt(args.receipt, EXIT_VERIFY)
final = receipt["final_verdict"]
decision = as_dict(receipt["decision"])
if final not in ("JOIN", "NEW") or decision.get("verdict") != final:
raise Stop(EXIT_VERIFY, "receipt has no final JOIN/NEW decision; run make task-triage-decide")
age = (dt.datetime.now(dt.timezone.utc) - parse_time(receipt["discovered_at"])).total_seconds()
if age > args.max_age_seconds:
raise Stop(EXIT_VERIFY, f"receipt is stale ({int(age)}s old, max {args.max_age_seconds}s); re-run make task-triage")
if age < -300:
raise Stop(EXIT_VERIFY, "receipt discovered_at is in the future")
if receipt["root_target"] != target_branch():
raise Stop(EXIT_VERIFY, "receipt root target does not match TASK_TARGET_BRANCH")
selected = decision.get("selected_initiative")
if args.kind == "child":
if final != "JOIN" or not args.initiative or args.initiative != selected:
raise Stop(EXIT_VERIFY, f"child requires a JOIN receipt selecting {args.initiative or '(none)'}")
elif final != "NEW":
raise Stop(EXIT_VERIFY, f"{args.kind} requires a NEW receipt, got {final}")
current = snapshot(receipt["root_target"])
if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]):
raise Stop(EXIT_VERIFY, "remote initiative candidates changed since triage; re-run make task-triage")
head = next((c["head_sha"] for c in receipt["candidates"] if c["branch"] == selected), None)
print(f"[triage-verified] verdict={final} selected_initiative={selected or '无'} "
f"requirement_sha256={receipt['requirement']['sha256']} receipt_sha256={sha256_bytes(raw)}")
print(f"[triage-head] selected_head_sha={head or '无'}")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(prog="task_triage.py", description="远端需求分流收据")
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("discover")
p.add_argument("--receipt", required=True)
p.add_argument("--requirement", required=True)
p = sub.add_parser("decide")
p.add_argument("--receipt", required=True)
p.add_argument("--verdict", required=True)
p.add_argument("--initiative", default="")
p.add_argument("--reason", default="")
p = sub.add_parser("verify")
p.add_argument("--receipt", required=True)
p.add_argument("--kind", required=True, choices=("standalone", "initiative", "child"))
p.add_argument("--initiative", default="")
p.add_argument("--max-age-seconds", type=int, default=DEFAULT_MAX_AGE)
args = parser.parse_args(argv)
handlers = {"discover": cmd_discover, "decide": cmd_decide, "verify": cmd_verify}
try:
return handlers[args.cmd](args)
except Stop as stop:
if stop.code == EXIT_REMOTE:
print(f"[verdict] REMOTE_UNVERIFIED — {stop.message}")
else:
print(f"[triage] refused ({stop.code}): {stop.message}", file=sys.stderr)
return stop.code
if __name__ == "__main__":
sys.exit(main())
+218 -67
View File
@@ -2,15 +2,65 @@
import os
import json
import hashlib
import http.server
import threading
from pathlib import Path
import shutil
import shlex
import subprocess
import tempfile
import unittest
import urllib.parse
SOURCE = Path(__file__).resolve().parents[1]
TRIAGE_COMMANDS = ("start", "triage", "triage-decide")
class FakeGitea:
"""Gitea pulls API 桩(进程内 127.0.0.1):校验 token 请求头,按页返回 self.prs;从不联网。"""
PAGE = 50
def __init__(self, token="dummy"):
self.token = token
self.prs = []
stub = self
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, status, obj):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.headers.get("Authorization") != f"token {stub.token}":
return self._send(401, {"message": "unauthorized"})
parsed = urllib.parse.urlsplit(self.path)
if not parsed.path.startswith("/api/v1/repos/") or not parsed.path.endswith("/pulls"):
return self._send(404, {"message": "not found"})
page = int(urllib.parse.parse_qs(parsed.query).get("page", ["1"])[0])
start = (page - 1) * stub.PAGE
return self._send(200, stub.prs[start:start + stub.PAGE])
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
@staticmethod
def pr(number, branch, sha, base="main", state="open"):
return {"number": number, "state": state, "base": {"ref": base},
"head": {"ref": branch, "sha": sha}}
class TaskLifecycleTests(unittest.TestCase):
@@ -20,6 +70,8 @@ class TaskLifecycleTests(unittest.TestCase):
self.root = Path(self.tmp.name)
self.repo = self.root / "repo"
self.remote = self.root / "remote.git"
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
self.env = os.environ.copy()
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
@@ -27,8 +79,11 @@ class TaskLifecycleTests(unittest.TestCase):
"GIT_COMMITTER_NAME": "Lifecycle Test", "GIT_COMMITTER_EMAIL": "test@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT",
"GIT_CONFIG_VALUE_1": "false",
# 桩位于 127.0.0.1:禁止经代理转发,保证测试不触达外部网络。
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL",
"GITEA_REPO", "TRIAGE_RECEIPT", "PRECHECK_RECEIPT",
"TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH",
"TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE", "TASK_BRANCH_KIND", "TASK_INITIATIVE"):
self.env.pop(key, None)
@@ -60,8 +115,14 @@ class TaskLifecycleTests(unittest.TestCase):
def task(self, cwd, command, *args, **kwargs):
if command == "finish" and "env" not in kwargs:
kwargs["env"] = self.env | {"TASK_WRITERS_STOPPED": "1"}
if command in TRIAGE_COMMANDS:
# 分流只读远端:注入桩地址与测试专用 token,绝不读取真实 ~/.secrets。
kwargs["env"] = self.gitea_env() | kwargs.get("env", self.env)
return self.run_cmd(cwd, "bash", str(self.repo / "scripts/task.sh"), command, *args, **kwargs)
def gitea_env(self):
return {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo", "GITEA_TOKEN": self.gitea.token}
def worktree(self, name="feature/test"):
wt = self.root / "worktree"
self.git(self.repo, "worktree", "add", "-b", name, str(wt), "origin/main")
@@ -676,13 +737,41 @@ class TaskLifecycleTests(unittest.TestCase):
self.run_cmd(cwd or self.repo, "bash", str(self.repo / "scripts/evidence.sh"), env=env)
return receipt
def triage_receipt(self, join=None, reason="fixture decision"):
"""经真实 task.sh triage 生成分流收据(对 Gitea 桩)。候选存在时按 join(分支名)或 NEW 显式决策。"""
receipt = self.root / f"triage-{len(list(self.root.glob('triage*.json')))}.json"
result = self.task(self.repo, "triage", "fixture requirement", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
if result.returncode == 5:
decide_env = self.env | {"TRIAGE_RECEIPT": str(receipt), "REASON": reason,
"VERDICT": "JOIN" if join else "NEW"}
if join:
decide_env["INITIATIVE"] = join
self.task(self.repo, "triage-decide", env=decide_env)
else:
self.assertEqual(result.returncode, 0, result.stdout)
return receipt
def publish_initiative(self, branch):
"""把 initiative 分支推到 origin 并登记 open PR(模拟远端接纳中的大任务)。"""
self.git(self.repo, "push", "origin", f"refs/heads/{branch}:refs/heads/{branch}")
sha = self.git(self.repo, "rev-parse", f"refs/heads/{branch}").stdout.strip()
known = [pr for pr in self.gitea.prs if pr["head"]["ref"] == branch]
if known:
known[0]["head"]["sha"] = sha
else:
self.gitea.prs.append(FakeGitea.pr(len(self.gitea.prs) + 1, branch, sha))
def test_start_requires_precheck_receipt_before_creating_worktree(self):
result = self.task(self.repo, "start", "feature/new", check=False)
triage = self.triage_receipt()
result = self.task(self.repo, "start", "feature/new", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse((self.root / "repo-wt-feature-new").exists(), result.stdout)
def test_start_preserves_merged_local_and_remote_branch_names(self):
receipt = self.capture_precheck()
triage = self.triage_receipt()
head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
for kind in ("local", "remote"):
with self.subTest(kind=kind):
@@ -695,7 +784,8 @@ class TaskLifecycleTests(unittest.TestCase):
ref = "refs/remotes/origin/" + name
self.git(self.repo, "fetch", "origin", "refs/heads/" + name + ":" + ref)
result = self.task(self.repo, "start", name, check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
env=self.env | {"PRECHECK_RECEIPT": str(receipt),
"TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("already merged", result.stdout)
self.assertFalse((self.root / ("repo-wt-" + name.replace("/", "-"))).exists())
@@ -703,11 +793,17 @@ class TaskLifecycleTests(unittest.TestCase):
def test_start_imports_precheck_and_creates_four_part_commit(self):
receipt = self.capture_precheck()
self.task(self.repo, "start", "feature/new", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
triage = self.triage_receipt()
self.task(self.repo, "start", "feature/new",
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
wt = self.root / "repo-wt-feature-new"
self.assertTrue((wt / ".task/feature-new.md").is_file())
card = (wt / ".task/feature-new.md").read_text()
self.assertIn("make precheck", card)
self.assertIn("- 需求分流:verdict=NEW;selected_initiative=无;requirement_sha256=", card)
self.assertIn(f"triage_receipt={triage.resolve()};receipt_sha256=", card)
branch_line = card.index("- 分支类型:standalone")
self.assertEqual(card.index("- 需求分流:"), card.index("\n", branch_line) + 1)
self.assertIn("交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)", card)
self.assertIn("实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)", card)
self.assertIn("人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收", card)
@@ -974,7 +1070,9 @@ class TaskLifecycleTests(unittest.TestCase):
self.git(parent, "commit", "-m", "Parent change")
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
receipt = self.capture_precheck(parent)
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_PARENT_BRANCH": "feature/parent",
triage = self.triage_receipt()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
"TASK_PARENT_BRANCH": "feature/parent",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/42"}
self.task(parent, "start", "feature/parent-child", env=env)
wt = self.root / "repo-wt-feature-parent-child"
@@ -985,17 +1083,20 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertEqual((wt / "parent-change").read_text(), "parent feature")
def test_start_offline_does_not_create_worktree(self):
triage = self.triage_receipt()
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.task(self.repo, "start", "feature/offline", check=False)
result = self.task(self.repo, "start", "feature/offline", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-offline").exists())
def test_start_rejects_receipt_when_authoritative_base_changed(self):
receipt = self.capture_precheck()
triage = self.triage_receipt()
self.advance_remote()
result = self.task(self.repo, "start", "feature/stale", check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse((self.root / "repo-wt-feature-stale").exists())
@@ -1032,80 +1133,77 @@ class TaskLifecycleTests(unittest.TestCase):
def start_initiative(self, name="feature/alpha"):
receipt = self.capture_precheck()
triage = self.triage_receipt()
self.task(self.repo, "start", name,
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": "initiative"})
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
"TASK_BRANCH_KIND": "initiative"})
return self.root / ("repo-wt-" + name.replace("/", "-"))
def start_child(self, name="feature/alpha-login", initiative="feature/alpha", check=True):
# 远端须已登记该 initiative(PR+卡片),子任务才能经真实 JOIN 收据启动。
if initiative:
self.publish_initiative(initiative)
triage = self.triage_receipt(join=initiative)
else:
triage = self.root / "missing-triage.json"
receipt = self.capture_precheck()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": "child",
"TASK_INITIATIVE": initiative,
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": initiative,
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
result = self.task(self.repo, "start", name, env=env, check=check)
return self.root / ("repo-wt-" + name.replace("/", "-")), result
def test_triage_reports_new_without_active_initiatives(self):
result = self.task(self.repo, "triage", "add login")
self.assertIn("[triage] requirement: add login", result.stdout)
self.assertIn("[triage] active initiatives: 0", result.stdout)
self.assertIn("[verdict] NEW", result.stdout)
self.assertNotIn("[initiative]", result.stdout)
def test_triage_without_receipt_is_refused(self):
result = self.task(self.repo, "triage", "add login", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("TRIAGE_RECEIPT required", result.stdout)
def test_triage_joins_single_active_initiative(self):
self.write_initiative_card("feature/alpha")
result = self.task(self.repo, "triage", "add login")
self.assertIn("[triage] active initiatives: 1", result.stdout)
self.assertIn("[initiative] branch=feature/alpha initiative_id=feature-alpha", result.stdout)
self.assertIn("[verdict] JOIN", result.stdout)
self.assertIn("INITIATIVE=feature/alpha", result.stdout)
def test_triage_ignores_local_cards_and_records_new_without_remote_initiative(self):
# main 上的本地卡片不是权威来源:远端没有 PR/分支时必须是 NEW,而不是按本地计数 JOIN。
self.write_initiative_card("feature/local-only")
receipt = self.triage_receipt()
data = json.loads(receipt.read_text())
self.assertEqual((data["discovery_verdict"], data["final_verdict"]), ("NEW", "NEW"))
self.assertEqual(data["candidates"], [])
self.assertIsNone(data["decision"]["selected_initiative"])
def test_triage_requires_decision_for_multiple_active_initiatives(self):
self.write_initiative_card("feature/alpha")
self.write_initiative_card("feature/beta")
result = self.task(self.repo, "triage", check=False)
def test_remote_initiative_needs_explicit_decision_and_never_auto_joins(self):
self.start_initiative("feature/alpha")
self.publish_initiative("feature/alpha")
receipt = self.root / "single-candidate.json"
result = self.task(self.repo, "triage", "add login", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
self.assertEqual(result.returncode, 5, result.stdout)
self.assertIn("[triage] active initiatives: 2", result.stdout)
self.assertIn("[initiative] branch=feature/alpha", result.stdout)
self.assertIn("[initiative] branch=feature/beta", result.stdout)
self.assertIn("[candidate] branch=feature/alpha", result.stdout)
self.assertIn("[verdict] DECISION_REQUIRED", result.stdout)
self.assertNotIn("[verdict] JOIN", result.stdout)
data = json.loads(receipt.read_text())
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
self.assertIsNone(data["decision"])
def test_triage_ignores_closed_merged_pending_and_non_accepting_initiatives(self):
self.write_initiative_card("feature/active")
self.write_initiative_card("feature/done", status="已完成(回填)")
self.write_initiative_card("feature/closed-intake", accepting="false")
self.write_initiative_card("feature/plain", kind="standalone")
self.write_initiative_card("feature/other-root", root="release")
self.write_initiative_card("feature/merged-init")
self.git(self.repo, "switch", "-c", "feature/merged-init")
(self.repo / "merged-work").write_text("landed\n")
self.git(self.repo, "add", "merged-work")
self.git(self.repo, "commit", "-m", "Merged initiative work")
self.git(self.repo, "switch", "main")
self.git(self.repo, "merge", "--no-ff", "feature/merged-init", "-m",
"Merge pull request 'Init' (#9) from feature/merged-init into main")
self.write_initiative_card("feature/pending-init")
self.git(self.repo, "switch", "-c", "feature/card-close-1")
self.git(self.repo, "mv", ".task/feature-pending-init.md", ".task/archive-tmp.md")
(self.repo / ".task/archive").mkdir()
self.git(self.repo, "mv", ".task/archive-tmp.md", ".task/archive/feature-pending-init.md")
self.git(self.repo, "commit", "-m", "Close pending initiative card")
self.git(self.repo, "switch", "main")
self.git(self.repo, "push", "origin", "main", "feature/card-close-1")
result = self.task(self.repo, "triage")
self.assertIn("[triage] active initiatives: 1", result.stdout)
self.assertIn("[initiative] branch=feature/active", result.stdout)
for branch in ("done", "closed-intake", "plain", "other-root", "merged-init", "pending-init"):
self.assertNotIn(f"branch=feature/{branch} ", result.stdout)
self.assertIn("[verdict] JOIN", result.stdout)
def test_multiple_remote_candidates_stop_at_decision_required(self):
self.start_initiative("feature/alpha")
self.publish_initiative("feature/alpha")
self.start_initiative("feature/beta")
self.publish_initiative("feature/beta")
receipt = self.root / "multi-candidate.json"
result = self.task(self.repo, "triage", "add login", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
self.assertEqual(result.returncode, 5, result.stdout)
self.assertIn("[candidate] branch=feature/alpha", result.stdout)
self.assertIn("[candidate] branch=feature/beta", result.stdout)
self.assertIn("[verdict] DECISION_REQUIRED", result.stdout)
self.assertNotIn("[verdict] JOIN", result.stdout)
self.assertEqual(json.loads(receipt.read_text())["final_verdict"], "DECISION_REQUIRED")
def test_precheck_reports_active_initiatives(self):
result = self.task(self.repo, "precheck", check=False)
self.assertIn("[initiatives] active: none", result.stdout)
self.assertIn("[initiatives] local cards (not authoritative): none", result.stdout)
self.write_initiative_card("feature/alpha")
self.write_initiative_card("feature/beta")
self.git(self.repo, "push", "origin", "main")
result = self.task(self.repo, "precheck", check=False)
self.assertIn("[initiatives] active: feature/alpha feature/beta", result.stdout)
self.assertIn("[initiatives] local cards (not authoritative): feature/alpha feature/beta", result.stdout)
def test_start_initiative_records_branch_metadata(self):
wt = self.start_initiative()
@@ -1120,7 +1218,9 @@ class TaskLifecycleTests(unittest.TestCase):
def test_start_standalone_records_default_metadata(self):
receipt = self.capture_precheck()
self.task(self.repo, "start", "feature/solo", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
triage = self.triage_receipt()
self.task(self.repo, "start", "feature/solo",
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
text = (self.root / "repo-wt-feature-solo/.task/feature-solo.md").read_text()
self.assertIn("- 分支类型:standalone;root_target:main;immediate_target:main;"
"accepting_requirements:false", text)
@@ -1199,8 +1299,51 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertEqual(self.card_helper("card_root_target", card), "main")
self.assertEqual(self.card_helper("card_immediate_target", card), "main")
self.assertEqual(self.card_helper("card_accepting_requirements", card), "false")
result = self.task(self.repo, "triage")
self.assertIn("[verdict] NEW", result.stdout)
receipt = self.triage_receipt()
self.assertEqual(json.loads(receipt.read_text())["final_verdict"], "NEW")
def test_start_without_triage_receipt_creates_no_worktree_or_branch(self):
receipt = self.capture_precheck()
result = self.task(self.repo, "start", "feature/untriaged", check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("TRIAGE_RECEIPT required", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-untriaged").exists())
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/untriaged").stdout.strip(), "")
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin",
"feature/untriaged").stdout.strip(), "")
def test_start_child_refuses_non_join_receipt_without_side_effects(self):
self.start_initiative("feature/alpha")
self.publish_initiative("feature/alpha")
new_receipt = self.triage_receipt() # 远端存在候选,显式 NEW 决策(非适配)
receipt = self.capture_precheck()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(new_receipt),
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
result = self.task(self.repo, "start", "feature/alpha-login", check=False, env=env)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("triage receipt rejected", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "")
def test_start_refuses_receipt_when_remote_initiative_moved_after_triage(self):
self.start_initiative("feature/alpha")
self.publish_initiative("feature/alpha")
join = self.triage_receipt(join="feature/alpha")
parent = self.root / "repo-wt-feature-alpha"
(parent / "moved").write_text("later initiative work\n")
self.git(parent, "add", "moved")
self.git(parent, "commit", "-m", "Move initiative after triage")
self.publish_initiative("feature/alpha")
receipt = self.capture_precheck()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(join),
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
result = self.task(self.repo, "start", "feature/alpha-login", check=False, env=env)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("remote initiative candidates changed", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
if __name__ == "__main__":
@@ -1222,9 +1365,13 @@ class FinishGuardFixTests(unittest.TestCase):
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "Guard Test", "GIT_AUTHOR_EMAIL": "guard@example.invalid",
"GIT_COMMITTER_NAME": "Guard Test", "GIT_COMMITTER_EMAIL": "guard@example.invalid",
"TASK_WRITERS_STOPPED": "1"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT"):
"TASK_WRITERS_STOPPED": "1",
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL",
"GITEA_REPO", "TRIAGE_RECEIPT", "PRECHECK_RECEIPT"):
self.env.pop(key, None)
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
self.git(self.root, "init", "--bare", "-b", "main", self.root + "/origin.git")
self.git(self.root, "init", "-b", "main", str(self.repo))
(self.repo / "tracked").write_text("base\n")
@@ -1248,8 +1395,12 @@ class FinishGuardFixTests(unittest.TestCase):
["bash", str(self.repo / "scripts" / "evidence.sh")], cwd=str(self.repo),
capture_output=True, text=True,
env=self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"}, check=True)
triage = Path(self.root) / "triage.json"
triage_env = self.env | {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo",
"GITEA_TOKEN": self.gitea.token}
self.task(self.repo, "triage", "guard fixture", env=triage_env | {"TRIAGE_RECEIPT": str(triage)})
self.task(self.repo, "start", "feature/guard",
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
env=triage_env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
wt = self.root + "/repo-wt-feature-guard"
assert Path(wt).is_dir(), "worktree not created"
return Path(wt)
+401
View File
@@ -0,0 +1,401 @@
"""PR base/head 门禁(scripts/task_pr_check.py):临时真实 Git 仓库(bare origin + clone)+ 进程内 Gitea 桩。
全部离线运行:不联网、不读取真实 ~/.secrets(HOME 指向临时目录),token 为合成值。
"""
import http.server
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
import urllib.parse
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "task_pr_check.py"
TOKEN = "synthetic-gitea-token-not-real"
def card_text(branch, kind, root="main", immediate="main", accepting="false", registry=None):
slug = branch.replace("/", "-")
lines = [
f"# {branch} 任务卡",
"任务主记录:fixture;状态:进行中;owner:PR Check Test",
"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= 0",
f"- 工作区:fixture(worktree);分支:{branch}",
f"- 分支类型:{kind};initiative_id:{slug};root_target:{root};"
f"immediate_target:{immediate};accepting_requirements:{accepting}",
"",
"## 目标与非目标",
"",
"- 交付 PR 门禁",
"",
]
if registry is not None:
lines += ["## 子任务登记", "", "| 子任务分支 | 必需 | 状态 | PR |", "| --- | --- | --- | --- |"]
lines += [f"| {branch_name} | {required} | {status} | {pr} |"
for branch_name, required, status, pr in registry]
lines.append("")
return "\n".join(lines) + "\n"
def pr(number, branch, base, sha, state="open", merged=False):
return {"number": number, "state": state, "merged": merged,
"base": {"ref": base}, "head": {"ref": branch, "sha": sha}}
class FakeGitea:
"""Gitea pulls API 桩(127.0.0.1):校验 token,列表(分页+state)与单 PR 查询;从不联网。"""
PAGE = 50
def __init__(self, token=TOKEN):
self.token = token
self.prs = []
stub = self
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, status, obj):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.headers.get("Authorization") != f"token {stub.token}":
return self._send(401, {"message": "unauthorized"})
parsed = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(parsed.query)
parts = parsed.path.split("/")
if parts[1:4] != ["api", "v1", "repos"] or len(parts) not in (7, 8) or parts[6] != "pulls":
return self._send(404, {"message": "not found"})
if len(parts) == 8:
found = [item for item in stub.prs if str(item["number"]) == parts[7]]
return self._send(200, found[0]) if found else self._send(404, {"message": "not found"})
state = query.get("state", [""])[0]
items = [item for item in stub.prs if not state or item["state"] == state]
page = int(query.get("page", ["1"])[0])
start = (page - 1) * stub.PAGE
return self._send(200, items[start:start + stub.PAGE])
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
def closed_port_url():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
return f"http://127.0.0.1:{port}/api/v1"
class PrCheckTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="pr-check-")
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.remote = root / "remote.git"
self.repo = root / "repo"
self.env = {k: v for k, v in os.environ.items()
if not k.startswith(("GITHUB_", "GIT_")) and k not in {
"GITEA_API_URL", "GITEA_REPO", "GITEA_TOKEN", "TASK_TARGET_BRANCH"}}
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "PR Check Test", "GIT_AUTHOR_EMAIL": "pr-check@example.invalid",
"GIT_COMMITTER_NAME": "PR Check Test", "GIT_COMMITTER_EMAIL": "pr-check@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false",
"HOME": str(root / "no-home"),
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost",
"GITEA_REPO": "fixture/repo", "GITEA_TOKEN": TOKEN})
self.git(root, "init", "--bare", "--initial-branch=main", str(self.remote))
self.git(root, "init", "--initial-branch=main", str(self.repo))
(self.repo / "README").write_text("fixture\n")
self.git(self.repo, "add", "README")
self.git(self.repo, "commit", "-m", "Create fixture")
self.git(self.repo, "remote", "add", "origin", str(self.remote))
self.git(self.repo, "push", "-u", "origin", "main")
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
self.env["GITEA_API_URL"] = self.gitea.url
# ---- 夹具 ----
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True,
text=True, check=True).stdout.strip()
def head(self, ref="HEAD"):
return self.git(self.repo, "rev-parse", ref)
def make_branch(self, branch, base="main"):
self.git(self.repo, "switch", "-c", branch, base)
self.git(self.repo, "push", "-u", "origin", branch)
return self.head()
def commit_card(self, branch, text, base="main"):
self.make_branch(branch, base)
path = self.repo / ".task" / (branch.replace("/", "-") + ".md")
path.parent.mkdir(exist_ok=True)
path.write_text(text, encoding="utf-8")
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Record {branch}")
self.git(self.repo, "push", "origin", branch)
return self.head()
def set_card(self, branch, text):
self.git(self.repo, "switch", branch)
path = self.repo / ".task" / (branch.replace("/", "-") + ".md")
path.write_text(text, encoding="utf-8")
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Update {branch}")
self.git(self.repo, "push", "origin", branch)
return self.head()
def commit_work(self, branch, push=True):
self.git(self.repo, "switch", branch)
count = len(list(self.repo.glob("work-*.txt")))
(self.repo / f"work-{count}.txt").write_text("more work\n")
self.git(self.repo, "add", ".")
self.git(self.repo, "commit", "-m", "More work")
if push:
self.git(self.repo, "push", "origin", "HEAD")
return self.head()
def build_child(self):
self.commit_card("feature/init", card_text("feature/init", "initiative", accepting="true"))
self.commit_card("feature/init-child",
card_text("feature/init-child", "child", root="main", immediate="feature/init"),
base="feature/init")
return self.head()
def invoke(self, *args, env=None, drop=()):
merged = {k: v for k, v in (self.env | (env or {})).items() if k not in drop}
result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo, env=merged,
capture_output=True, text=True, timeout=60)
result.out = result.stdout + result.stderr
self.assertNotIn(TOKEN, result.out)
return result
def local(self, *args, env=None, drop=()):
return self.invoke("local", *args, env=env, drop=drop)
@staticmethod
def fails(result):
return [line for line in result.stdout.splitlines() if line.startswith("[pr-check] FAIL")]
# ---- local:基址与分支类型 ----
def test_child_pr_into_main_is_rejected(self):
self.build_child()
self.gitea.prs = [pr(1, "feature/init", "main", self.head("feature/init")),
pr(2, "feature/init-child", "main", self.head())]
result = self.local()
self.assertEqual(result.returncode, 1, result.out)
self.assertEqual(len(self.fails(result)), 1, result.out)
self.assertIn("expected 'feature/init' for child branch feature/init-child", result.out)
self.assertIn("[verdict] PR_CHECK_FAILED", result.out)
def test_child_pr_into_initiative_passes(self):
self.build_child()
head = self.head()
self.gitea.prs = [pr(1, "feature/init", "main", self.head("feature/init")),
pr(2, "feature/init-child", "feature/init", head)]
result = self.local()
self.assertEqual(result.returncode, 0, result.out)
self.assertIn(f"[verdict] PR_CHECK_OK base=feature/init head={head} kind=child", result.out)
def test_child_is_rejected_when_parent_initiative_pr_is_closed(self):
self.build_child()
self.gitea.prs = [pr(1, "feature/init", "main", self.head("feature/init"), state="closed"),
pr(2, "feature/init-child", "feature/init", self.head())]
result = self.local()
self.assertEqual(result.returncode, 1, result.out)
self.assertEqual(len(self.fails(result)), 1, result.out)
self.assertIn("parent initiative feature/init has no open PR into main", result.out)
def test_standalone_and_initiative_base_gate(self):
self.make_branch("feature/other")
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
self.commit_card("feature/init-x", card_text("feature/init-x", "initiative", accepting="true"))
for branch, kind in (("feature/solo", "standalone"), ("feature/init-x", "initiative")):
self.git(self.repo, "switch", branch)
head = self.head()
for base, expected_ok in (("main", True), ("feature/other", False)):
with self.subTest(kind=kind, base=base):
self.gitea.prs = [pr(1, branch, base, head)]
result = self.local()
if expected_ok:
self.assertEqual(result.returncode, 0, result.out)
self.assertIn(f"[verdict] PR_CHECK_OK base=main head={head} kind={kind}", result.out)
else:
self.assertEqual(result.returncode, 1, result.out)
self.assertIn(f"expected 'main' for {kind} branch {branch}", result.out)
# ---- local:PR 状态与 head ----
def test_stale_pr_head_is_rejected(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
stale = self.head()
self.gitea.prs = [pr(1, "feature/solo", "main", stale)]
fresh = self.commit_work("feature/solo")
result = self.local()
self.assertEqual(result.returncode, 1, result.out)
self.assertEqual(len(self.fails(result)), 1, result.out)
self.assertIn(f"PR #1 head {stale} != local HEAD {fresh}", result.out)
def test_remote_branch_head_differing_from_local_is_rejected(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
remote = self.head()
local = self.commit_work("feature/solo", push=False)
self.gitea.prs = [pr(1, "feature/solo", "main", local)]
result = self.local()
self.assertEqual(result.returncode, 1, result.out)
self.assertEqual(len(self.fails(result)), 1, result.out)
self.assertIn(f"origin/feature/solo is {remote}, local HEAD is {local}", result.out)
def test_closed_and_merged_prs_are_rejected(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
head = self.head()
for kwargs, reason in (({"state": "closed"}, "PR #1 is not open (state=closed)"),
({"state": "closed", "merged": True}, "PR #1 is already merged")):
with self.subTest(reason=reason):
self.gitea.prs = [pr(1, "feature/solo", "main", head, **kwargs)]
result = self.local("--pr", "1")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn(reason, result.out)
def test_pr_lookup_requires_one_open_pr_or_explicit_number(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
head = self.head()
self.make_branch("feature/other")
self.git(self.repo, "switch", "feature/solo")
self.gitea.prs = [pr(1, "feature/solo", "feature/other", head), pr(2, "feature/solo", "main", head)]
ambiguous = self.local()
self.assertEqual(ambiguous.returncode, 1, ambiguous.out)
self.assertIn("2 open PRs have head feature/solo; pass --pr N", ambiguous.out)
explicit = self.local("--pr", "2")
self.assertEqual(explicit.returncode, 0, explicit.out)
self.gitea.prs = []
missing = self.local()
self.assertEqual(missing.returncode, 1, missing.out)
self.assertIn("no open PR has head feature/solo", missing.out)
# ---- 远端不可核验:fail closed ----
def test_unreachable_or_unauthorized_api_fails_closed_with_exit_6(self):
self.build_child()
self.gitea.prs = [pr(1, "feature/init", "main", self.head("feature/init")),
pr(2, "feature/init-child", "feature/init", self.head())]
for label, env in (("unreachable", {"GITEA_API_URL": closed_port_url()}),
("unauthorized", {"GITEA_TOKEN": "wrong-token"})):
with self.subTest(label):
result = self.local(env=env)
self.assertEqual(result.returncode, 6, result.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", result.out)
def test_token_is_first_access_token_line_of_secrets_file(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
home = Path(self.tmp.name) / "secrets-home"
(home / ".secrets").mkdir(parents=True)
(home / ".secrets" / "gitea").write_text(
f"URL_PREFIX=https://example.invalid\n- ACCESS_TOKEN={TOKEN}\nACCESS_TOKEN=other-token\n")
self.gitea.prs = [pr(1, "feature/solo", "main", self.head())]
result = self.local(env={"HOME": str(home)}, drop=("GITEA_TOKEN",))
self.assertEqual(result.returncode, 0, result.out)
# ---- local --final ----
def test_final_rejects_initiative_still_accepting_requirements(self):
self.commit_card("feature/init", card_text("feature/init", "initiative", accepting="true", registry=[]))
self.gitea.prs = [pr(1, "feature/init", "main", self.head())]
result = self.local("--final")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn("still accepts requirements", result.out)
def test_final_requires_required_children_merged_or_deferred(self):
self.commit_card("feature/init", card_text("feature/init", "initiative", accepting="true"))
scenarios = [
("merged and deferred pass",
[("feature/init-a", "是", "已合入", "#2"), ("feature/init-b", "否", "进行中", "-"),
("feature/init-c", "是", "已延期", "-")],
[pr(2, "feature/init-a", "feature/init", "a" * 40, state="closed", merged=True)],
None),
("required child still in progress",
[("feature/init-d", "是", "进行中", "-")], [], "required child feature/init-d is '进行中'"),
("open PR is not merged",
[("feature/init-e", "是", "已合入", "#3")],
[pr(3, "feature/init-e", "feature/init", "b" * 40, state="open")],
"child feature/init-e PR #3 is not merged into feature/init"),
("merged into main instead",
[("feature/init-f", "是", "已合入", "#4")],
[pr(4, "feature/init-f", "main", "c" * 40, state="closed", merged=True)],
"child feature/init-f PR #4 is not merged into feature/init"),
]
for label, registry, children, reason in scenarios:
with self.subTest(label):
head = self.set_card("feature/init", card_text("feature/init", "initiative",
accepting="false", registry=registry))
self.gitea.prs = [pr(1, "feature/init", "main", head)] + children
result = self.local("--final")
if reason is None:
self.assertEqual(result.returncode, 0, result.out)
self.assertIn("[verdict] PR_CHECK_OK base=main", result.out)
else:
self.assertEqual(result.returncode, 1, result.out)
self.assertIn(reason, result.out)
# ---- ci 模式 ----
def ci(self, base=None, head=None, event="pull_request"):
env = {"GITHUB_EVENT_NAME": event}
if base is not None:
env["GITHUB_BASE_REF"] = base
if head is not None:
env["GITHUB_HEAD_REF"] = head
# ci 模式不得需要网络或 token:指向关闭端口并移除 token,证明零依赖。
return self.invoke("ci", env=env | {"GITEA_API_URL": closed_port_url()}, drop=("GITEA_TOKEN",))
def test_ci_mode_gates_pull_request_base_against_head_card(self):
self.build_child()
result = self.ci(base="feature/init", head="feature/no-card")
self.assertEqual(result.returncode, 1, result.out) # 无卡 → standalone → 期望 main
self.assertIn("expected 'main' for standalone branch feature/no-card", result.out)
cases = [
("skips non pull_request events", dict(event="push", base="main", head="feature/init-child"), 0,
"[pr-check] skip: event push is not pull_request"),
("child into main is rejected", dict(base="main", head="feature/init-child"), 1,
"expected 'feature/init' for child branch feature/init-child"),
("child into initiative passes", dict(base="feature/init", head="feature/init-child"), 0,
"[verdict] PR_CHECK_OK base=feature/init head=feature/init-child kind=child"),
("card-less head defaults to main", dict(base="main", head="feature/no-card"), 0,
"[verdict] PR_CHECK_OK base=main head=feature/no-card kind=standalone"),
("card-less head into other base is rejected", dict(base="feature/init", head="feature/no-card"), 1,
"expected 'main' for standalone branch feature/no-card"),
("missing base ref is rejected", dict(base=None, head="feature/init-child"), 1,
"lacks GITHUB_BASE_REF or GITHUB_HEAD_REF"),
]
for label, kwargs, code, text in cases:
with self.subTest(label):
result = self.ci(**kwargs)
self.assertEqual(result.returncode, code, result.out)
self.assertIn(text, result.out)
if __name__ == "__main__":
unittest.main()
+7 -2
View File
@@ -1,8 +1,12 @@
"""Local recovery is task-scoped and cannot grant delivery readiness."""
import json
import os
import sys
import unittest
import test_task as lifecycle
# 允许 `python3 -m unittest tests/test_task_recovery.py` 直接运行(与 discover 模式一致地导入同目录夹具)。
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_task as lifecycle # noqa: E402
class TaskRecoveryTests(unittest.TestCase):
@@ -178,7 +182,8 @@ class TaskRecoveryTests(unittest.TestCase):
self.assertEqual(self.card.read_bytes(), before)
start = f.task(self.wt, "start", "feature/bypass", check=False,
env=f.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_WORK_MODE": "local",
"VALIDATE_RECEIPT_PURPOSE": "resume-local"})
"VALIDATE_RECEIPT_PURPOSE": "resume-local",
"TRIAGE_RECEIPT": str(f.triage_receipt())})
self.assertNotEqual(start.returncode, 0, start.stdout)
self.assertFalse((f.root / "repo-wt-feature-bypass").exists())
+401
View File
@@ -0,0 +1,401 @@
"""需求分流收据契约(scripts/task_triage.py):远端发现、显式决策、核验拒绝。
全部对真实临时 Git 仓库(bare origin + clone)与进程内 Gitea 桩运行;不联网,不读取真实 ~/.secrets。
"""
import hashlib
import json
import os
import socket
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_task import FakeGitea # noqa: E402
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "task_triage.py"
sys.path.insert(0, str(SOURCE / "scripts"))
import task_triage # noqa: E402
def card_text(branch, kind="initiative", accepting="true", root="main", status="进行中"):
slug = branch.replace("/", "-")
return (
f"# {branch} 任务卡\n"
f"任务主记录:fixture;状态:{status};owner:Triage Test\n"
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= 0\n"
f"- 工作区:fixture(worktree);分支:{branch}\n"
f"- 分支类型:{kind};initiative_id:{slug};root_target:{root};"
f"immediate_target:{root};accepting_requirements:{accepting}\n\n"
"## 目标与非目标\n\n- 交付登录能力\n- 不做第三方登录\n\n## 其它\n\n- 无\n"
)
class TriageTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="triage-")
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.remote = self.root / "remote.git"
self.repo = self.root / "repo"
self.receipt = self.root / "receipt.json"
self.env = os.environ.copy()
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "Triage Test", "GIT_AUTHOR_EMAIL": "triage@example.invalid",
"GIT_COMMITTER_NAME": "Triage Test", "GIT_COMMITTER_EMAIL": "triage@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false",
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost",
"GITEA_REPO": "fixture/repo", "GITEA_TOKEN": "dummy"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_API_URL", "TASK_TARGET_BRANCH",
"TRIAGE_RECEIPT"):
self.env.pop(key, None)
self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote))
self.git(self.root, "init", "--initial-branch=main", str(self.repo))
(self.repo / "README").write_text("fixture\n")
self.git(self.repo, "add", "README")
self.git(self.repo, "commit", "-m", "Create fixture")
self.git(self.repo, "remote", "add", "origin", str(self.remote))
self.git(self.repo, "push", "-u", "origin", "main")
self.gitea = FakeGitea(token="dummy")
self.addCleanup(self.gitea.close)
self.env["GITEA_API_URL"] = self.gitea.url
# ---- 夹具 ----
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, check=True)
def make_initiative(self, branch, **card):
self.git(self.repo, "switch", "-c", branch, "main")
path = self.repo / ".task" / (branch.replace("/", "-") + ".md")
path.parent.mkdir(exist_ok=True)
path.write_text(card_text(branch, **card), encoding="utf-8")
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Record {branch}")
self.git(self.repo, "push", "origin", branch)
self.git(self.repo, "switch", "main")
return self.head(branch)
def advance_initiative(self, branch):
self.git(self.repo, "switch", branch)
(self.repo / "more-work.txt").write_text("later initiative work\n")
self.git(self.repo, "add", "more-work.txt")
self.git(self.repo, "commit", "-m", "More initiative work")
self.git(self.repo, "push", "origin", branch)
self.git(self.repo, "switch", "main")
def head(self, ref):
return subprocess.run(["git", "rev-parse", ref], cwd=self.repo, env=self.env,
capture_output=True, text=True, check=True).stdout.strip()
def open_pr(self, number, branch, base="main", state="open"):
self.gitea.prs.append(FakeGitea.pr(number, branch, self.head(branch), base=base, state=state))
def run_script(self, *args, env=None):
result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo,
env=self.env | (env or {}), capture_output=True, text=True, timeout=60)
result.out = result.stdout + result.stderr
return result
def discover(self, env=None, requirement="add login", receipt=None):
return self.run_script("discover", "--receipt", str(receipt or self.receipt),
"--requirement", requirement, env=env)
def decide(self, verdict, reason="", initiative=None):
args = ["decide", "--receipt", str(self.receipt), "--verdict", verdict, "--reason", reason]
if initiative:
args += ["--initiative", initiative]
return self.run_script(*args)
def verify(self, kind, initiative=None, max_age=None, env=None):
args = ["verify", "--receipt", str(self.receipt), "--kind", kind]
if initiative:
args += ["--initiative", initiative]
if max_age is not None:
args += ["--max-age-seconds", str(max_age)]
return self.run_script(*args, env=env)
def load(self):
return json.loads(self.receipt.read_text(encoding="utf-8"))
def save(self, data):
self.receipt.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def discover_single(self, branch="feature/alpha"):
self.make_initiative(branch)
self.open_pr(len(self.gitea.prs) + 1, branch)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
return result
# ---- 发现 ----
def test_remote_initiative_absent_from_main_tree_is_discovered(self):
head = self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.assertNotIn(".task/feature-alpha.md",
subprocess.run(["git", "ls-tree", "-r", "--name-only", "main"], cwd=self.repo,
env=self.env, capture_output=True, text=True).stdout)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertIn("[candidate] branch=feature/alpha", result.out)
self.assertIn("[verdict] DECISION_REQUIRED", result.out)
self.assertIn("make task-triage-decide", result.out)
data = self.load()
cand = data["candidates"][0]
self.assertEqual(cand["head_sha"], head)
self.assertEqual(cand["card_path"], ".task/feature-alpha.md")
self.assertEqual(cand["pr_base"], "main")
self.assertEqual(cand["pr_number"], 1)
self.assertIn("交付登录能力", cand["goal_excerpt"])
blob = subprocess.run(["git", "cat-file", "blob", f"{head}:.task/feature-alpha.md"],
cwd=self.repo, env=self.env, capture_output=True, check=True).stdout
self.assertEqual(cand["card_sha256"], hashlib.sha256(blob).hexdigest())
self.assertEqual((data["final_verdict"], data["discovery_verdict"]),
("DECISION_REQUIRED", "DECISION_REQUIRED"))
self.assertIsNone(data["decision"])
def test_zero_candidates_yields_new_without_selecting_an_initiative(self):
result = self.discover()
self.assertEqual(result.returncode, 0, result.out)
self.assertIn("[verdict] NEW", result.out)
data = self.load()
self.assertEqual((data["discovery_verdict"], data["final_verdict"]), ("NEW", "NEW"))
self.assertEqual(data["candidates"], [])
self.assertEqual(data["decision"]["verdict"], "NEW")
self.assertIsNone(data["decision"]["selected_initiative"])
self.assertEqual(data["decision"]["rationale"], "no accepting initiative")
self.assertEqual(data["requirement"]["sha256"], hashlib.sha256(b"add login").hexdigest())
self.assertEqual(data["repo"]["repo_slug"], "fixture/repo")
self.assertEqual(data["root_target_sha"], self.head("main"))
def test_single_candidate_requires_decision_never_auto_join(self):
self.discover_single()
data = self.load()
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
self.assertIsNone(data["decision"])
def test_multiple_candidates_stop_at_decision_required(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha")
self.open_pr(2, "feature/beta")
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertIn("[candidate] branch=feature/alpha", result.out)
self.assertIn("[candidate] branch=feature/beta", result.out)
data = self.load()
self.assertEqual([c["branch"] for c in data["candidates"]], ["feature/alpha", "feature/beta"])
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
def test_pr_base_not_main_and_closed_pr_are_ignored(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha", base="release")
self.open_pr(2, "feature/beta", state="closed")
result = self.discover()
self.assertEqual(result.returncode, 0, result.out)
self.assertEqual(self.load()["final_verdict"], "NEW")
def test_card_filters_ignore_closed_intake_done_plain_and_other_root(self):
self.make_initiative("feature/active")
self.make_initiative("feature/closed-intake", accepting="false")
self.make_initiative("feature/done", status="已完成(回填)")
self.make_initiative("feature/plain", kind="standalone")
self.make_initiative("feature/other-root", root="release")
for number, branch in enumerate(("feature/active", "feature/closed-intake", "feature/done",
"feature/plain", "feature/other-root"), start=1):
self.open_pr(number, branch)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertEqual([c["branch"] for c in self.load()["candidates"]], ["feature/active"])
def test_remote_discovery_paginates_until_empty(self):
for number in range(1, 51):
self.gitea.prs.append(FakeGitea.pr(number, f"feature/noise-{number}", "0" * 40, base="release"))
self.make_initiative("feature/late")
self.open_pr(51, "feature/late")
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertEqual([c["branch"] for c in self.load()["candidates"]], ["feature/late"])
def test_api_unreachable_yields_remote_unverified_without_receipt(self):
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
result = self.discover(env={"GITEA_API_URL": f"http://127.0.0.1:{port}/api/v1"})
self.assertEqual(result.returncode, 6, result.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", result.out)
self.assertFalse(self.receipt.exists())
def test_ls_remote_failure_yields_remote_unverified_without_receipt(self):
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.discover()
self.assertEqual(result.returncode, 6, result.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", result.out)
self.assertFalse(self.receipt.exists())
def test_token_from_secrets_file_is_used_and_never_printed(self):
home = self.root / "home"
(home / ".secrets").mkdir(parents=True)
(home / ".secrets" / "gitea").write_text(
"URL_PREFIX=https://example.invalid\n- ACCESS_TOKEN=file-token-7f3a\nACCESS_TOKEN=second-token\n")
self.gitea.token = "file-token-7f3a"
self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
result = self.discover(env={"HOME": str(home), "GITEA_TOKEN": ""})
self.assertEqual(result.returncode, 5, result.out)
self.assertNotIn("file-token-7f3a", result.out)
self.assertNotIn("file-token-7f3a", self.receipt.read_text(encoding="utf-8"))
self.gitea.token = "other-token"
rejected = self.discover(env={"HOME": str(home), "GITEA_TOKEN": ""}, receipt=self.root / "other.json")
self.assertEqual(rejected.returncode, 6, rejected.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", rejected.out)
self.assertNotIn("file-token-7f3a", rejected.out)
def test_repo_slug_is_derived_from_origin_when_gitea_repo_unset(self):
owner_dir = self.root / "fixture-owner"
owner_dir.mkdir()
named = owner_dir / "fixture-repo.git"
self.git(self.root, "init", "--bare", "--initial-branch=main", str(named))
self.git(self.repo, "remote", "set-url", "origin", str(named))
self.git(self.repo, "push", "-u", "origin", "main")
result = self.discover(env={"GITEA_REPO": ""})
self.assertEqual(result.returncode, 0, result.out)
self.assertEqual(self.load()["repo"]["repo_slug"], "fixture-owner/fixture-repo")
def test_discover_requires_requirement_text(self):
result = self.discover(requirement=" ")
self.assertEqual(result.returncode, 2, result.out)
# ---- 决策 ----
def test_join_decision_succeeds_and_verifies_child(self):
head = self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.discover()
decided = self.decide("JOIN", "加入登录主线", initiative="feature/alpha")
self.assertEqual(decided.returncode, 0, decided.out)
data = self.load()
self.assertEqual(data["final_verdict"], "JOIN")
self.assertEqual(data["decision"]["selected_initiative"], "feature/alpha")
verified = self.verify("child", initiative="feature/alpha")
self.assertEqual(verified.returncode, 0, verified.out)
self.assertIn("[triage-verified] verdict=JOIN selected_initiative=feature/alpha "
"requirement_sha256=", verified.out)
self.assertIn(f"[triage-head] selected_head_sha={head}", verified.out)
def test_new_decision_for_single_non_fitting_candidate_requires_reason(self):
self.discover_single()
before = self.receipt.read_bytes()
no_reason = self.decide("NEW", "")
self.assertEqual(no_reason.returncode, 7, no_reason.out)
self.assertEqual(self.receipt.read_bytes(), before)
decided = self.decide("NEW", "不适配:独立的后台巡检任务")
self.assertEqual(decided.returncode, 0, decided.out)
self.assertEqual(self.load()["final_verdict"], "NEW")
self.assertEqual(self.verify("standalone").returncode, 0)
def test_decide_rejects_unknown_initiative_or_missing_reason_without_changing_receipt(self):
self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.discover()
before = self.receipt.read_bytes()
unknown = self.decide("JOIN", "理由", initiative="feature/beta")
self.assertEqual(unknown.returncode, 7, unknown.out)
no_reason = self.decide("JOIN", "", initiative="feature/alpha")
self.assertEqual(no_reason.returncode, 7, no_reason.out)
self.assertEqual(self.receipt.read_bytes(), before)
def test_decide_rejects_candidate_head_change_and_keeps_receipt(self):
self.discover_single()
before = self.receipt.read_bytes()
self.advance_initiative("feature/alpha")
result = self.decide("JOIN", "加入", initiative="feature/alpha")
self.assertEqual(result.returncode, 7, result.out)
self.assertIn("candidate set changed", result.out)
self.assertEqual(self.receipt.read_bytes(), before)
# ---- 核验 ----
def test_verify_rejects_stale_receipt(self):
self.assertEqual(self.discover().returncode, 0)
self.assertEqual(self.verify("standalone").returncode, 0)
data = self.load()
data["discovered_at"] = "2001-01-01T00:00:00Z"
self.save(data)
result = self.verify("standalone")
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("stale", result.out)
def test_verify_rejects_changed_candidate_head(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
self.advance_initiative("feature/alpha")
result = self.verify("child", initiative="feature/alpha")
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("candidates changed", result.out)
def test_verify_rejects_changed_card_digest(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
data = self.load()
data["candidates"][0]["card_sha256"] = "0" * 64
self.save(data)
result = self.verify("child", initiative="feature/alpha")
self.assertEqual(result.returncode, 8, result.out)
def test_verify_rejects_verdict_and_kind_mismatch(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
standalone = self.verify("standalone")
self.assertEqual(standalone.returncode, 8, standalone.out)
self.decide("NEW", "不适配")
child = self.verify("child", initiative="feature/alpha")
self.assertEqual(child.returncode, 8, child.out)
def test_verify_rejects_child_with_wrong_initiative(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha")
self.open_pr(2, "feature/beta")
self.discover()
self.decide("JOIN", "加入", initiative="feature/alpha")
result = self.verify("child", initiative="feature/beta")
self.assertEqual(result.returncode, 8, result.out)
def test_verify_rejects_root_target_mismatch(self):
self.discover()
result = self.verify("standalone", env={"TASK_TARGET_BRANCH": "release"})
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("root target", result.out)
def test_verify_rejects_wrong_schema_and_missing_receipt(self):
self.discover()
data = self.load()
data["schema"] = "agent-system/other/v9"
self.save(data)
self.assertEqual(self.verify("standalone").returncode, 8)
self.receipt.unlink()
missing = self.verify("standalone")
self.assertEqual(missing.returncode, 8, missing.out)
class RedactionTests(unittest.TestCase):
def test_userinfo_is_stripped_from_urls_and_scp_forms(self):
self.assertEqual(task_triage.redact_url("https://user:secret@git.example:30319/o/r.git"),
"https://git.example:30319/o/r.git")
self.assertEqual(task_triage.redact_url("git@git-ssh.example:weichen/Repo.git"),
"git-ssh.example:weichen/Repo.git")
self.assertEqual(task_triage.redact_url("/tmp/plain/remote.git"), "/tmp/plain/remote.git")
if __name__ == "__main__":
unittest.main()