From 74a834c78633793c95ea1f91bf6c34385b39da9f Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Fri, 16 Oct 2026 18:12:57 +0000 Subject: [PATCH] agent-exec transport cd4961f74af64c6ebd54c5fd0bf27d27/d63165e5e315 --- scripts/check-evidence.py | 7 +- scripts/evidence.py | 51 +++++++++++-- scripts/task.sh | 123 ++++++++++++++++++++++++------- tests/test_evidence.py | 23 ++++++ tests/test_task.py | 147 +++++++++++++++++++++++++++++++++++++- 5 files changed, 317 insertions(+), 34 deletions(-) diff --git a/scripts/check-evidence.py b/scripts/check-evidence.py index df609348..70395128 100644 --- a/scripts/check-evidence.py +++ b/scripts/check-evidence.py @@ -64,10 +64,11 @@ def check_snapshot(snapshot): def check_receipt(value): if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != evidence.SCHEMA: raise ValueError("unsupported or malformed receipt") - expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir", - "before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"} - if set(value) not in (expected, expected | {"capture_note"}): + if not evidence.receipt_keys_ok(value): raise ValueError("invalid receipt fields") + for key in evidence.TARGET_FIELDS: + if key in value and not evidence.valid_branch(value[key]): + raise ValueError("invalid receipt target: " + key) for key in ("source", "timestamp", "command", "cwd", "common_dir", "output"): if not isinstance(value[key], str): raise ValueError("invalid receipt field: " + key) diff --git a/scripts/evidence.py b/scripts/evidence.py index 3ea96e94..b534860c 100644 --- a/scripts/evidence.py +++ b/scripts/evidence.py @@ -20,6 +20,11 @@ SCHEMA = 1 SECRET_NAME = re.compile(r"token|password|passwd|secret|api[_-]?key|credential|private[_-]?key", re.I) SHA256 = re.compile(r"[0-9a-f]{64}\Z") GIT_SHA = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") +BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}\Z") +# 可选的分支目标字段:两者须同时出现或同时缺省(旧收据没有)。 +TARGET_FIELDS = ("root_target", "immediate_target") +BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir", + "before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"} class EvidenceError(Exception): @@ -111,9 +116,41 @@ def snapshot(): } +def valid_branch(name): + return (isinstance(name, str) and BRANCH.match(name) is not None and ".." not in name + and "//" not in name and not name.endswith(("/", ".", ".lock"))) + + +def receipt_keys_ok(keys): + keys = set(keys) + targets = keys & set(TARGET_FIELDS) + if targets not in (set(), set(TARGET_FIELDS)): + return False + rest = keys - targets + return rest in (BASE_FIELDS, BASE_FIELDS | {"capture_note"}) + + +def task_targets(): + """Root and immediate targets named by the current branch card; no metadata means main/main.""" + default = os.environ.get("TASK_TARGET_BRANCH", "main") + try: + card = find_card() + except EvidenceError: + return default, default + match = re.search(r"^- 分支类型:[^\n]*?;root_target:([^;\n]+);immediate_target:([^;\n]+)", + card.read_text(encoding="utf-8"), re.M) + if not match: + return default, default + root, immediate = match.group(1).strip(), match.group(2).strip() + if not (valid_branch(root) and valid_branch(immediate)): + raise EvidenceError("Task card target metadata is invalid") + return root, immediate + + def create_receipt(command, output, exit_code, before, after, *, timestamp=None, - cwd=None, git_common_dir=None, after_error=None, capture_note=None): + cwd=None, git_common_dir=None, after_error=None, capture_note=None, targets=None): """Package an actual captured run; callers must not reconstruct missing output.""" + root_target, immediate_target = targets or task_targets() receipt = sanitize_tree({ "schema": SCHEMA, "source": "SELF", "timestamp": timestamp or datetime.datetime.now(datetime.timezone.utc).isoformat(), @@ -122,6 +159,7 @@ def create_receipt(command, output, exit_code, before, after, *, timestamp=None, "common_dir": git_common_dir or common_dir(), "before": before, "after": after, "after_error": after_error, "exit_code": exit_code, "output": output, + "root_target": root_target, "immediate_target": immediate_target, }) if capture_note is not None: receipt["capture_note"] = sanitize(capture_note) @@ -159,10 +197,11 @@ def read_receipt(filename): value = json.loads(Path(filename).read_text()) if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != SCHEMA: raise EvidenceError("Unsupported receipt schema") - expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir", - "before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"} - if set(value) not in (expected, expected | {"capture_note"}): + if not receipt_keys_ok(value): raise EvidenceError("Invalid receipt fields") + for key in TARGET_FIELDS: + if key in value and not valid_branch(value[key]): + raise EvidenceError("Invalid receipt target: " + key) for key in ("source", "timestamp", "command", "command_sha256", "cwd", "common_dir", "output", "output_sha256", "receipt_sha256"): if not isinstance(value.get(key), str): @@ -350,6 +389,7 @@ def main(): raise EvidenceError("Capture requires an absolute, unused receipt path") card = find_card() if path is None else None before = snapshot() + targets = task_targets() source_cwd, source_common = str(Path.cwd().resolve()), common_dir() timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat() child_env = os.environ.copy() @@ -366,7 +406,8 @@ def main(): after_error = str(error) receipt = create_receipt(command, result.stdout.decode("utf-8", "replace"), child_exit, before, after, timestamp=timestamp, cwd=source_cwd, - git_common_dir=source_common, after_error=after_error) + git_common_dir=source_common, after_error=after_error, + targets=targets) if path is not None: write_receipt(path, receipt) report(receipt, "receipt=" + str(path)) diff --git a/scripts/task.sh b/scripts/task.sh index bec000ed..6a21012f 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -59,7 +59,7 @@ cmd_precheck() { card_pending "${pc##*/}" && continue # card_merge 兼容 merge 提交与 squash 落地(squash 走 Gitea API 兜底,无 token 时只认 merge 提交); # 其内部以完整消费输出取首行,避免 grep -q 早退令 git log 吃 SIGPIPE(141 under pipefail)。 - if card_merge "$pb" >/dev/null; then + if card_merged_into "$pc" "$pb" >/dev/null; then open_cards="${open_cards} ${pc##*/}" fi done @@ -162,12 +162,13 @@ cmd_start() { 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 + local triage_out triage_line triage_head 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_head=$(triage_field selected_head_sha "$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-head\] //p')") triage_verdict=$(triage_field verdict "$triage_line") triage_init=$(triage_field selected_initiative "$triage_line") triage_rsha=$(triage_field requirement_sha256 "$triage_line") @@ -187,7 +188,7 @@ cmd_start() { [ "$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)" -eq 0 ] || die "dirty tree — resolve first (precheck verdict BUSY)" [ "$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })" -eq 0 ] || die "submodule drift/conflict — resolve first (precheck verdict BUSY)" git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "local ${TARGET_BRANCH} behind origin — pull --ff-only first" - local rmain slug wt mainroot start_ref parent_head="" parent_note="" + local rmain slug wt mainroot start_ref parent_head="" parent_source="" parent_note="" rmain=$(git rev-parse "origin/${TARGET_BRANCH}") start_ref=$rmain slug=$(echo "$name" | tr '/' '-') @@ -196,9 +197,18 @@ cmd_start() { initiative) branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;; child) - parent_head=$(git rev-parse --verify "refs/heads/${initiative}^{commit}" 2>/dev/null) || die "parent initiative branch ${initiative} missing locally" + if [ "$(work_mode)" = delivery ]; then + # 交付模式:父 initiative 只认 origin 上刚取回的头,且必须等于分流收据核验的头(未准入的本地提交不算)。 + [ -n "$triage_head" ] || die "triage receipt carries no selected initiative head; re-run make task-triage (no worktree or branch was created)" + parent_head=$(remote_parent_head "$initiative") || die "remote parent initiative ${initiative} unavailable; no worktree or branch was created" + [ "$parent_head" = "$triage_head" ] || die "remote ${initiative} is ${parent_head} but triage verified ${triage_head}; re-run make task-triage (no worktree or branch was created)" + parent_source=origin + else + parent_head=$(git rev-parse --verify "refs/heads/${initiative}^{commit}" 2>/dev/null) || die "parent initiative branch ${initiative} missing locally" + parent_source="local(非正式准入)" + fi start_ref=$parent_head - branch_meta="- 分支类型:child;initiative_id:$(echo "$initiative" | tr '/' '-');root_target:${TARGET_BRANCH};immediate_target:${initiative};accepting_requirements:false;parent_branch:${initiative};parent_head_sha:${parent_head};parent_pr:${TASK_PARENT_PR:-无}" ;; + branch_meta="- 分支类型:child;initiative_id:$(echo "$initiative" | tr '/' '-');root_target:${TARGET_BRANCH};immediate_target:${initiative};accepting_requirements:false;parent_branch:${initiative};parent_head_sha:${parent_head};parent_pr:${TASK_PARENT_PR:-无};parent_source:${parent_source}" ;; *) branch_meta="- 分支类型:standalone;root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:false" ;; esac @@ -363,8 +373,22 @@ cmd_resume() { local receipt receipt=$(realpath -- "$PRECHECK_RECEIPT") VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh" + # 立即目标:子任务为其父 initiative(delivery 下取远端头),其余为根目标。 + local branch card immediate="${TARGET_BRANCH}" observed recorded + branch=$(git rev-parse --abbrev-ref HEAD) + card=$(branch_card "$branch") + [ -n "$card" ] && immediate=$(card_immediate_target "$card") + if [ "$immediate" != "${TARGET_BRANCH}" ]; then + observed=$(resolve_parent_ref "$immediate") || die "immediate target ${immediate} unavailable; receipt not imported" + recorded=$(card_field "$card" parent_head_sha) + if [ -n "$recorded" ] && ! git merge-base --is-ancestor "$recorded" "$observed"; then + die "immediate target ${immediate} no longer contains recorded parent head ${recorded}; receipt not imported" + fi + else + observed=$(git rev-parse "origin/${TARGET_BRANCH}") + fi IMPORT_RECEIPT="$receipt" bash "${SCRIPT_DIR}/evidence.sh" - echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=$(git rev-parse "origin/${TARGET_BRANCH}")" + echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=${observed} immediate_target=${immediate}" } cmd_sync() { @@ -462,12 +486,35 @@ active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为 case "$(card_status "$pc")" in 已完成*) continue ;; esac pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue card_pending "${pc##*/}" && continue - card_merge "$pb" >/dev/null && continue + card_merged_into "$pc" "$pb" >/dev/null && continue printf '%s|%s|%s\n' "$pb" "$(card_initiative_id "$pc")" "$pc" done } -resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(远端与本地取包含对方的一侧) +remote_parent_head() { # $1=父 initiative 分支 → 打印刚从 origin 取回的头(不看本地);不可查询返回 6,远端缺失返回 1 git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1" + local rc=0 + git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$? + if [ "$rc" -eq 2 ]; then echo "ERROR: parent branch $1 not found on origin" >&2; return 1; fi + if [ "$rc" -ne 0 ]; then echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1; preserve local work and retry" >&2; return 6; fi + if ! git fetch -q origin "refs/heads/$1:refs/remotes/origin/$1"; then + echo "[verdict] REMOTE_UNVERIFIED — cannot refresh origin/$1; preserve local work and retry" >&2 + return 6 + fi + git rev-parse --verify "refs/remotes/origin/$1^{commit}" +} +resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(delivery 仅远端;local 取远端与本地包含对方的一侧) + git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1" + if [ "$(work_mode)" = delivery ]; then + # delivery 只认 origin 上刚取回的父分支头:本地领先/分叉意味着未准入的提交,不得被同步或合入。 + local remote_head local_head + remote_head=$(remote_parent_head "$1") || return $? + local_head=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true) + if [ -n "$local_head" ] && ! git merge-base --is-ancestor "$local_head" "$remote_head"; then + die "local $1 has commits origin lacks; push or reconcile them first (delivery mode uses origin only)" + fi + printf '%s\n' "$remote_head" + return 0 + fi local remote="" local_ref="" rc=0 git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$? if [ "$rc" -eq 0 ]; then @@ -515,17 +562,37 @@ triage_field() { printf '%s\n' "$2" | tr ' ' '\n' | sed -n "s/^$1=//p"; } card_merge() { # $1=branch → "sha|subject|pr"(该分支合入 origin/ 的记录;兼容 merge 提交与 squash 落地) local hit pr - hit=$(git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \ - | grep -F " from $1 into ${TARGET_BRANCH}" | sed -n '1p') || hit="" + hit=$(git log --merges --format='%H|%s' "origin/$2" 2>/dev/null \ + | grep -F " from $1 into $3" | sed -n '1p') || hit="" if [ -n "$hit" ]; then pr=$(printf '%s' "$hit" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p') printf '%s|%s\n' "$hit" "${pr:-unknown}" return 0 fi - card_merge_squash "$1" + card_merge_squash "$1" "$2" +} +# 卡片闭合判定:子任务优先核直接目标(父 initiative 仍在远端);父已合入并删除则回落到根目标历史。 +# 打印 "目标分支|merge_sha|subject|pr";未合入或不可核则失败。参数:$1=card $2=branch +card_merged_into() { + local card="$1" branch="$2" imm root rc=0 hit + imm=$(card_immediate_target "$card"); root=$(card_root_target "$card") + if [ "$imm" != "$root" ]; then + git ls-remote --exit-code --heads origin "refs/heads/${imm}" >/dev/null 2>&1 || rc=$? + case "$rc" in + 0) + git fetch -q origin "refs/heads/${imm}:refs/remotes/origin/${imm}" 2>/dev/null || return 1 + hit=$(card_merge "$branch" "$imm" "$imm") || return 1 + printf '%s|%s\n' "$imm" "$hit" + return 0 ;; + 2) ;; # 父 initiative 已不在远端:子任务合入记录仍在根目标历史中 + *) return 1 ;; + esac + fi + hit=$(card_merge "$branch" "$root" "$imm") || return 1 + printf '%s|%s\n' "$root" "$hit" } -card_merge_squash() { # $1=branch → squash/rebase 落地没有 merge 提交,经 Gitea API 按 head 分支找 merged PR。 +card_merge_squash() { # $1=branch $2=ref → squash/rebase 落地没有 merge 提交,经 Gitea API 按 head 分支找 merged PR。 # 返回 "merge_commit_sha|PR标题|PR号";sha 必须可核为 origin/ 祖先,否则不认(不可核即不闭合)。 # 仅查最近 5 页(250 条):card_merge 的 merge 提交路径仍是主路径,此兜底面向新近任务卡。 local token="${GITEA_TOKEN:-}" web_base url repo owner api payload pg found sha @@ -566,7 +633,7 @@ for p in entries if isinstance(entries, list) else []: done [ -n "$found" ] || return 1 sha="${found%%|*}" - git merge-base --is-ancestor "$sha" "origin/${TARGET_BRANCH}" 2>/dev/null || return 1 + git merge-base --is-ancestor "$sha" "origin/$2" 2>/dev/null || return 1 printf '%s\n' "$found" } card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入 @@ -576,10 +643,10 @@ card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/ done return 1 } -card_backfill() { # $1=card $2=pr_num $3=merge_sha $4=merge_subject $5=web_base(原地改写) - python3 - "$1" "$2" "$3" "$4" "$5" <<'PY' +card_backfill() { # $1=card $2=pr_num $3=merge_sha $4=merge_subject $5=web_base $6=contained_in_target(原地改写) + python3 - "$1" "$2" "$3" "$4" "$5" "$6" <<'PY' import re, sys -path, pr, sha, subject, base = sys.argv[1:6] +path, pr, sha, subject, base, target = sys.argv[1:7] s = open(path, encoding='utf-8').read() s = re.sub(r'状态:[^;\n]*', '状态:已完成(回填)', s, count=1) m = re.search(r'任务主记录:([^;\n]*)', s) @@ -590,7 +657,7 @@ s = s.rstrip('\n') + f''' ## 闭合记录(card-autoclose 回填) -- 依据:merge {sha}(PR #{pr})已包含于 origin/main,分支成果已落盘;本段为合入后回填。 +- 依据:merge {sha}(PR #{pr})已包含于 origin/{target},分支成果已落盘;本段为合入后回填。 - 合并主题:{subject} - 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。 ''' @@ -609,8 +676,13 @@ cmd_close() { local url owner repo url=$(git remote get-url origin); url="${url%.git}" repo=$(basename "$url"); owner=$(basename "$(dirname "$url")") - local branch c b st m cards="" + local branch c b st m cards="" own pr_base="${TARGET_BRANCH}" branch=$(git rev-parse --abbrev-ref HEAD) + if [ "$fold" = 1 ]; then + # 任务分支内折叠:PR 的 base 必须是本分支的立即目标(子任务为父 initiative,而非 main)。 + own=$(branch_card "$branch") + if [ -n "$own" ]; then pr_base=$(card_immediate_target "$own"); fi + fi for c in .task/*.md; do [ -f "$c" ] || continue b=$(card_of_branch "$c"); [ -n "$b" ] || continue @@ -621,7 +693,7 @@ cmd_close() { echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)" continue fi - m=$(card_merge "$b") || m="" + m=$(card_merged_into "$c" "$b") || m="" if [ -z "$m" ]; then [ "$done_unarchived" = 1 ] || continue m="ARCHIVE" @@ -640,9 +712,10 @@ $c|$b|$m" elif [ "$fold" != "1" ]; then die "close 在任务分支内须加 --fold(并入当前任务 PR);或到主检出运行(将建 feature/card-close-* 分支+PR)" fi - local n=0 sha subject pr closed_list="" + local n=0 sha subject pr tgt closed_list="" while IFS='|' read -r c b m; do [ -n "$c" ] || continue + tgt="${m%%|*}"; m="${m#*|}" if [ "$m" = "ARCHIVE" ]; then mkdir -p .task/archive git mv "$c" ".task/archive/${c##*/}" @@ -652,10 +725,10 @@ $c|$b|$m" fi sha="${m%%|*}"; rest="${m#*|}"; pr="${rest##*|}"; subject="${rest%|*}" case "$pr" in "$rest"|''|*[!0-9]*) pr=unknown ;; esac - card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}" + card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}" "$tgt" mkdir -p .task/archive git mv "$c" ".task/archive/${c##*/}" - closed_list="$closed_list ${c##*/}(PR#${pr})" + closed_list="$closed_list ${c##*/}(PR#${pr} into ${tgt})" n=$((n+1)) done < "$msg" @@ -682,7 +755,7 @@ EOF for attempt in 1 2 3 4; do resp=$(curl -sS -m 25 -X POST -H "Authorization: token ${GITEA_TOKEN}" -H 'Content-Type: application/json' \ "$api" \ - -d "{\"head\":\"${headbr}\",\"base\":\"${TARGET_BRANCH}\",\"title\":\"Close merged task cards (${headbr})\",\"body\":\"Auto-generated by task.sh close; see commit message for evidence.\"}" 2>&1) || resp="" + -d "{\"head\":\"${headbr}\",\"base\":\"${pr_base}\",\"title\":\"Close merged task cards (${headbr})\",\"body\":\"Auto-generated by task.sh close; see commit message for evidence.\"}" 2>&1) || resp="" pr_url=$(printf '%s' "$resp" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("html_url","") if isinstance(d,dict) else "")' 2>/dev/null || true) [ -n "$pr_url" ] && break echo "[close] 建 PR 第 ${attempt} 次未成功${resp:+($(printf '%s' "$resp" | head -c 120))},退避重试" >&2 diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 23186d95..e179cbc3 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -374,6 +374,29 @@ class EvidenceTests(unittest.TestCase): result = self.run_evidence(VALIDATE_RECEIPT=self.receipt) self.assertNotEqual(result.returncode, 0) + def test_child_receipts_record_root_and_immediate_targets(self): + self.card.write_text("# Test task\n- 分支类型:child;initiative_id:feature-alpha;root_target:main;" + "immediate_target:feature/alpha;accepting_requirements:false\n") + data = self.capture() + self.assertEqual((data["root_target"], data["immediate_target"]), ("main", "feature/alpha")) + self.assertEqual(self.import_receipt().returncode, 0) + self.assertIn(data["receipt_sha256"], self.card.read_text()) + checked = subprocess.run(["python3", str(SCRIPTS / "check-evidence.py"), str(self.card)], + cwd=self.repo, capture_output=True, text=True) + self.assertNotIn("WARN", checked.stdout + checked.stderr) + + def test_receipts_without_target_metadata_default_to_main(self): + data = self.capture() + self.assertEqual((data["root_target"], data["immediate_target"]), ("main", "main")) + + def test_receipt_target_fields_must_come_in_pairs(self): + original = self.capture() + partial = dict(original) + del partial["immediate_target"] + self.resign(partial) + self.assertNotEqual(self.import_receipt().returncode, 0) + self.assertEqual(self.card.read_text(), "# Test task\n") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_task.py b/tests/test_task.py index ccf19e1e..e91712fe 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -25,6 +25,7 @@ class FakeGitea: def __init__(self, token="dummy"): self.token = token self.prs = [] + self.posted = [] stub = self class Handler(http.server.BaseHTTPRequestHandler): @@ -49,8 +50,16 @@ class FakeGitea: start = (page - 1) * stub.PAGE return self._send(200, stub.prs[start:start + stub.PAGE]) + def do_POST(self): + if self.headers.get("Authorization") != f"token {stub.token}": + return self._send(401, {"message": "unauthorized"}) + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))) or b"{}") + stub.posted.append(body) + return self._send(201, {"html_url": f"{stub.web}/fixture/repo/pulls/{len(stub.posted)}"}) + 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" + self.web = f"http://127.0.0.1:{self.server.server_address[1]}" + self.url = f"{self.web}/api/v1" threading.Thread(target=self.server.serve_forever, daemon=True).start() def close(self): @@ -1260,6 +1269,7 @@ class TaskLifecycleTests(unittest.TestCase): (parent / "parent-later").write_text("later initiative work\n") self.git(parent, "add", "parent-later") self.git(parent, "commit", "-m", "Later initiative work") + self.publish_initiative("feature/alpha") # delivery 只认远端父分支头 parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip() result = self.task(wt, "sync") self.assertIn("changed=1", result.stdout) @@ -1283,6 +1293,7 @@ class TaskLifecycleTests(unittest.TestCase): self.assertIn("has not merged into feature/alpha", refused.stdout) self.assertTrue(wt.exists()) self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative") + self.publish_initiative("feature/alpha") # delivery 的 finish 只认远端父分支已含子分支 result = self.task(wt, "finish") self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha", result.stdout) @@ -1345,6 +1356,140 @@ class TaskLifecycleTests(unittest.TestCase): self.assertIn("remote initiative candidates changed", result.stdout) self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists()) + def close_env(self): + # 测试 token 与本地 Gitea 桩:建 PR 只能落到桩上,绝不触达真实 Gitea。 + return self.env | {"GITEA_TOKEN": self.gitea.token, "TASK_CARD_WEB_BASE": self.gitea.web} + + def child_env(self, receipt, triage): + return self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage), + "TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha", + "TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"} + + def test_delivery_child_ignores_ahead_local_parent(self): + parent = self.start_initiative() + self.publish_initiative("feature/alpha") + remote_head = self.git(parent, "rev-parse", "HEAD").stdout.strip() + (parent / "unadmitted").write_text("local only\n") + self.git(parent, "add", "unadmitted") + self.git(parent, "commit", "-m", "Unpublished initiative work") + triage = self.triage_receipt(join="feature/alpha") + receipt = self.capture_precheck() + self.task(self.repo, "start", "feature/alpha-login", env=self.child_env(receipt, triage)) + wt = self.root / "repo-wt-feature-alpha-login" + card = (wt / ".task/feature-alpha-login.md").read_text() + self.assertIn(f"parent_head_sha:{remote_head}", card) + self.assertIn("parent_source:origin", card) + self.assertFalse((wt / "unadmitted").exists()) + + def test_delivery_child_ignores_stale_local_parent(self): + self.start_initiative() + self.publish_initiative("feature/alpha") + other = self.root / "other" + self.git(self.root, "clone", "-q", str(self.remote), str(other)) + self.git(other, "checkout", "-q", "feature/alpha") + (other / "remote-admitted").write_text("admitted\n") + self.git(other, "add", "remote-admitted") + self.git(other, "commit", "-m", "Admitted initiative work") + self.git(other, "push", "-q", "origin", "feature/alpha") + remote_head = self.git(other, "rev-parse", "HEAD").stdout.strip() + triage = self.triage_receipt(join="feature/alpha") + receipt = self.capture_precheck() + self.task(self.repo, "start", "feature/alpha-login", env=self.child_env(receipt, triage)) + wt = self.root / "repo-wt-feature-alpha-login" + self.assertIn(f"parent_head_sha:{remote_head}", (wt / ".task/feature-alpha-login.md").read_text()) + self.assertEqual((wt / "remote-admitted").read_text(), "admitted\n") + + def test_delivery_child_fails_when_remote_parent_is_deleted_after_triage(self): + self.start_initiative() + self.publish_initiative("feature/alpha") + triage = self.triage_receipt(join="feature/alpha") + self.git(self.repo, "push", "origin", "--delete", "feature/alpha") + receipt = self.capture_precheck() + result = self.task(self.repo, "start", "feature/alpha-login", check=False, + env=self.child_env(receipt, triage)) + self.assertNotEqual(result.returncode, 0, 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_local_mode_child_uses_local_parent_and_marks_local_source(self): + parent = self.start_initiative() + self.publish_initiative("feature/alpha") + (parent / "local-admission").write_text("local only\n") + self.git(parent, "add", "local-admission") + self.git(parent, "commit", "-m", "Local initiative work") + local_head = self.git(parent, "rev-parse", "HEAD").stdout.strip() + triage = self.triage_receipt(join="feature/alpha") + receipt = self.capture_precheck() + self.task(self.repo, "start", "feature/alpha-login", + env=self.child_env(receipt, triage) | {"TASK_WORK_MODE": "local"}) + wt = self.root / "repo-wt-feature-alpha-login" + card = (wt / ".task/feature-alpha-login.md").read_text() + self.assertIn(f"parent_head_sha:{local_head}", card) + self.assertIn("parent_source:local(非正式准入)", card) + self.assertEqual((wt / "local-admission").read_text(), "local only\n") + + def test_resume_child_reports_parent_as_immediate_target(self): + parent = self.start_initiative() + wt, _ = self.start_child() + parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip() + receipt = self.capture_precheck(cwd=wt) + result = self.task(wt, "resume", env=self.env | {"PRECHECK_RECEIPT": str(receipt)}) + self.assertIn(f"observed_target={parent_head} immediate_target=feature/alpha", result.stdout) + + def test_resume_child_rejects_rewound_immediate_target(self): + parent = self.start_initiative() + wt, _ = self.start_child() + receipt = self.capture_precheck(cwd=wt) + self.git(parent, "reset", "--hard", "origin/main") + self.git(parent, "push", "-f", "origin", "feature/alpha") + result = self.task(wt, "resume", check=False, env=self.env | {"PRECHECK_RECEIPT": str(receipt)}) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("no longer contains recorded parent head", result.stdout) + self.assertNotIn("[resumed]", result.stdout) + + def test_close_archives_child_merged_into_initiative_and_names_it(self): + parent = self.start_initiative() + wt, _ = self.start_child() + (wt / "child-work").write_text("child result\n") + self.git(wt, "add", "child-work") + self.git(wt, "commit", "-m", "Child work") + self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", + "Merge pull request 'feature/alpha-login' (#5) from feature/alpha-login into feature/alpha") + self.publish_initiative("feature/alpha") + closed = self.task(parent, "close", "--fold", env=self.close_env()) + self.assertIn("[closed] 1 张已合入卡片", closed.stdout) + self.assertEqual(self.gitea.posted[-1]["head"], "feature/alpha") + archived = self.git(parent, "show", "HEAD:.task/archive/feature-alpha-login.md").stdout + self.assertIn("已包含于 origin/feature/alpha", archived) + self.assertIn("状态:已完成(回填)", archived) + self.assertIn("PR #5", archived) + self.assertFalse((parent / ".task/feature-alpha-login.md").exists()) + + def test_close_child_still_closable_after_initiative_merged_and_deleted(self): + parent = self.start_initiative() + wt, _ = self.start_child() + (wt / "child-work").write_text("child result\n") + self.git(wt, "add", "child-work") + self.git(wt, "commit", "-m", "Child work") + self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", + "Merge pull request 'feature/alpha-login' (#5) from feature/alpha-login into feature/alpha") + self.publish_initiative("feature/alpha") + self.git(self.repo, "fetch", "-q", "origin") + self.git(self.repo, "merge", "--no-ff", "origin/feature/alpha", "-m", + "Merge pull request 'feature/alpha' (#3) from feature/alpha into main") + self.git(self.repo, "push", "origin", "main") + self.git(self.repo, "push", "origin", "--delete", "feature/alpha") + closed = self.task(self.repo, "close", env=self.close_env()) + self.assertIn("[closed] 2 张已合入卡片", closed.stdout) + self.assertIn("[pr] " + self.gitea.web, closed.stdout) + self.assertEqual(self.gitea.posted[-1]["base"], "main") + refs = self.git(self.repo, "for-each-ref", "--format=%(refname:short)", + "refs/heads/feature/card-close-*").stdout.splitlines() + self.assertEqual(len(refs), 1) + archived = self.git(self.repo, "show", f"{refs[0]}:.task/archive/feature-alpha-login.md").stdout + self.assertIn("已包含于 origin/main", archived) + self.assertIn("PR #5", archived) + if __name__ == "__main__": unittest.main()