agent-exec transport cd4961f74af64c6ebd54c5fd0bf27d27/d63165e5e315
This commit is contained in:
@@ -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)
|
||||
|
||||
+46
-5
@@ -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))
|
||||
|
||||
+98
-25
@@ -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/<target> 的记录;兼容 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/<target> 祖先,否则不认(不可核即不闭合)。
|
||||
# 仅查最近 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 <<EOF
|
||||
$cards
|
||||
@@ -663,8 +736,8 @@ EOF
|
||||
local msg; msg=$(mktemp)
|
||||
{ echo "Close ${n} merged task card(s) and archive them"
|
||||
echo
|
||||
echo "Why: branches merged into ${TARGET_BRANCH} had left their task cards open; backfill status and archive to prevent bookkeeping debt."
|
||||
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges plus Gitea API squash lookup against origin/${TARGET_BRANCH}."
|
||||
echo "Why: branches merged into their immediate or root target had left their task cards open; backfill status and archive to prevent bookkeeping debt."
|
||||
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges plus Gitea API squash lookup against each card's origin target."
|
||||
echo "Impact: .task/*.md moved to .task/archive/ with status backfilled; no code or submodule change."
|
||||
echo "Refs: task.sh close (card-autoclose-r1)"
|
||||
} > "$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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+146
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user