diff --git a/AGENTS.md b/AGENTS.md index ae3422fe..01e23625 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 核验后合入。 ## 证据与验证门禁(强制) diff --git a/Makefile b/Makefile index 77f2f6f9..d1fd4f17 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/policy/delivery.md b/policy/delivery.md index 67bde148..606676bb 100644 --- a/policy/delivery.md +++ b/policy/delivery.md @@ -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。 中断或交接时记录当前有效版本、批准范围、未决假设、最新试验结论、剩余事项及下一动作。 diff --git a/policy/governance.md b/policy/governance.md index 30985805..7e328e43 100644 --- a/policy/governance.md +++ b/policy/governance.md @@ -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` 保存输出,清理结果由后续闭合记录引用。 diff --git a/scripts/evidence.py b/scripts/evidence.py index b534860c..fc36c290 100644 --- a/scripts/evidence.py +++ b/scripts/evidence.py @@ -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") diff --git a/scripts/hooks/post-merge b/scripts/hooks/post-merge index fab194bd..dcbe397a 100755 --- a/scripts/hooks/post-merge +++ b/scripts/hooks/post-merge @@ -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 diff --git a/scripts/task.sh b/scripts/task.sh index ff0d5e6d..ac0d6d28 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -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 diff --git a/scripts/task_pr_check.py b/scripts/task_pr_check.py index d928476c..19b6d92d 100644 --- a/scripts/task_pr_check.py +++ b/scripts/task_pr_check.py @@ -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): diff --git a/tests/test_evidence.py b/tests/test_evidence.py index e179cbc3..520d4a27 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -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")) diff --git a/tests/test_task.py b/tests/test_task.py index dd6ff536..e8ed576a 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -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() diff --git a/tests/test_task_pr_check.py b/tests/test_task_pr_check.py index 4454fd26..cb9b7c34 100644 --- a/tests/test_task_pr_check.py +++ b/tests/test_task_pr_check.py @@ -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()