Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=682a4e8263fe007cb102fc9deaf71bda514ee387. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
@@ -5,7 +5,7 @@ import ast
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_V1 = "agent-execution/v1"
|
||||
@@ -47,7 +47,17 @@ SECRET_VALUE_RE = re.compile(
|
||||
r"(?i)^(bearer\s+\S+|sk-[A-Za-z0-9_\-]{8,}|[A-Fa-f0-9]{32,})$"
|
||||
)
|
||||
|
||||
ROOT_KEYS = frozenset({"schema", "execution", "roles", "profiles", "custom_adapters"})
|
||||
ROOT_KEYS = frozenset(
|
||||
{"schema", "execution", "roles", "profiles", "custom_adapters", "integration"}
|
||||
)
|
||||
INTEGRATION_KEYS = frozenset({"exempt_paths", "transport", "admission", "cleanup"})
|
||||
ENTRYPOINT_KEYS = frozenset({"command"})
|
||||
# Mirrors the host-managed prefixes enforced by scripts/agent_exec.py
|
||||
# (_HOST_MANAGED_PREFIXES, the enforcement source). A declared
|
||||
# integration.exempt_paths must equal this set so it cannot widen host authority.
|
||||
HOST_EXEMPT_PATHS = frozenset(
|
||||
{".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"}
|
||||
)
|
||||
EXECUTION_KEYS = frozenset(
|
||||
{
|
||||
"fresh_process_per_unit",
|
||||
@@ -116,6 +126,14 @@ class CustomAdapter:
|
||||
output_format: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationConfig:
|
||||
exempt_paths: tuple[str, ...]
|
||||
transport_command: str
|
||||
admission_command: str
|
||||
cleanup_command: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionConfig:
|
||||
schema: str
|
||||
@@ -123,6 +141,7 @@ class ExecutionConfig:
|
||||
roles: Mapping[str, tuple[str, ...]]
|
||||
profiles: Mapping[str, Profile]
|
||||
custom_adapters: Mapping[str, CustomAdapter]
|
||||
integration: IntegrationConfig | None = None
|
||||
|
||||
|
||||
def load_config(repo: Path) -> ExecutionConfig:
|
||||
@@ -351,12 +370,17 @@ def _build_config(raw: dict[str, Any]) -> ExecutionConfig:
|
||||
_validate_normal_role_adapters(profiles, roles)
|
||||
_validate_normal_role_exactness(profiles, roles)
|
||||
|
||||
integration = None
|
||||
if "integration" in raw:
|
||||
integration = _build_integration(raw["integration"])
|
||||
|
||||
return ExecutionConfig(
|
||||
schema=schema,
|
||||
execution=execution,
|
||||
roles=roles,
|
||||
profiles=profiles,
|
||||
custom_adapters=custom_adapters,
|
||||
integration=integration,
|
||||
)
|
||||
|
||||
|
||||
@@ -634,6 +658,62 @@ def _build_custom_adapters(raw: Any) -> dict[str, CustomAdapter]:
|
||||
return out
|
||||
|
||||
|
||||
def _build_integration(raw: Any) -> IntegrationConfig:
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError("integration must be a mapping")
|
||||
unknown = set(raw) - INTEGRATION_KEYS
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown keys in integration: {sorted(unknown)}")
|
||||
missing = INTEGRATION_KEYS - set(raw)
|
||||
if missing:
|
||||
raise ConfigError(f"missing keys in integration: {sorted(missing)}")
|
||||
exempt_paths = _exempt_paths_tuple(raw["exempt_paths"])
|
||||
if set(exempt_paths) != HOST_EXEMPT_PATHS:
|
||||
raise ConfigError(
|
||||
"integration.exempt_paths must exactly match the host-managed set "
|
||||
f"{sorted(HOST_EXEMPT_PATHS)}; got {list(exempt_paths)}"
|
||||
)
|
||||
return IntegrationConfig(
|
||||
exempt_paths=exempt_paths,
|
||||
transport_command=_entrypoint_command(raw["transport"], "integration.transport"),
|
||||
admission_command=_entrypoint_command(raw["admission"], "integration.admission"),
|
||||
cleanup_command=_entrypoint_command(raw["cleanup"], "integration.cleanup"),
|
||||
)
|
||||
|
||||
|
||||
def _exempt_paths_tuple(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ConfigError("integration.exempt_paths must be a non-empty list")
|
||||
items: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item:
|
||||
raise ConfigError("integration.exempt_paths entries must be non-empty strings")
|
||||
if item.startswith("/") or ".." in PurePosixPath(item).parts:
|
||||
raise ConfigError(
|
||||
f"integration.exempt_paths must be relative without '..': {item!r}"
|
||||
)
|
||||
if item in items:
|
||||
raise ConfigError(f"duplicate integration.exempt_paths entry: {item!r}")
|
||||
items.append(item)
|
||||
return tuple(items)
|
||||
|
||||
|
||||
def _entrypoint_command(raw: Any, label: str) -> str:
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError(f"{label} must be a mapping")
|
||||
unknown = set(raw) - ENTRYPOINT_KEYS
|
||||
if unknown:
|
||||
raise ConfigError(f"unknown keys in {label}: {sorted(unknown)}")
|
||||
missing = ENTRYPOINT_KEYS - set(raw)
|
||||
if missing:
|
||||
raise ConfigError(f"missing keys in {label}: {sorted(missing)}")
|
||||
command = raw["command"]
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ConfigError(f"{label}.command must be a non-empty string")
|
||||
_reject_shell_or_secret_value(f"{label}.command", command)
|
||||
return command
|
||||
|
||||
|
||||
def _argv_tuple(value: Any, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
raise ConfigError(f"{label} must be a list")
|
||||
|
||||
@@ -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))
|
||||
|
||||
+267
-50
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 任务启动门禁:precheck / start / resume / sync / close / finish(配套 Makefile 同名目标)。
|
||||
# 任务启动门禁:precheck / triage / start / resume / sync / close / finish(配套 Makefile 同名目标)。
|
||||
# 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。
|
||||
set -euo pipefail
|
||||
|
||||
@@ -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
|
||||
@@ -72,6 +72,9 @@ cmd_precheck() {
|
||||
esac
|
||||
done
|
||||
[ -n "$done_open" ] && echo "[cards] 已完成但未归档:$(echo $done_open)"
|
||||
local initiatives
|
||||
initiatives=$(active_initiatives | cut -d'|' -f1)
|
||||
echo "[initiatives] local cards (not authoritative): $([ -n "$initiatives" ] && echo $initiatives || echo none)"
|
||||
now=$(date +%s)
|
||||
while read -r ref ts; do
|
||||
[ -n "$ref" ] || continue
|
||||
@@ -142,10 +145,36 @@ cmd_start() {
|
||||
need_repo
|
||||
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix|release>/<slug>"
|
||||
echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/<slug>"
|
||||
local kind="${TASK_BRANCH_KIND:-standalone}" initiative="${TASK_INITIATIVE:-}"
|
||||
case "$kind" in standalone|initiative|child) ;; *) die "TASK_BRANCH_KIND must be standalone, initiative or child" ;; esac
|
||||
if [ "$kind" = child ]; then
|
||||
[ -n "$initiative" ] || die "child task requires INITIATIVE=<parent initiative branch>"
|
||||
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_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")
|
||||
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。
|
||||
@@ -159,10 +188,31 @@ 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
|
||||
if [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then
|
||||
slug=$(echo "$name" | tr '/' '-')
|
||||
local branch_meta
|
||||
case "$kind" in
|
||||
initiative)
|
||||
branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;;
|
||||
child)
|
||||
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:-无};parent_source:${parent_source}" ;;
|
||||
*)
|
||||
branch_meta="- 分支类型:standalone;root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:false" ;;
|
||||
esac
|
||||
if [ "$kind" != child ] && [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then
|
||||
[ -n "${TASK_PARENT_BRANCH:-}" ] && [ -n "${TASK_PARENT_PR:-}" ] || die "stacked task requires TASK_PARENT_BRANCH and TASK_PARENT_PR"
|
||||
echo "$TASK_PARENT_BRANCH" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent task branch"
|
||||
echo "$TASK_PARENT_PR" | grep -qE '^https?://[^[:space:]]+/pulls/[0-9]+$' || die "TASK_PARENT_PR must be a parent PR URL"
|
||||
@@ -172,7 +222,6 @@ cmd_start() {
|
||||
start_ref=$parent_head
|
||||
parent_note="- 堆叠父任务:${TASK_PARENT_BRANCH};父 PR:${TASK_PARENT_PR};parent_head_sha=${parent_head};父合入后 task-sync 并将 PR base 改回 ${TARGET_BRANCH}"
|
||||
fi
|
||||
slug=$(echo "$name" | tr '/' '-')
|
||||
# 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错,
|
||||
# 以 Git 登记表定位主检出,兼容 linked worktree 与独立 git-dir。
|
||||
mainroot=$(main_worktree_root)
|
||||
@@ -198,6 +247,8 @@ 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(目标、范围和交付终点一次确认;范围内连续推进)
|
||||
@@ -322,27 +373,57 @@ 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() {
|
||||
need_repo
|
||||
fetch_target
|
||||
check_worktree before-sync
|
||||
local base head card branch slug pc changed=0 ancestry_status
|
||||
base=$(git rev-parse "origin/${TARGET_BRANCH}")
|
||||
local base head card branch changed=0 ancestry_status immediate="${TARGET_BRANCH}"
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
card=$(branch_card "$branch")
|
||||
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
|
||||
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
|
||||
base=$(resolve_parent_ref "$immediate")
|
||||
else
|
||||
base=$(git rev-parse "origin/${TARGET_BRANCH}")
|
||||
fi
|
||||
if git merge-base --is-ancestor "$base" HEAD; then
|
||||
:
|
||||
else
|
||||
ancestry_status=$?
|
||||
[ "$ancestry_status" -eq 1 ] || die "cannot verify target ancestry"
|
||||
git merge --no-edit "$base" \
|
||||
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
|
||||
-m "Why: include the current integration baseline before verification." \
|
||||
-m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \
|
||||
-m "Impact: integrate upstream changes without rewriting task history." \
|
||||
-m "Refs: task-sync"
|
||||
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
|
||||
git merge --no-edit "$base" \
|
||||
-m "Merge parent initiative ${immediate} into the task branch" \
|
||||
-m "Why: include the current parent initiative baseline before verification." \
|
||||
-m "Evidence: resolved ${immediate}=${base}." \
|
||||
-m "Impact: integrate parent initiative changes without rewriting task history." \
|
||||
-m "Refs: task-sync"
|
||||
else
|
||||
git merge --no-edit "$base" \
|
||||
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
|
||||
-m "Why: include the current integration baseline before verification." \
|
||||
-m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \
|
||||
-m "Impact: integrate upstream changes without rewriting task history." \
|
||||
-m "Refs: task-sync"
|
||||
fi
|
||||
changed=1
|
||||
fi
|
||||
git merge-base --is-ancestor "$base" HEAD || die "sync incomplete"
|
||||
@@ -350,19 +431,6 @@ cmd_sync() {
|
||||
check_worktree materialize
|
||||
check_worktree after-sync
|
||||
head=$(git rev-parse HEAD)
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
slug=$(echo "$branch" | tr '/' '-')
|
||||
card=".task/${slug}.md"
|
||||
if [ ! -f "$card" ]; then
|
||||
card=""
|
||||
for pc in .task/*.md; do
|
||||
[ -f "$pc" ] || continue
|
||||
if [ "$(card_of_branch "$pc")" = "$branch" ]; then
|
||||
card=$pc
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -n "$card" ] && [ -f "$card" ]; then
|
||||
if [ "$changed" -eq 1 ]; then
|
||||
printf '| 同步(记录前对象) | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
|
||||
@@ -378,7 +446,7 @@ cmd_sync() {
|
||||
echo "[warn] 找不到分支 ${branch} 的任务卡,同步记录未写入" >&2
|
||||
fi
|
||||
head=$(git rev-parse HEAD)
|
||||
echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed}"
|
||||
echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed} sync_target=${immediate}"
|
||||
echo "[next] 跑正式验收(make test 等),证据写入任务卡"
|
||||
}
|
||||
|
||||
@@ -391,19 +459,140 @@ cmd_sync() {
|
||||
|
||||
card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$1" | head -1; }
|
||||
card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
|
||||
# 两级分支元数据行:'- 分支类型:<kind>;initiative_id:…;root_target:…;immediate_target:…;accepting_requirements:…'。
|
||||
# 旧卡没有该行:按 standalone、root/immediate 均为目标分支、不接收新需求处理。
|
||||
card_field() { sed -n "s/^- 分支类型:.*;$2:\([^;]*\).*/\1/p" "$1" 2>/dev/null | head -1; }
|
||||
card_branch_kind() { local v; v=$(sed -n 's/^- 分支类型:\([^;]*\).*/\1/p' "$1" 2>/dev/null | head -1); printf '%s\n' "${v:-standalone}"; }
|
||||
card_initiative_id() { card_field "$1" initiative_id; }
|
||||
card_root_target() { local v; v=$(card_field "$1" root_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
|
||||
card_immediate_target() { local v; v=$(card_field "$1" immediate_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
|
||||
card_accepting_requirements() { local v; v=$(card_field "$1" accepting_requirements); printf '%s\n' "${v:-false}"; }
|
||||
branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空)
|
||||
local pc=".task/$(echo "$1" | tr '/' '-').md"
|
||||
[ -f "$pc" ] && { printf '%s\n' "$pc"; return 0; }
|
||||
for pc in .task/*.md; do
|
||||
[ -f "$pc" ] || continue
|
||||
[ "$(card_of_branch "$pc")" = "$1" ] && { printf '%s\n' "$pc"; return 0; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为集成分支、未完成、仍接收需求且未合入/待闭合
|
||||
local pc pb
|
||||
for pc in .task/*.md; do
|
||||
[ -f "$pc" ] || continue
|
||||
[ "$(card_branch_kind "$pc")" = initiative ] || continue
|
||||
[ "$(card_root_target "$pc")" = "${TARGET_BRANCH}" ] || continue
|
||||
[ "$(card_accepting_requirements "$pc")" = true ] || continue
|
||||
case "$(card_status "$pc")" in 已完成*) continue ;; esac
|
||||
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
|
||||
card_pending "${pc##*/}" && continue
|
||||
card_merged_into "$pc" "$pb" >/dev/null && continue
|
||||
printf '%s|%s|%s\n' "$pb" "$(card_initiative_id "$pc")" "$pc"
|
||||
done
|
||||
}
|
||||
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
|
||||
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
|
||||
remote=$(git rev-parse --verify "refs/remotes/origin/$1^{commit}")
|
||||
elif [ "$rc" -ne 2 ]; then
|
||||
echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1; preserve local work and retry" >&2
|
||||
return 6
|
||||
fi
|
||||
local_ref=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true)
|
||||
if [ -n "$remote" ] && [ -n "$local_ref" ]; then
|
||||
if git merge-base --is-ancestor "$remote" "$local_ref"; then printf '%s\n' "$local_ref"
|
||||
elif git merge-base --is-ancestor "$local_ref" "$remote"; then printf '%s\n' "$remote"
|
||||
else die "parent branch $1 diverged between local and origin; reconcile it first"
|
||||
fi
|
||||
elif [ -n "$remote" ]; then printf '%s\n' "$remote"
|
||||
elif [ -n "$local_ref" ]; then printf '%s\n' "$local_ref"
|
||||
else die "parent branch $1 not found locally or on origin"
|
||||
fi
|
||||
}
|
||||
|
||||
# 需求分流以 origin 为准(scripts/task_triage.py):远端 open PR + 远端分支卡片;本地 .task/ 不参与发现。
|
||||
# 计数不自动选择 JOIN;候选存在时必须经 triage-decide 记录显式决策。
|
||||
cmd_triage() {
|
||||
need_repo
|
||||
[ -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 \
|
||||
| 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
|
||||
@@ -444,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-* 分支待合入
|
||||
@@ -454,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)
|
||||
@@ -468,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。
|
||||
'''
|
||||
@@ -487,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
|
||||
@@ -499,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"
|
||||
@@ -518,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##*/}"
|
||||
@@ -530,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
|
||||
@@ -541,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"
|
||||
@@ -560,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
|
||||
@@ -594,6 +789,13 @@ cmd_finish() {
|
||||
head=$(git rev-parse HEAD)
|
||||
mainrepo=$(main_worktree_root)
|
||||
fetch_target
|
||||
local card immediate="${TARGET_BRANCH}" merged_into
|
||||
card=$(branch_card "$branch")
|
||||
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
|
||||
merged_into="origin/${TARGET_BRANCH}"
|
||||
if [ "$immediate" != "${TARGET_BRANCH}" ] && [ "$abandon" != "--abandon" ]; then
|
||||
merged_into=$(resolve_parent_ref "$immediate")
|
||||
fi
|
||||
if [ "$abandon" = "--abandon" ]; then
|
||||
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon requires a reason"
|
||||
# An explicit abandon removes only the clean checkout; commits stay reachable.
|
||||
@@ -602,15 +804,19 @@ cmd_finish() {
|
||||
elif [ -n "$abandon" ]; then
|
||||
die "usage: task.sh finish [--abandon <reason>]"
|
||||
else
|
||||
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "branch has not merged into origin/${TARGET_BRANCH}; preserve it or explicitly abandon the checkout"
|
||||
git merge-base --is-ancestor "$head" "$merged_into" || die "branch has not merged into ${immediate}; preserve it or explicitly abandon the checkout"
|
||||
remote_status=$(git ls-remote --heads origin "refs/heads/${branch}") || die "cannot verify remote task branch; preserve workspace"
|
||||
if [ -n "$remote_status" ]; then
|
||||
# Refresh its object, then use that exact observation as a deletion lease.
|
||||
git fetch -q origin "refs/heads/${branch}" || die "cannot fetch remote task branch; preserve workspace"
|
||||
remote_head=$(git rev-parse --verify FETCH_HEAD)
|
||||
git merge-base --is-ancestor "$remote_head" "origin/${TARGET_BRANCH}" || die "remote task branch has unmerged work; preserve workspace and remote branch"
|
||||
git merge-base --is-ancestor "$remote_head" "$merged_into" || die "remote task branch has unmerged work; preserve workspace and remote branch"
|
||||
fi
|
||||
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
|
||||
echo "[verified] ${branch} is included in parent initiative ${immediate}"
|
||||
else
|
||||
echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}"
|
||||
fi
|
||||
echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}"
|
||||
fi
|
||||
# Re-check after fetch. --force only compensates Git's gitlink limitation after all
|
||||
# registered-root, owner, lock, recursive dirt and ignored-file checks have passed.
|
||||
@@ -623,9 +829,18 @@ cmd_finish() {
|
||||
die "Git refused worktree removal; workspace and branch retained. Owner must inspect the refusal; no rm fallback or additional force level is permitted."
|
||||
fi
|
||||
cd "$mainrepo"
|
||||
local deleted=1
|
||||
if [ "$abandon" = "--abandon" ]; then
|
||||
:
|
||||
elif [ "$immediate" != "${TARGET_BRANCH}" ]; then
|
||||
# git branch -d compares with the main checkout HEAD; the child is merged into its parent instead.
|
||||
git update-ref -d "refs/heads/${branch}" "$head" && deleted=0
|
||||
else
|
||||
git branch -d "$branch" && deleted=0
|
||||
fi
|
||||
if [ "$abandon" = "--abandon" ]; then
|
||||
echo "[recycled] worktree removed; local branch=${branch} retained; remote=NOT_RUN"
|
||||
elif git branch -d "$branch"; then
|
||||
elif [ "$deleted" -eq 0 ]; then
|
||||
if [ -z "$remote_head" ]; then
|
||||
echo "[recycled] worktree and local branch=${branch} removed; remote=ALREADY_ABSENT"
|
||||
elif git push -q "--force-with-lease=refs/heads/${branch}:${remote_head}" origin ":refs/heads/${branch}"; then
|
||||
@@ -644,10 +859,12 @@ 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|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
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user