Merge current main into versioned dependency workspace
Why: Include the latest Dev task-card closure and governance fix while preserving the versioned dependency migration. Evidence: SELF merge conflict inspection, clean staged diff, bash syntax check, and prior isolated E2E replay; full final-head verification pending. Impact: Keep existing Backend and Frontend source directories outside the Git index and retain their on-disk Git data. Preserve password setup, audit grants, scoped browser coverage, and owned process cleanup. Refs: #537, #538, mq-dev-20261014-537-01
This commit is contained in:
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
# feature/proposal-status-20261012 任务卡
|
# feature/proposal-status-20261012 任务卡
|
||||||
|
|
||||||
任务主记录:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/489;状态:进行中;owner:weichen
|
任务主记录:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/489;状态:已完成(回填);owner:weichen
|
||||||
|
|
||||||
## 基线与授权
|
## 基线与授权
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
||||||
## 结果与验证(完成后填写)
|
## 结果与验证
|
||||||
|
|
||||||
- 结果:
|
- 结果:
|
||||||
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
|
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
|
||||||
@@ -784,3 +784,9 @@
|
|||||||
"receipt_sha256": "969b9039ffb9c73f4fb898ebeaec7c9079b2023e7458443d44356aa0d570145a"
|
"receipt_sha256": "969b9039ffb9c73f4fb898ebeaec7c9079b2023e7458443d44356aa0d570145a"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 闭合记录(受控试行回填)
|
||||||
|
|
||||||
|
- 依据:PR #489 由当前智能体合入为 `f00165e8d71e4dc8ba0ceb7dc50c1a8a3deb6d60`,该提交已包含于 `origin/main`;原始主 PR、Review 和精确 HEAD GATE 见任务主记录与 Dev 调度 Issue #518。
|
||||||
|
- 合并主题:Merge pull request 'Pin proposal status and start the controlled merge queue trial' (#489) from feature/proposal-status-20261012 into main
|
||||||
|
- 归档:仅本轮获授权的三张卡由此闭合 PR 移至 `.task/archive/`;原始过程收据保留。
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,63 @@
|
|||||||
|
# U7 账户事务、审计与只读打开
|
||||||
|
|
||||||
|
本规格落实账户方案 r2 的 U7(R13–R15、KTD11–14)。HTTP 响应形状不变。`scripts/svc.sh` 与登录页不在本增量。
|
||||||
|
|
||||||
|
## 1. 事务
|
||||||
|
|
||||||
|
存储层提供一个事务助手。业务函数在该事务内完成写入,成功则同事务追加审计,失败则整笔回滚。
|
||||||
|
|
||||||
|
严格建号使用 INSERT。owner 已存在时返回 `ErrConflict`,不读取后再 UPDATE,因此并发不能覆盖先提交的账户。
|
||||||
|
|
||||||
|
以下每条是一次提交:
|
||||||
|
|
||||||
|
- 开户:用户行、激活 Key(无初始密码时)、成功审计。
|
||||||
|
- `applyPasswordChange`:写密码哈希、更新 must_change、作废该 owner 未使用的登录 Key、按路径处理会话、成功审计。覆盖四条调用:主动改密、强制设密、管理员或 CLI 重置、恢复设密。
|
||||||
|
- `--revoke-all-credentials`:吊销长期 API Key、删除会话、作废未使用登录 Key、签发新 Key、成功审计,一次提交。
|
||||||
|
|
||||||
|
失败路径在回滚之后另记一条 `outcome=rejected` 的审计。该条不包含密码、哈希或明文 Key。
|
||||||
|
|
||||||
|
## 2. bootstrap
|
||||||
|
|
||||||
|
`bootstrap-admin` 在事务内复核,不能只看用户表在事务外为空。
|
||||||
|
|
||||||
|
- SQLite:`BEGIN IMMEDIATE`。
|
||||||
|
- PostgreSQL:`SELECT ... FROM agent_system_identity FOR UPDATE`。没有身份行则拒绝,本命令不补建身份。
|
||||||
|
|
||||||
|
复核条件:`users` 为空,且下列业务表都为空:`projects`、`conversations`、`messages`、`runs`、`api_keys`、`sessions`、`login_keys`。任一表有行则拒绝。通过后,管理员、激活 Key 与成功审计同事务提交。
|
||||||
|
|
||||||
|
## 3. 审计
|
||||||
|
|
||||||
|
迁移在 `audit_events` 增加列:`entry`、`instance_id`、`operation_id`、`outcome`。文本,默认空串。既有行保持可读。版本号取本分支当前最大版本的下一个整数。若 U8 已占用该号,task-sync 时顺延,不改已发布的迁移正文。
|
||||||
|
|
||||||
|
成功记录与变更同事务。`actor`:HTTP 取会话 owner;CLI 取操作系统 uid 的十进制字符串。`login_keys.instance_id` 在签发时写入调用方给出的实例标识,缺省空串。
|
||||||
|
|
||||||
|
## 4. 账户运维打开
|
||||||
|
|
||||||
|
`user`、`apikey`、`identity verify` 只打开既有库:文件或库不存在则失败,不创建父目录,不执行迁移。`user`、`apikey` 的 schema 版本与本二进制不一致时拒绝,错误文本提示使用 `migrate` 或先启动一次服务。`identity verify` 只校验既有实例身份并报告 schema 版本,不以版本不一致阻断启动前身份核验。
|
||||||
|
|
||||||
|
设置了 `AGENT_SYSTEM_INSTANCE_ID` 或 `--instance-id` 时,与库内身份行严格比对,不一致则拒绝。未设置时不补建身份。
|
||||||
|
|
||||||
|
## 5. 凭据输入输出
|
||||||
|
|
||||||
|
- DSN:`AGENT_SYSTEM_DSN`,或模式 0600 的 `--dsn-file`。
|
||||||
|
- 密码:`--password-stdin`。
|
||||||
|
- 明文 Key 或生成的密码写入 `--out-file`。创建使用独占创建(已存在则失败),权限 0600。
|
||||||
|
- stderr 不输出凭据明文;可打印 operation id、凭据 id,以及必要的输出路径、弃用警告和错误提示。
|
||||||
|
|
||||||
|
`--dsn` 与 `--password` 仍可用,并在 stderr 打印弃用警告,以兼容现有 `scripts/svc.sh` 与 `scripts/e2e.sh`。移除留给账户 U5。
|
||||||
|
|
||||||
|
`agent-system capabilities --json` 打印本二进制能力,不打开数据库,不创建目录。
|
||||||
|
|
||||||
|
## 6. HTTP
|
||||||
|
|
||||||
|
`internal/server/accounts.go` 的建号、改密、重置走同一事务助手和审计。响应 JSON 字段保持现状。
|
||||||
|
|
||||||
|
## 7. 测试
|
||||||
|
|
||||||
|
`internal/store/account_lifecycle_test.go` 固定在 SQLite 上运行;`account_security_integration_test.go` 使用 `openTestStore` 在 SQLite 和设有 `AGENT_PG_TEST_DSN` 的 PostgreSQL 上分别运行。`account_serialization_pg_test.go` 与 `account_two_process_pg_test.go` 覆盖真实 PostgreSQL 交叠和跨进程入场;未配置 DSN 时 PostgreSQL 专项 SKIP,不能记为已验证。Dev CI 的 `make test-backend-pg` 是父仓 PostgreSQL 组合的 GATE。
|
||||||
|
|
||||||
|
- T7-1 / T7-4:两个进程同时用同一 owner 开户,只有一个成功。并发 bootstrap,以及 bootstrap 与普通开户竞争,同样只有一个成功。
|
||||||
|
- T7-2:在提交中途注入失败,读到的只能是完整旧态或完整新态。
|
||||||
|
- T7-3:改密、重置、恢复设密之后,旧的未使用登录 Key 不能再兑换。长期 API Key 仅在 `--revoke-all-credentials` 时撤销。
|
||||||
|
- T7-5:关闭再打开后原密码仍能校验。真实进程收到 SIGTERM 或 SIGKILL 后同样能校验。两个版本的真实升级记 NOT_RUN。
|
||||||
|
- 账户 CLI 对既有库不执行迁移;缺文件时不建目录。既有登录 Key 与认证测试保持通过。
|
||||||
@@ -550,6 +550,11 @@ scripts/svc.sh help <主题> # 主题详解:model deploy lifecycle accounts db
|
|||||||
# accounts:实例绑定账户 CLI(create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin)
|
# accounts:实例绑定账户 CLI(create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`agent-system` 的账户命令仍接受 `--dsn` 和 `--password`,并在 stderr 打印弃用警告。
|
||||||
|
新调用使用 `AGENT_SYSTEM_DSN` 或模式 0600 的 `--dsn-file`,密码使用 `--password-stdin`,
|
||||||
|
明文写入独占创建的 `--out-file`。`agent-system capabilities --json` 不打开数据库。
|
||||||
|
`scripts/svc.sh` 仍传 `--dsn` / `--password`,切换留给账户 U5。
|
||||||
|
|
||||||
## 7. 命令参考:数据库
|
## 7. 命令参考:数据库
|
||||||
|
|
||||||
### 7.1 db create(创建实例)
|
### 7.1 db create(创建实例)
|
||||||
|
|||||||
+42
-17
@@ -1,10 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# e2e.sh —— U4 运行验收一条龙(真实浏览器:Playwright × RDM 审计工作区)。
|
# e2e.sh —— U4 运行验收一条龙(真实浏览器:Playwright × RDM 审计工作区)。
|
||||||
#
|
#
|
||||||
# 动线(与 frontend/e2e/rdm-audit.spec.ts 的环境契约一一对应):
|
# 动线(与 Frontend e2e/rdm-audit.playwright.ts 的环境契约一一对应):
|
||||||
# 1. 构建:RDM 二进制(RDM 仓 origin/main 临时 worktree,pins 物化)
|
# 1. 构建:版本化候选从固定 Dev 组合取得 RDM、同源基座 CLI 与 Frontend dist;
|
||||||
# + 基座 CLI(本 worktree modules/backend,建号用)
|
# 历史候选继续按原 gitlink 和 pins 路径物化。
|
||||||
# + frontend dist(本 worktree modules/frontend)
|
|
||||||
# 2. 种子:RDM -bootstrap-admin(域管理员)→ 基座 user create(登录账号)
|
# 2. 种子:RDM -bootstrap-admin(域管理员)→ 基座 user create(登录账号)
|
||||||
# → serve(auth on)→ API rdm.project.create×2 + rdm.ingest(主项目)
|
# → serve(auth on)→ API rdm.project.create×2 + rdm.ingest(主项目)
|
||||||
# 3. 验收:vite preview(dist+/api 代理)→ npx playwright test
|
# 3. 验收:vite preview(dist+/api 代理)→ npx playwright test
|
||||||
@@ -17,6 +16,7 @@
|
|||||||
# RDM_REPO RDM 仓路径(默认 <本仓上级>/Agent-System-RDM)
|
# RDM_REPO RDM 仓路径(默认 <本仓上级>/Agent-System-RDM)
|
||||||
# E2E_API_PORT/E2E_UI_PORT 服务端口(默认 18791/4173)
|
# E2E_API_PORT/E2E_UI_PORT 服务端口(默认 18791/4173)
|
||||||
# E2E_OWNER/E2E_PASSWORD 登录账号(默认 e2e-auditor/E2eAudit#2026)
|
# E2E_OWNER/E2E_PASSWORD 登录账号(默认 e2e-auditor/E2eAudit#2026)
|
||||||
|
# E2E_PASSWORD2 首次登录后的设密结果(默认 E2eAudit#2026b)
|
||||||
# E2E_PROJECT_MAIN/E2E_PROJECT_ISOLATED 项目名(默认 审计演示项目/隔离检查项目)
|
# E2E_PROJECT_MAIN/E2E_PROJECT_ISOLATED 项目名(默认 审计演示项目/隔离检查项目)
|
||||||
# E2E_OUT 工件目录(默认 .tmp/e2e/<ts>;.gitignore 已覆盖 .tmp)
|
# E2E_OUT 工件目录(默认 .tmp/e2e/<ts>;.gitignore 已覆盖 .tmp)
|
||||||
# 依赖:go、node/npm、curl;首次运行会 npx playwright install chromium。
|
# 依赖:go、node/npm、curl;首次运行会 npx playwright install chromium。
|
||||||
@@ -39,6 +39,7 @@ API_PORT=${E2E_API_PORT:-18791}
|
|||||||
UI_PORT=${E2E_UI_PORT:-4173}
|
UI_PORT=${E2E_UI_PORT:-4173}
|
||||||
OWNER=${E2E_OWNER:-e2e-auditor}
|
OWNER=${E2E_OWNER:-e2e-auditor}
|
||||||
PASSWORD=${E2E_PASSWORD:-E2eAudit#2026}
|
PASSWORD=${E2E_PASSWORD:-E2eAudit#2026}
|
||||||
|
PASSWORD2=${E2E_PASSWORD2:-E2eAudit#2026b}
|
||||||
PROJ_MAIN=${E2E_PROJECT_MAIN:-审计演示项目}
|
PROJ_MAIN=${E2E_PROJECT_MAIN:-审计演示项目}
|
||||||
PROJ_ISO=${E2E_PROJECT_ISOLATED:-隔离检查项目}
|
PROJ_ISO=${E2E_PROJECT_ISOLATED:-隔离检查项目}
|
||||||
API_BASE="http://127.0.0.1:$API_PORT"
|
API_BASE="http://127.0.0.1:$API_PORT"
|
||||||
@@ -53,7 +54,8 @@ die() { printf '\033[31m[e2e][FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
|
|||||||
command -v go >/dev/null || die "go not found"
|
command -v go >/dev/null || die "go not found"
|
||||||
command -v node >/dev/null || die "node not found"
|
command -v node >/dev/null || die "node not found"
|
||||||
command -v curl >/dev/null || die "curl not found"
|
command -v curl >/dev/null || die "curl not found"
|
||||||
git -C "$RDM_REPO" rev-parse --git-dir >/dev/null 2>&1 || die "RDM repo not found at $RDM_REPO (set RDM_REPO)"
|
[ "$(git -C "$RDM_REPO" rev-parse --is-inside-work-tree 2>/dev/null)" = true ] \
|
||||||
|
|| die "RDM repo not found at $RDM_REPO (set RDM_REPO)"
|
||||||
|
|
||||||
if [ "$BUILD_ONLY" != 1 ]; then
|
if [ "$BUILD_ONLY" != 1 ]; then
|
||||||
for p in "$API_PORT" "$UI_PORT"; do
|
for p in "$API_PORT" "$UI_PORT"; do
|
||||||
@@ -66,10 +68,11 @@ fi
|
|||||||
API_PID=""
|
API_PID=""
|
||||||
UI_PID=""
|
UI_PID=""
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [ -n "${API_PID:-}" ]; then kill "$API_PID" 2>/dev/null || true; fi
|
if [ -n "${API_PID:-}" ]; then kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; fi
|
||||||
if [ -n "${UI_PID:-}" ]; then
|
# Preview is a single owned Vite process. Check its command before signaling
|
||||||
|
# so a recycled PID cannot stop a process from another worktree.
|
||||||
|
if [ -n "${UI_PID:-}" ] && ps -p "$UI_PID" -o args= 2>/dev/null | rg -Fq -- "vite preview --outDir $FRONTEND_DIST --port $UI_PORT"; then
|
||||||
kill "$UI_PID" 2>/dev/null || true
|
kill "$UI_PID" 2>/dev/null || true
|
||||||
pkill -f "vite preview --port $UI_PORT" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
@@ -179,9 +182,23 @@ stop_serve() {
|
|||||||
[ -n "${API_PID:-}" ] && { kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; API_PID=""; }
|
[ -n "${API_PID:-}" ] && { kill "$API_PID" 2>/dev/null || true; wait "$API_PID" 2>/dev/null || true; API_PID=""; }
|
||||||
}
|
}
|
||||||
login() {
|
login() {
|
||||||
curl -sf -c "$JAR" -X POST "$API_BASE/api/auth/login" \
|
local i
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if curl -sf -c "$JAR" -X POST "$API_BASE/api/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"owner\":\"$OWNER\",\"password\":\"$PASSWORD\"}" >/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
die "login failed"
|
||||||
|
}
|
||||||
|
finish_password_gate() {
|
||||||
|
curl -sf -b "$JAR" -c "$JAR" -X POST "$API_BASE/api/auth/password" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "{\"owner\":\"$OWNER\",\"password\":\"$PASSWORD\"}" >/dev/null || die "login failed"
|
-d "{\"next\":\"$PASSWORD2\"}" >/dev/null || die "password setup failed"
|
||||||
|
PASSWORD=$PASSWORD2
|
||||||
|
login
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容忍部分成功)
|
cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容忍部分成功)
|
||||||
@@ -196,11 +213,17 @@ cmd() { # cmd <id> <params-json> → payload(失败即死,种子步骤不容
|
|||||||
start_serve
|
start_serve
|
||||||
JAR="$OUT/cookies.txt"
|
JAR="$OUT/cookies.txt"
|
||||||
login
|
login
|
||||||
|
finish_password_gate
|
||||||
log "seed: create projects"
|
log "seed: create projects"
|
||||||
PID_MAIN=$(cmd rdm.project.create "{\"name\":\"$PROJ_MAIN\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
PID_MAIN=$(cmd rdm.project.create "{\"name\":\"$PROJ_MAIN\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
||||||
PID_ISO=$(cmd rdm.project.create "{\"name\":\"$PROJ_ISO\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
PID_ISO=$(cmd rdm.project.create "{\"name\":\"$PROJ_ISO\"}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["projectId"])')
|
||||||
log "seed: projects main=$PID_MAIN iso=$PID_ISO"
|
log "seed: projects main=$PID_MAIN iso=$PID_ISO"
|
||||||
|
|
||||||
|
log "seed: grant explicit audit.read for the fixture owner"
|
||||||
|
cmd rdm.perm.cap '{"action":"audit.read","allow":true}' >/dev/null
|
||||||
|
cmd rdm.perm.set "{\"projectId\":\"$PID_MAIN\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
|
||||||
|
cmd rdm.perm.set "{\"projectId\":\"$PID_ISO\",\"member\":\"$OWNER\",\"action\":\"audit.read\",\"allow\":true}" >/dev/null
|
||||||
|
|
||||||
log "seed: import requirement ledger into $PID_MAIN"
|
log "seed: import requirement ledger into $PID_MAIN"
|
||||||
cmd rdm.reqs.import "{\"projectId\":\"$PID_MAIN\",\"items\":$(
|
cmd rdm.reqs.import "{\"projectId\":\"$PID_MAIN\",\"items\":$(
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
@@ -253,11 +276,12 @@ if [ "$SEED_ONLY" = 1 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "start vite preview at http://127.0.0.1:$UI_PORT"
|
log "start vite preview at http://127.0.0.1:$UI_PORT"
|
||||||
(cd "$FRONTEND_DIR" && \
|
(
|
||||||
PLAYWRIGHT_API_TARGET="$API_BASE" npx vite preview --outDir "$FRONTEND_DIST" --port "$UI_PORT" --strictPort \
|
cd "$FRONTEND_DIR"
|
||||||
>"$OUT/preview.log" 2>&1 &
|
export PLAYWRIGHT_API_TARGET="$API_BASE"
|
||||||
echo $! > "$OUT/preview.pid")
|
exec ./node_modules/.bin/vite preview --outDir "$FRONTEND_DIST" --port "$UI_PORT" --strictPort
|
||||||
UI_PID=$(cat "$OUT/preview.pid")
|
) >"$OUT/preview.log" 2>&1 &
|
||||||
|
UI_PID=$!
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
curl -sf -o /dev/null "http://127.0.0.1:$UI_PORT/" && break
|
curl -sf -o /dev/null "http://127.0.0.1:$UI_PORT/" && break
|
||||||
sleep 0.25
|
sleep 0.25
|
||||||
@@ -269,10 +293,11 @@ log "playwright (install chromium if missing)"
|
|||||||
|
|
||||||
log "run specs"
|
log "run specs"
|
||||||
cd "$FRONTEND_DIR"
|
cd "$FRONTEND_DIR"
|
||||||
export PLAYWRIGHT_BASE_URL="http://127.0.0.1:$UI_PORT" E2E_OWNER="$OWNER" E2E_PASSWORD="$PASSWORD" \
|
export PLAYWRIGHT_BASE_URL="http://127.0.0.1:$UI_PORT" E2E_OWNER="$OWNER" \
|
||||||
|
E2E_PASSWORD="${E2E_PASSWORD:-E2eAudit#2026}" E2E_PASSWORD2="$PASSWORD2" \
|
||||||
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
|
E2E_PROJECT_MAIN="$PROJ_MAIN" E2E_PROJECT_ISOLATED="$PROJ_ISO"
|
||||||
set +e
|
set +e
|
||||||
npx playwright test --reporter=list >"$OUT/playwright.log" 2>&1
|
npx playwright test e2e/rdm-audit.playwright.ts --reporter=list >"$OUT/playwright.log" 2>&1
|
||||||
PW_EXIT=$?
|
PW_EXIT=$?
|
||||||
set -e
|
set -e
|
||||||
cat "$OUT/playwright.log"
|
cat "$OUT/playwright.log"
|
||||||
|
|||||||
+4
-2
@@ -64,7 +64,9 @@ cmd_precheck() {
|
|||||||
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
|
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
|
||||||
pst=$(card_status "$pc"); case "$pst" in 已完成*) continue ;; esac
|
pst=$(card_status "$pc"); case "$pst" in 已完成*) continue ;; esac
|
||||||
card_pending "${pc##*/}" && continue
|
card_pending "${pc##*/}" && continue
|
||||||
if git log --merges --format='%s' "origin/${TARGET_BRANCH}" 2>/dev/null | grep -qF " from $pb into ${TARGET_BRANCH}"; then
|
# 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
|
||||||
open_cards="${open_cards} ${pc##*/}"
|
open_cards="${open_cards} ${pc##*/}"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -392,7 +394,7 @@ card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\
|
|||||||
card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
|
card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
|
||||||
card_merge() { # $1=branch → "sha|subject"(该分支合入 origin/<target> 的合并提交)
|
card_merge() { # $1=branch → "sha|subject"(该分支合入 origin/<target> 的合并提交)
|
||||||
git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
|
git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
|
||||||
| grep -F " from $1 into ${TARGET_BRANCH}" | head -1
|
| grep -F " from $1 into ${TARGET_BRANCH}" | sed -n '1p'
|
||||||
}
|
}
|
||||||
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
|
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
|
||||||
local cb
|
local cb
|
||||||
|
|||||||
@@ -758,6 +758,35 @@ class TaskLifecycleTests(unittest.TestCase):
|
|||||||
self.git(publisher, "push", "origin", "main")
|
self.git(publisher, "push", "origin", "main")
|
||||||
return self.git(publisher, "rev-parse", "HEAD").stdout.strip(), card.read_text()
|
return self.git(publisher, "rev-parse", "HEAD").stdout.strip(), card.read_text()
|
||||||
|
|
||||||
|
def test_long_merge_history_keeps_merged_card_visible_and_closable(self):
|
||||||
|
# A recent match makes grep -q/head exit before git log has finished
|
||||||
|
# writing a large history. With pipefail, its SIGPIPE used to hide the
|
||||||
|
# card from precheck and leave task-close with nothing to archive.
|
||||||
|
base = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
tree = self.git(self.repo, "rev-parse", "HEAD^{tree}").stdout.strip()
|
||||||
|
side = self.git(self.repo, "commit-tree", tree, "-p", base,
|
||||||
|
"-m", "Fixture side commit").stdout.strip()
|
||||||
|
tip = base
|
||||||
|
for i in range(220):
|
||||||
|
tip = self.git(self.repo, "commit-tree", tree, "-p", tip, "-p", side,
|
||||||
|
"-m", f"Older fixture merge {i} " + "x" * 512).stdout.strip()
|
||||||
|
self.git(self.repo, "update-ref", "refs/heads/main", tip)
|
||||||
|
self.git(self.repo, "push", "origin", "main")
|
||||||
|
actual_merge, _ = self.publish_merged_task()
|
||||||
|
self.git(self.repo, "fetch", "origin")
|
||||||
|
self.git(self.repo, "merge", "--ff-only", "origin/main")
|
||||||
|
|
||||||
|
precheck = self.task(self.repo, "precheck")
|
||||||
|
self.assertIn("[cards] 已合入未闭合任务卡:feature-merged.md", precheck.stdout)
|
||||||
|
closed = self.task(self.repo, "close")
|
||||||
|
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-merged.md").stdout
|
||||||
|
self.assertIn("状态:已完成(回填)", archived)
|
||||||
|
self.assertIn(actual_merge, archived)
|
||||||
|
|
||||||
def assert_post_merge_only_reminds_owner(self, operation):
|
def assert_post_merge_only_reminds_owner(self, operation):
|
||||||
expected_head, expected_card = self.publish_merged_task()
|
expected_head, expected_card = self.publish_merged_task()
|
||||||
self.git(self.repo, "fetch", "origin")
|
self.git(self.repo, "fetch", "origin")
|
||||||
|
|||||||
Reference in New Issue
Block a user