agent-exec transport b9c7fb196afb6984f4367f30ec8ec18a/162bb30bb46e
This commit is contained in:
@@ -113,12 +113,13 @@ policy/delivery.md。
|
||||
`TASK_WRITERS_STOPPED=1 make task-finish`。父子仓改动、未跟踪/ignored 唯一文件先保存或显式处置;删除被拒时
|
||||
保留现场并按原因处理,禁止强删兜底;无法证明写者已退出时才请 owner 确认。放弃未合入工作用
|
||||
`make task-finish ABANDON="--abandon <原因>"` 并记录原因;放弃不豁免现场保护。
|
||||
- **任务卡闭合(防欠账)**:分支合入 main 后,其任务卡须回填"已完成"+主记录 PR 链接并
|
||||
归档至 `.task/archive/`。`task-finish` 回收后按提示运行 `make task-close`,不借回收修改
|
||||
共享主检出或推送另一任务。主检出 pull/merge 的 post-merge 钩子仅提示待办,不自动写入或推送。
|
||||
`make task-close` 会提交、推送并可能创建 PR,标准闭合属于已授权交付的收口;仍须核对只包含本任务卡,否则停止扩围。闭合在主检出运行→建
|
||||
`feature/card-close-*` 分支+PR,任务分支内加 `FOLD=--fold` 随当前 PR 合入。
|
||||
precheck 的 `[cards]` 行报告欠账,验收前应清零;闭合 PR 同样由智能体按
|
||||
- **任务卡闭合(防欠账)**:默认在合入前闭合——最终同步/合入前,在任务 worktree 运行
|
||||
`make task-close SELF=1`:本分支任务卡改为"已完成(合入前闭合)"、主记录写入本 PR 链接并归档至
|
||||
`.task/archive/`,随同一 PR 合入,该 PR 的合并提交即落地记录,无需单独闭合 PR 或合入后回填。
|
||||
它会提交并推送当前任务分支,属于已授权交付的收口;只处理本分支的卡。主检出 pull/merge 的
|
||||
post-merge 钩子仅提示,不自动写入或推送。主检出的 `make task-close`(建 `feature/card-close-*`
|
||||
分支+PR)或任务分支内 `FOLD=--fold` 只用于已合入但未闭合的遗留卡;仍须核对只包含目标任务卡,
|
||||
否则停止扩围。precheck 的 `[cards]` 行报告欠账,验收前应清零;遗留卡的闭合 PR 同样由智能体按
|
||||
policy/delivery.md 完成 Review、冲突处置及当前 HEAD 的 CI 核验后合入。
|
||||
|
||||
## 证据与验证门禁(强制)
|
||||
|
||||
@@ -54,8 +54,10 @@ task-resume:
|
||||
task-sync:
|
||||
@bash $(TASK) sync
|
||||
|
||||
# 任务卡闭合:默认在任务 worktree 合入前运行 SELF=1(本卡随当前 PR 完成并归档,无需单独闭合 PR);
|
||||
# 不带参数(主检出)或 FOLD=--fold 仅用于已合入未闭合的遗留卡。
|
||||
task-close:
|
||||
@bash $(TASK) close $(FOLD)
|
||||
@bash $(TASK) close $(if $(SELF),--self,$(FOLD))
|
||||
|
||||
task-finish:
|
||||
@bash $(TASK) finish $(ABANDON)
|
||||
|
||||
+5
-3
@@ -157,7 +157,7 @@ host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/s
|
||||
- **子任务分支(child)**:从远端所属大任务分支的当前头建立(须等于分流收据核验的 head,`make task-start` 校验),即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。
|
||||
- **普通任务分支(standalone)**:无需阶段集成的小需求或独立修复,直接从最新 `origin/main` 建立并向 `main` 交付。
|
||||
|
||||
每个任务卡记录 `root_target` 与 `immediate_target`(子任务为所属大任务分支,其余为 `main`)。`make task-resume`、`make task-sync` 与 `make task-finish` 以 `immediate_target` 为准;`make task-close` 仅在 `FOLD=--fold` 时以其为闭合 PR base。
|
||||
每个任务卡记录 `root_target` 与 `immediate_target`(子任务为所属大任务分支,其余为 `main`)。`make task-resume`、`make task-sync` 与 `make task-finish` 以 `immediate_target` 为准;任务卡默认在合入前以 `make task-close SELF=1` 闭合并随本 PR 合入(归档后上述命令仍从 `.task/archive/` 读取合入前闭合的卡);遗留卡的 `make task-close` 仅在 `FOLD=--fold` 时以其为闭合 PR base。
|
||||
|
||||
每次接到新需求,创建分支前必须分流,事实来源为 origin(本地 `.task/` 不参与发现):
|
||||
1. **发现**:`make task-triage REQ='<需求>' TRIAGE_RECEIPT=<路径>` 查询远端。候选须已推送、存在以 `main` 为 base 的开放 PR,且该远端分支的任务卡声明 `branch_kind=initiative`、`accepting_requirements=true` 且未标记已完成。无候选时收据直接为 `NEW`;有候选时为 `DECISION_REQUIRED`(退出码 5)。远端或 Gitea 不可核验时记为 `REMOTE_UNVERIFIED`(退出码 6),不得得出 `NEW`。
|
||||
@@ -353,8 +353,10 @@ Review 只将阻塞项列为必修;非阻塞意见标明后续建议,不得
|
||||
|
||||
清理前保存任务主记录与证据。执行回收时将命令输出用 evidence 的 `RECEIPT` 模式保存到
|
||||
待删目录之外;回收后的结果写入后续闭合记录,不能向已删除的任务卡补写收据。
|
||||
任务卡须回填真实主 PR 和完成状态并归档;闭合 PR 的创建核对当前闭合授权,合入按上述
|
||||
智能体 Review 与 PR 合入流程执行。
|
||||
任务卡默认在最终同步/合入前于任务 worktree 运行 `make task-close SELF=1`:写入本 PR 链接与
|
||||
"已完成(合入前闭合)"状态并归档,随同一 PR 合入,不另建闭合 PR。仅已合入未闭合的遗留卡在主检出
|
||||
运行 `make task-close`(`feature/card-close-*` PR)或用 `FOLD=--fold` 回填归档;其闭合 PR 的创建核对
|
||||
当前闭合授权,合入按上述智能体 Review 与 PR 合入流程执行。
|
||||
post-merge 默认只提示,不因 pull 或 merge 自动提交、推送或创建闭合 PR。
|
||||
|
||||
中断或交接时记录当前有效版本、批准范围、未决假设、最新试验结论、剩余事项及下一动作。
|
||||
|
||||
@@ -138,7 +138,8 @@ Git 对含子模块的工作区需要显式删除选项时,只有全部现场
|
||||
子仓 pin 之外的 refs/stash/reflog 历史采取保守拒绝,即使其中部分可能已在远端:
|
||||
owner 须先独立确认其保存与归属,工具不把缓存的远端引用当作唯一数据已经备份的证据。
|
||||
远端分支删除与本地回收分别报告,失败不能声明两者全部完成。
|
||||
回收后由所有者执行 `make task-close` 闭合卡片;回收命令不顺带改写共享主检出。闭合 PR 默认 base 为 `main`;仅 `FOLD=--fold`(随任务分支合入)时 base 为卡片 `immediate_target`。
|
||||
任务卡默认在合入前闭合:最终同步/合入前在任务 worktree 运行 `make task-close SELF=1`,本卡改为"已完成(合入前闭合)"并归档,随同一 PR 合入,无需单独闭合 PR;回收命令不顺带改写共享主检出。
|
||||
主检出的 `make task-close`(闭合 PR base 为 `main`)或 `FOLD=--fold`(随任务分支合入,base 为卡片 `immediate_target`)只用于已合入未闭合的遗留卡。
|
||||
post-merge 仅提示,不自动调用闭合。`make task-close` 会提交、推送并可能创建 PR,
|
||||
执行者先按迭代交付规则核对该动作的授权及所选任务,保留未授权的其他任务现场。
|
||||
回收命令使用待删目录之外的 `RECEIPT` 保存输出,清理结果由后续闭合记录引用。
|
||||
|
||||
+10
-2
@@ -23,6 +23,7 @@ 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")
|
||||
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
|
||||
BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
|
||||
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
|
||||
|
||||
@@ -255,7 +256,7 @@ def task_context(current=None):
|
||||
if (root / ".task").is_symlink():
|
||||
raise EvidenceError("Task registration must belong to the current checkout")
|
||||
card = find_card()
|
||||
if card.is_symlink() or card.resolve().parent != root / ".task":
|
||||
if card.is_symlink() or card.resolve().parent not in (root / ".task", root / ".task" / "archive"):
|
||||
raise EvidenceError("Task registration must belong to the current checkout")
|
||||
content = card.read_text()
|
||||
locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M)
|
||||
@@ -318,8 +319,15 @@ def find_card():
|
||||
card = root / ".task" / (branch.replace("/", "-") + ".md")
|
||||
if card.is_file():
|
||||
return card
|
||||
owner = re.compile(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", re.M)
|
||||
for candidate in (root / ".task").glob("*.md"):
|
||||
if re.search(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", candidate.read_text(), re.M):
|
||||
if owner.search(candidate.read_text()):
|
||||
return candidate
|
||||
# A card fold-closed before merge (task.sh close --self) lives in the archive until its PR lands.
|
||||
for candidate in sorted((root / ".task" / "archive").glob("*.md")):
|
||||
text = candidate.read_text()
|
||||
status = re.search(r";状态:([^;\n]*)", text)
|
||||
if owner.search(text) and status and status.group(1).startswith(PRE_MERGE_CLOSED):
|
||||
return candidate
|
||||
raise EvidenceError("No task card exists for the current branch")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地钩子(post-merge):pull/merge 落地后提示 owner 按授权闭合任务卡;ff-only 也会触发。
|
||||
# precheck [cards] 报告欠账;此钩子只提示,不执行闭合或其他写入。
|
||||
# 本地钩子(post-merge):pull/merge 落地后提示任务卡闭合方式;ff-only 也会触发。
|
||||
# 新任务在合入前用 make task-close SELF=1 闭合本卡;precheck [cards] 报告的遗留卡才需主检出闭合。
|
||||
# 此钩子只提示,不执行闭合或其他写入。
|
||||
# 触发条件收紧到"主检出 + 集成分支 main"——任务 worktree 里 task-sync 的 merge 不触发,
|
||||
# 防止把卡片闭合提示混进任务分支现场。
|
||||
common=$(git rev-parse --git-common-dir) && common=$(cd "$common" 2>/dev/null && pwd) || exit 0
|
||||
@@ -8,5 +9,5 @@ mainrepo=$(dirname "$common")
|
||||
[ "$mainrepo" = "$PWD" ] || exit 0
|
||||
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || exit 0
|
||||
[ -f "$mainrepo/scripts/task.sh" ] || exit 0
|
||||
echo "[next] owner 按已有授权在主检出运行 make task-close 闭合已合入任务卡;未授权时先取得授权。"
|
||||
echo "[next] 新任务在合入前于任务 worktree 运行 make task-close SELF=1 闭合本卡;主检出的 make task-close 仅用于 precheck [cards] 报告的遗留卡,owner 按已有授权运行,未授权时先取得授权。"
|
||||
exit 0
|
||||
|
||||
+102
@@ -456,6 +456,8 @@ cmd_sync() {
|
||||
# (PR 链接+合并 SHA)与闭合记录,并 git mv 到 .task/archive/。变更落点:主检出运行→
|
||||
# 新建 feature/card-close-* 分支推送(集成分支受保护,不直推);任务分支内运行须 --fold
|
||||
# (随当前任务 PR 合入)。合并判定用合并提交主题(分支删除后仍可追溯),不依赖分支存活。
|
||||
# 默认闭合方式是 close --self:任务分支在合入前把本分支的卡改为"已完成(合入前闭合)"并归档,
|
||||
# 随本 PR 合入;上述合入后回填只用于遗留卡。
|
||||
|
||||
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; }
|
||||
@@ -467,13 +469,20 @@ 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}"; }
|
||||
PRE_MERGE_CLOSED="已完成(合入前闭合)"
|
||||
branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空)
|
||||
# 顶层优先;顶层无卡时只认 .task/archive/ 中状态为"已完成(合入前闭合)"的卡(close --self 合入前已归档)。
|
||||
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
|
||||
for pc in .task/archive/*.md; do
|
||||
[ -f "$pc" ] || continue
|
||||
[ "$(card_of_branch "$pc")" = "$1" ] || continue
|
||||
case "$(card_status "$pc")" in "${PRE_MERGE_CLOSED}"*) printf '%s\n' "$pc"; return 0 ;; esac
|
||||
done
|
||||
return 0
|
||||
}
|
||||
active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为集成分支、未完成、仍接收需求且未合入/待闭合
|
||||
@@ -665,8 +674,101 @@ open(path, 'w', encoding='utf-8').write(s)
|
||||
PY
|
||||
}
|
||||
|
||||
self_close_pr() { # $1=branch → 打印 Gitea 上 head 为该分支的开放 PR 号(空格分隔;token 只在进程内使用)
|
||||
CLOSE_BRANCH="$1" TASK_SCRIPT_DIR="$SCRIPT_DIR" python3 - <<'PY'
|
||||
import os, subprocess, sys
|
||||
sys.path.insert(0, os.environ["TASK_SCRIPT_DIR"])
|
||||
from task_triage import Stop, api_base, open_pulls, read_token, repo_slug
|
||||
branch = os.environ["CLOSE_BRANCH"]
|
||||
try:
|
||||
origin = subprocess.run(["git", "remote", "get-url", "origin"], capture_output=True, text=True)
|
||||
if origin.returncode != 0:
|
||||
raise Stop(6, "origin remote is not configured")
|
||||
token = read_token()
|
||||
if not token:
|
||||
raise Stop(6, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
|
||||
pulls = open_pulls(api_base(), repo_slug(origin.stdout.strip()), token)
|
||||
except Stop as stop:
|
||||
print(f"ERROR: cannot look up the open PR for {branch}: {stop.message}", file=sys.stderr)
|
||||
sys.exit(6)
|
||||
numbers = []
|
||||
for pr in pulls:
|
||||
if not isinstance(pr, dict) or pr.get("state", "open") != "open":
|
||||
continue
|
||||
head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||
if head.get("ref") == branch and str(pr.get("number", "")).isdigit():
|
||||
numbers.append(str(pr["number"]))
|
||||
print(" ".join(numbers))
|
||||
PY
|
||||
}
|
||||
|
||||
card_self_close() { # $1=card $2=pr_num $3=pr_url(原地改写:状态、主记录、结果节标题、合入前闭合记录)
|
||||
python3 - "$1" "$2" "$3" <<'PY'
|
||||
import re, sys
|
||||
path, pr, link = sys.argv[1:4]
|
||||
s = open(path, encoding='utf-8').read()
|
||||
s, n = re.subn(r';状态:[^;\n]*', ';状态:已完成(合入前闭合)', s, count=1)
|
||||
if n != 1:
|
||||
sys.exit(f"ERROR: {path} has no ';状态:' field; refuse to fold-close")
|
||||
s = re.sub(r'任务主记录:[^;\n]*', f'任务主记录:PR #{pr} {link}', s, count=1)
|
||||
s = s.replace('## 结果与验证(完成后填写)', '## 结果与验证', 1)
|
||||
s = s.rstrip('\n') + f'''
|
||||
|
||||
## 闭合记录(合入前折叠)
|
||||
|
||||
- 本卡在 PR #{pr}({link})合入前闭合并归档,随该 PR 一同合入。
|
||||
- 该 PR 的合并提交即本任务的落地记录。
|
||||
- 合入后无需回填,也无需单独的闭合 PR。
|
||||
'''
|
||||
open(path, 'w', encoding='utf-8').write(s)
|
||||
PY
|
||||
}
|
||||
|
||||
cmd_close_self() {
|
||||
local branch card dest pr nums web_base url owner repo link msg
|
||||
branch=$(git symbolic-ref --quiet --short HEAD) || die "close --self 须在任务分支上运行(当前为 detached HEAD)"
|
||||
[ "$branch" != "${TARGET_BRANCH}" ] || die "close --self 不能在 ${TARGET_BRANCH} 上运行;请在任务 worktree 内对本分支的卡运行"
|
||||
[ -z "$(git status --porcelain | grep -v '^.. \.zcode/' || true)" ] || die "任务工作区不干净——先提交或处置现场再 close --self"
|
||||
card=$(branch_card "$branch")
|
||||
[ -n "$card" ] || die "找不到分支 ${branch} 的任务卡"
|
||||
case "$card" in .task/archive/*) die "${card} 已在合入前闭合归档,无需重复闭合" ;; esac
|
||||
dest=".task/archive/${card##*/}"
|
||||
[ ! -e "$dest" ] || die "归档目标已存在:${dest}"
|
||||
if [ -n "${TASK_PR:-}" ]; then
|
||||
case "$TASK_PR" in *[!0-9]*) die "TASK_PR 只能是 PR 号(纯数字)" ;; esac
|
||||
pr="$TASK_PR"
|
||||
else
|
||||
nums=$(self_close_pr "$branch") || die "无法经 Gitea 查询 ${branch} 的开放 PR;可用 TASK_PR=<号> 显式指定"
|
||||
case "$nums" in
|
||||
''|*' '*) die "需要恰好一个 head 为 ${branch} 的开放 PR(查到:${nums:-无});先建 PR,或用 TASK_PR=<号> 指定" ;;
|
||||
esac
|
||||
pr="$nums"
|
||||
fi
|
||||
web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
|
||||
url=$(git remote get-url origin); url="${url%.git}"
|
||||
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
|
||||
link="${web_base}/${owner}/${repo}/pulls/${pr}"
|
||||
card_self_close "$card" "$pr" "$link"
|
||||
mkdir -p .task/archive
|
||||
git mv "$card" "$dest"
|
||||
git add -- "$dest"
|
||||
msg=$(mktemp)
|
||||
{ echo "Fold-close the task card before merge"
|
||||
echo
|
||||
echo "Why: close the task card inside its own PR so landing it needs no post-merge backfill or separate close PR."
|
||||
echo "Evidence: ${card} -> ${dest}; status ${PRE_MERGE_CLOSED}; PR #${pr} ${link}."
|
||||
echo "Impact: task metadata only; the card is archived within PR #${pr} and lands with its merge commit."
|
||||
echo "Refs: task.sh close --self"
|
||||
} > "$msg"
|
||||
git commit -q -F "$msg"; rm -f "$msg"
|
||||
# push 失败重试一次:家服隧道间歇抖动(同 cmd_close)。
|
||||
git push -q -u origin HEAD || { sleep 5; git push -q -u origin HEAD; }
|
||||
echo "[closed] ${card} 已在 PR #${pr} 合入前闭合并归档;合入后无需 task-close"
|
||||
}
|
||||
|
||||
cmd_close() {
|
||||
need_repo
|
||||
if [ "${1:-}" = "--self" ]; then shift; cmd_close_self "$@"; return; fi
|
||||
local fold=0
|
||||
[ "${1:-}" = "--fold" ] && fold=1
|
||||
fetch_target
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
须仍有开放 PR 指向其 root_target。--final(仅 initiative):accepting_requirements=false,且
|
||||
`## 子任务登记` 中「必需=是」的子任务为「已合入」(PR 已合并入本 initiative)或「已延期」。
|
||||
ci
|
||||
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片,核对 GITHUB_BASE_REF。
|
||||
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片(顶层无卡时取 .task/archive/
|
||||
中状态为「已完成(合入前闭合)」的卡),核对 GITHUB_BASE_REF。
|
||||
不联网、不需要 token;非 pull_request 事件跳过(退出 0)。
|
||||
|
||||
期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。
|
||||
@@ -24,11 +25,12 @@ 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,
|
||||
from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, card_status, http_get_json,
|
||||
open_pulls, read_token, repo_slug, target_branch)
|
||||
|
||||
EXIT_FAILED = 1
|
||||
EXIT_USAGE = 2
|
||||
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
|
||||
CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR")
|
||||
DONE_STATES = ("已合入", "已延期")
|
||||
|
||||
@@ -64,16 +66,27 @@ def expected_base(meta):
|
||||
return meta["immediate_target"] if meta["kind"] == "child" else meta["root_target"]
|
||||
|
||||
|
||||
def find_card(branch):
|
||||
"""只看检出树的 .task/ 顶层(archive 不参与);返回 (路径, 文本),无卡返回 (None, None)。"""
|
||||
def matching_card(directory, branch, archived):
|
||||
"""directory 下声明该分支的唯一卡片;archived=True 时只认状态以「已完成(合入前闭合)」开头的卡。"""
|
||||
found = []
|
||||
for path in sorted(Path(".task").glob("*.md")):
|
||||
for path in sorted(Path(directory).glob("*.md")):
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if card_branch(text) == branch:
|
||||
found.append((path, text))
|
||||
if card_branch(text) != branch:
|
||||
continue
|
||||
if archived and not card_status(text).startswith(PRE_MERGE_CLOSED):
|
||||
continue
|
||||
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)
|
||||
return found[0] if found else None
|
||||
|
||||
|
||||
def find_card(branch):
|
||||
"""先看检出树的 .task/ 顶层;顶层无卡时再看 .task/archive/ 中合入前闭合(close --self)的卡,
|
||||
其余归档卡不参与。返回 (路径, 文本),无卡返回 (None, None)。"""
|
||||
return (matching_card(".task", branch, False)
|
||||
or matching_card(".task/archive", branch, True)
|
||||
or (None, None))
|
||||
|
||||
|
||||
def branch_meta(branch, target):
|
||||
|
||||
@@ -385,6 +385,32 @@ class EvidenceTests(unittest.TestCase):
|
||||
cwd=self.repo, capture_output=True, text=True)
|
||||
self.assertNotIn("WARN", checked.stdout + checked.stderr)
|
||||
|
||||
def archive_card(self, status):
|
||||
self.card.unlink()
|
||||
archived = self.repo / ".task/archive/feature-done.md"
|
||||
archived.parent.mkdir()
|
||||
archived.write_text(f"# Done task\n任务主记录:PR #3 fixture;状态:{status};owner:test\n"
|
||||
"- 工作区:fixture(worktree);分支:main\n"
|
||||
"- 分支类型:child;initiative_id:feature-alpha;root_target:main;"
|
||||
"immediate_target:feature/alpha;accepting_requirements:false\n")
|
||||
return archived
|
||||
|
||||
def test_evidence_appends_to_pre_merge_closed_archived_card(self):
|
||||
archived = self.archive_card("已完成(合入前闭合)")
|
||||
result = self.run_evidence("printf 'archived\\n'")
|
||||
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
|
||||
text = archived.read_text()
|
||||
self.assertIn("## 证据(Evidence)", text)
|
||||
self.assertIn('"immediate_target": "feature/alpha"', text)
|
||||
|
||||
def test_evidence_ignores_archived_card_with_other_status(self):
|
||||
archived = self.archive_card("已完成(回填)")
|
||||
original = archived.read_text()
|
||||
result = self.run_evidence("touch should-not-exist")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.repo / "should-not-exist").exists())
|
||||
self.assertEqual(archived.read_text(), original)
|
||||
|
||||
def test_receipts_without_target_metadata_default_to_main(self):
|
||||
data = self.capture()
|
||||
self.assertEqual((data["root_target"], data["immediate_target"]), ("main", "main"))
|
||||
|
||||
@@ -1490,6 +1490,115 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
self.assertIn("已包含于 origin/main", archived)
|
||||
self.assertIn("PR #5", archived)
|
||||
|
||||
# ---- close --self:合入前在任务分支内闭合本卡 ----
|
||||
|
||||
def self_close_env(self):
|
||||
return self.close_env() | {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo"}
|
||||
|
||||
def start_standalone(self, name="feature/solo"):
|
||||
receipt = self.capture_precheck()
|
||||
triage = self.triage_receipt()
|
||||
self.task(self.repo, "start", name,
|
||||
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
|
||||
return self.root / ("repo-wt-" + name.replace("/", "-"))
|
||||
|
||||
def open_pr(self, wt, branch, base="main"):
|
||||
self.git(wt, "push", "-q", "-u", "origin", branch)
|
||||
number = len(self.gitea.prs) + 1
|
||||
sha = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
self.gitea.prs.append(FakeGitea.pr(number, branch, sha, base=base))
|
||||
return number
|
||||
|
||||
def test_close_self_archives_own_card_inside_its_pr(self):
|
||||
wt = self.start_standalone()
|
||||
(wt / "work").write_text("task result\n")
|
||||
self.git(wt, "add", "work")
|
||||
self.git(wt, "commit", "-m", "Task work")
|
||||
number = self.open_pr(wt, "feature/solo")
|
||||
result = self.task(wt, "close", "--self", env=self.self_close_env())
|
||||
self.assertIn(f"[closed] .task/feature-solo.md 已在 PR #{number} 合入前闭合并归档;合入后无需 task-close",
|
||||
result.stdout)
|
||||
self.assertNotIn(self.gitea.token, result.stdout)
|
||||
self.assertFalse((wt / ".task/feature-solo.md").exists())
|
||||
text = (wt / ".task/archive/feature-solo.md").read_text()
|
||||
self.assertIn(";状态:已完成(合入前闭合);", text)
|
||||
self.assertIn(f"任务主记录:PR #{number} {self.gitea.web}/{self.root.name}/remote/pulls/{number};", text)
|
||||
self.assertIn("## 结果与验证\n", text)
|
||||
self.assertNotIn("## 结果与验证(完成后填写)", text)
|
||||
self.assertIn("## 闭合记录(合入前折叠)", text)
|
||||
self.assertIn(f"PR #{number}", text.split("## 闭合记录(合入前折叠)", 1)[1])
|
||||
self.assertEqual(self.git(wt, "log", "-1", "--format=%s").stdout.strip(),
|
||||
"Fold-close the task card before merge")
|
||||
body = self.git(wt, "log", "-1", "--format=%B").stdout
|
||||
for field in ("Why:", "Evidence:", "Impact:", "Refs: task.sh close --self"):
|
||||
self.assertIn(field, body)
|
||||
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
|
||||
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
self.assertEqual(self.git(self.remote, "rev-parse", "refs/heads/feature/solo").stdout.strip(), head)
|
||||
self.assertEqual(self.git(self.remote, "show", "feature/solo:.task/archive/feature-solo.md").stdout, text)
|
||||
again = self.task(wt, "close", "--self", check=False, env=self.self_close_env())
|
||||
self.assertNotEqual(again.returncode, 0, again.stdout)
|
||||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
|
||||
def test_close_self_accepts_explicit_pr_number_without_lookup(self):
|
||||
wt = self.start_standalone()
|
||||
env = self.close_env() | {"TASK_PR": "42", "GITEA_API_URL": "http://127.0.0.1:9/api/v1"}
|
||||
result = self.task(wt, "close", "--self", env=env)
|
||||
self.assertIn("PR #42 合入前闭合", result.stdout)
|
||||
self.assertIn("任务主记录:PR #42 ", (wt / ".task/archive/feature-solo.md").read_text())
|
||||
|
||||
def test_close_self_refuses_main_detached_dirty_and_missing_pr(self):
|
||||
main_head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
|
||||
on_main = self.task(self.repo, "close", "--self", check=False, env=self.self_close_env())
|
||||
self.assertNotEqual(on_main.returncode, 0, on_main.stdout)
|
||||
self.assertIn("不能在 main 上运行", on_main.stdout)
|
||||
self.assertEqual(self.git(self.repo, "rev-parse", "HEAD").stdout.strip(), main_head)
|
||||
wt = self.start_standalone()
|
||||
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
card = wt / ".task/feature-solo.md"
|
||||
original = card.read_text()
|
||||
|
||||
def assert_refused(result, reason):
|
||||
self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
self.assertIn(reason, result.stdout)
|
||||
self.assertNotIn(self.gitea.token, result.stdout)
|
||||
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
|
||||
self.assertEqual(card.read_text(), original)
|
||||
self.assertFalse((wt / ".task/archive").exists())
|
||||
|
||||
(wt / "stray").write_text("uncommitted\n")
|
||||
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()), "不干净")
|
||||
(wt / "stray").unlink()
|
||||
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()),
|
||||
"需要恰好一个 head 为 feature/solo 的开放 PR")
|
||||
assert_refused(self.task(wt, "close", "--self", check=False,
|
||||
env=self.self_close_env() | {"TASK_PR": "7; rm"}), "TASK_PR")
|
||||
self.git(wt, "checkout", "-q", "--detach")
|
||||
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()), "detached HEAD")
|
||||
|
||||
def test_close_self_child_keeps_initiative_target_from_archive(self):
|
||||
parent = self.start_initiative()
|
||||
wt, _ = self.start_child()
|
||||
number = self.open_pr(wt, "feature/alpha-login", base="feature/alpha")
|
||||
self.task(wt, "close", "--self", env=self.self_close_env())
|
||||
archived = wt / ".task/archive/feature-alpha-login.md"
|
||||
self.assertIn("immediate_target:feature/alpha", archived.read_text())
|
||||
self.assertIn(f"PR #{number}", archived.read_text())
|
||||
(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")
|
||||
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
|
||||
synced = self.task(wt, "sync")
|
||||
self.assertIn("sync_target=feature/alpha", synced.stdout)
|
||||
self.assertIn(f"sync_base={parent_head}", synced.stdout)
|
||||
self.assertIn(f"| 同步(记录前对象) | {parent_head} |", archived.read_text())
|
||||
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative")
|
||||
self.publish_initiative("feature/alpha")
|
||||
finished = self.task(wt, "finish")
|
||||
self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha",
|
||||
finished.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -396,6 +396,42 @@ class PrCheckTests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, code, result.out)
|
||||
self.assertIn(text, result.out)
|
||||
|
||||
def archive_child_card(self, status):
|
||||
"""把子任务卡移到 .task/archive/ 并改写状态(模拟 close --self 合入前折叠)。"""
|
||||
self.build_child()
|
||||
top = self.repo / ".task" / "feature-init-child.md"
|
||||
text = top.read_text(encoding="utf-8").replace(";状态:进行中;", f";状态:{status};")
|
||||
archive = self.repo / ".task" / "archive"
|
||||
archive.mkdir(exist_ok=True)
|
||||
(archive / top.name).write_text(text, encoding="utf-8")
|
||||
top.unlink()
|
||||
self.git(self.repo, "add", "-A", ".task")
|
||||
self.git(self.repo, "commit", "-m", "Archive child card")
|
||||
|
||||
def test_ci_mode_reads_pre_merge_closed_card_from_archive(self):
|
||||
self.archive_child_card("已完成(合入前闭合)")
|
||||
result = self.ci(base="main", head="feature/init-child")
|
||||
self.assertEqual(result.returncode, 1, result.out)
|
||||
self.assertIn("expected 'feature/init' for child branch feature/init-child", result.out)
|
||||
result = self.ci(base="feature/init", head="feature/init-child")
|
||||
self.assertEqual(result.returncode, 0, result.out)
|
||||
self.assertIn("[verdict] PR_CHECK_OK base=feature/init head=feature/init-child kind=child", result.out)
|
||||
|
||||
def test_ci_mode_ignores_archived_card_with_other_status(self):
|
||||
self.archive_child_card("已完成(回填)")
|
||||
result = self.ci(base="feature/init", head="feature/init-child")
|
||||
self.assertEqual(result.returncode, 1, result.out)
|
||||
self.assertIn("expected 'main' for standalone branch feature/init-child", result.out)
|
||||
|
||||
def test_ci_mode_rejects_ambiguous_archived_cards(self):
|
||||
self.archive_child_card("已完成(合入前闭合)")
|
||||
archive = self.repo / ".task" / "archive"
|
||||
(archive / "copy.md").write_text((archive / "feature-init-child.md").read_text(encoding="utf-8"),
|
||||
encoding="utf-8")
|
||||
result = self.ci(base="feature/init", head="feature/init-child")
|
||||
self.assertEqual(result.returncode, 1, result.out)
|
||||
self.assertIn("ambiguous task cards for feature/init-child", result.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user