Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=e1bc2602e18a89e4d75a4bf6cd80bc7f9b4d2164. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
Submodule modules/backend updated: 8e80e2618e...c8f8076579
+1
-1
Submodule modules/documents updated: 6619835a65...46506aec34
+61
-10
@@ -57,9 +57,9 @@ cmd_precheck() {
|
||||
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
|
||||
pst=$(card_status "$pc"); case "$pst" in 已完成*) continue ;; esac
|
||||
card_pending "${pc##*/}" && continue
|
||||
# Consume the complete producer output: grep -q exits on the first match,
|
||||
# making git log die with SIGPIPE (141 under pipefail) on long histories.
|
||||
if git log --merges --format='%s' "origin/${TARGET_BRANCH}" 2>/dev/null | grep -F " from $pb into ${TARGET_BRANCH}" >/dev/null; then
|
||||
# card_merge 兼容 merge 提交与 squash 落地(squash 走 Gitea API 兜底,无 token 时只认 merge 提交);
|
||||
# 其内部以完整消费输出取首行,避免 grep -q 早退令 git log 吃 SIGPIPE(141 under pipefail)。
|
||||
if card_merge "$pb" >/dev/null; then
|
||||
open_cards="${open_cards} ${pc##*/}"
|
||||
fi
|
||||
done
|
||||
@@ -382,9 +382,61 @@ 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; }
|
||||
card_merge() { # $1=branch → "sha|subject"(该分支合入 origin/<target> 的合并提交)
|
||||
git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
|
||||
| grep -F " from $1 into ${TARGET_BRANCH}" | sed -n '1p'
|
||||
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=""
|
||||
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=branch → 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
|
||||
[ -n "$token" ] || return 1
|
||||
web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
|
||||
url=$(git remote get-url origin 2>/dev/null) || return 1
|
||||
url="${url%.git}"
|
||||
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
|
||||
api="${web_base}/api/v1/repos/${owner}/${repo}/pulls"
|
||||
found=""
|
||||
for pg in 1 2 3 4 5; do
|
||||
payload=$(curl -fsS -m 15 -H "Authorization: token ${token}" "${api}?state=all&limit=50&page=${pg}" 2>/dev/null) || payload=""
|
||||
[ -n "$payload" ] || break
|
||||
found=$(printf '%s' "$payload" | CARD_BRANCH="$1" python3 -c '
|
||||
import json, os, sys
|
||||
branch = os.environ.get("CARD_BRANCH", "")
|
||||
try:
|
||||
entries = json.load(sys.stdin)
|
||||
except Exception:
|
||||
entries = []
|
||||
for p in entries if isinstance(entries, list) else []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
head = p.get("head") or {}
|
||||
ref = head.get("ref") or ""
|
||||
label = head.get("label") or ""
|
||||
# head 分支被删后 Gitea 把 ref 换成 refs/pull/N/head,原始分支名留在 label
|
||||
# (同仓为裸分支名,fork 为 owner:branch)。
|
||||
if branch != ref and branch != label and not label.endswith(":" + branch):
|
||||
continue
|
||||
if not p.get("merged"):
|
||||
continue
|
||||
sha, title, num = p.get("merge_commit_sha") or "", p.get("title") or "", str(p.get("number") or "")
|
||||
if sha and title and num:
|
||||
print(f"{sha}|{title}|{num}")
|
||||
break
|
||||
') && [ -n "$found" ] && break
|
||||
done
|
||||
[ -n "$found" ] || return 1
|
||||
sha="${found%%|*}"
|
||||
git merge-base --is-ancestor "$sha" "origin/${TARGET_BRANCH}" 2>/dev/null || return 1
|
||||
printf '%s\n' "$found"
|
||||
}
|
||||
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
|
||||
local cb
|
||||
@@ -467,9 +519,8 @@ $c|$b|$m"
|
||||
n=$((n+1))
|
||||
continue
|
||||
fi
|
||||
sha="${m%%|*}"; subject="${m#*|}"
|
||||
pr=$(printf '%s' "$subject" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p')
|
||||
[ -n "$pr" ] || pr=unknown
|
||||
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}"
|
||||
mkdir -p .task/archive
|
||||
git mv "$c" ".task/archive/${c##*/}"
|
||||
@@ -482,7 +533,7 @@ EOF
|
||||
{ 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 against origin/${TARGET_BRANCH}."
|
||||
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges plus Gitea API squash lookup against origin/${TARGET_BRANCH}."
|
||||
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"
|
||||
|
||||
@@ -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