Detect squash-merged PRs when closing task cards
Why: card_merge only recognized "Merge ... from <branch> into main" merge commits, so a PR landed as a squash (real case: #457) left its task card in .task/ forever; task-close reported nothing to close and precheck showed no debt. Closing required a manual backfill (PR #541). Evidence: card_merge now returns sha|subject|pr and falls back to a Gitea API lookup by head ref or label (deleted branches keep the original name only in label), gated by merge_commit_sha ancestry in origin/main; precheck [cards] reuses card_merge so both surfaces agree. Live probe against production Gitea returns 226a4af15791|Reset per-service runtime state in svc.sh env_config|457 for feature/svc-pidcheck-leak; stub-server tests cover squash landing, decoy skip and the not-on-main refusal; full governance suite follows in-card evidence. Impact: scripts/task.sh close and precheck detect squash-merged cards when GITEA_TOKEN is set (merge-commit path unchanged without token); tests/test_task.py adds two regressions; no protocol change for existing merge-commit closures beyond the appended pr field. Refs: card-merge-misses-squash-merges (memory); PR #541 manual closure.
This commit is contained in:
@@ -802,6 +802,90 @@ class TaskLifecycleTests(unittest.TestCase):
|
||||
self.assertIn("状态:已完成(回填)", archived)
|
||||
self.assertIn(actual_merge, archived)
|
||||
|
||||
def _stub_gitea(self, get_reply, post_reply=None):
|
||||
"""Stub Gitea API:GET 按路径返回 get_reply(path),POST 返回建 PR 应答。"""
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import threading
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def _send(self, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
self._send(get_reply(self.path))
|
||||
|
||||
def do_POST(self):
|
||||
self.rfile.read(int(self.headers.get("Content-Length") or 0))
|
||||
self._send(post_reply or {"html_url": "http://stub.example/pr/1"})
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
self.addCleanup(server.shutdown)
|
||||
return f"http://127.0.0.1:{server.server_port}", server
|
||||
|
||||
def test_close_backfills_card_landed_as_squash(self):
|
||||
# squash 落地没有 "from <branch> into main" 合并提交:card_merge 须经 Gitea API 识别,
|
||||
# 否则任务卡永远留在 .task/ 根目录(2026-10-14 #457 实测漏检)。
|
||||
wt = self.worktree("feature/squashy")
|
||||
self.record_task_card(wt)
|
||||
branch = self.git(wt, "branch", "--show-current").stdout.strip()
|
||||
self.git(wt, "push", "-u", "origin", branch)
|
||||
self.git(self.repo, "fetch", "origin", branch)
|
||||
self.git(self.repo, "merge", "--squash", "FETCH_HEAD")
|
||||
self.git(self.repo, "commit", "-m", "Squash the fixture task")
|
||||
squash_sha = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
|
||||
self.git(self.repo, "push", "origin", "main")
|
||||
|
||||
base, _ = self._stub_gitea(
|
||||
lambda path: [{
|
||||
"number": 41, "title": "Decoy other branch", "state": "closed",
|
||||
"merged": True, "head": {"ref": "feature/other", "label": "feature/other"},
|
||||
"merge_commit_sha": squash_sha,
|
||||
}, {
|
||||
# head 分支已删的真实形态:ref 换成 pull 引用,原始分支名在 label
|
||||
"number": 42, "title": "Fixture squash landing", "state": "closed",
|
||||
"merged": True, "head": {"ref": "refs/pull/42/head", "label": branch},
|
||||
"merge_commit_sha": squash_sha,
|
||||
}] if "/pulls" in path else [],
|
||||
post_reply={"html_url": "http://stub.example/pr/42"})
|
||||
env = self.env | {"GITEA_TOKEN": "stub-token", "TASK_CARD_WEB_BASE": base}
|
||||
closed = self.task(self.repo, "close", env=env)
|
||||
self.assertIn("[closed] 1 张已合入卡片", closed.stdout)
|
||||
refs = self.git(self.repo, "for-each-ref", "--format=%(refname:short)",
|
||||
"refs/heads/feature/card-close-*").stdout.splitlines()
|
||||
self.assertEqual(len(refs), 1)
|
||||
archived = self.git(self.repo, "show", f"{refs[0]}:.task/archive/feature-squashy.md").stdout
|
||||
self.assertIn("状态:已完成(回填)", archived)
|
||||
self.assertIn(squash_sha, archived)
|
||||
self.assertIn("PR #42", archived)
|
||||
commit_msg = self.git(self.repo, "log", "-1", "--format=%B", refs[0]).stdout
|
||||
self.assertIn("Gitea API squash lookup", commit_msg)
|
||||
|
||||
def test_close_ignores_squash_claim_when_sha_not_on_main(self):
|
||||
# API 声称 merged 但 merge_commit_sha 不在 origin/main(伪造或陈旧):不可核即不闭合。
|
||||
wt = self.worktree("feature/phantom")
|
||||
self.record_task_card(wt)
|
||||
branch = self.git(wt, "branch", "--show-current").stdout.strip()
|
||||
self.git(wt, "push", "-u", "origin", branch)
|
||||
tip = self.git(wt, "rev-parse", "HEAD").stdout.strip()
|
||||
base, _ = self._stub_gitea(
|
||||
lambda path: [{
|
||||
"number": 43, "title": "Phantom squash", "state": "closed",
|
||||
"merged": True, "head": {"ref": branch},
|
||||
"merge_commit_sha": tip,
|
||||
}] if "/pulls" in path else [])
|
||||
env = self.env | {"GITEA_TOKEN": "stub-token", "TASK_CARD_WEB_BASE": base}
|
||||
closed = self.task(self.repo, "close", env=env)
|
||||
self.assertIn("无已合入未闭合的任务卡", closed.stdout)
|
||||
|
||||
def assert_post_merge_only_reminds_owner(self, operation):
|
||||
expected_head, expected_card = self.publish_merged_task()
|
||||
self.git(self.repo, "fetch", "origin")
|
||||
|
||||
Reference in New Issue
Block a user