Skip to content

Commit 297d78c

Browse files
authored
feat(tdd): E1 — Task-local merge Gate + 解除全局 canMerge() 耦合
- canMergeTaskPR() 独立实现,不调用全局 canMerge() - 检查 Task reviewing + PR checkpoints + TDD compliance + Branch Protection - 不再要求全局 review Stage 为 pass - canMerge() 语义变更为 Flow 收尾确认 - 仅检查 FlowRun 状态 + 所有 Task 已 merged - 不再检查 review Stage - mergeTaskPR() 支持 --match-head-commit 安全检查 - setMergeGhExecutor() 支持测试注入 - 测试覆盖 26 个 merge + 29 个 gate(双 Task 依赖全链路 + 全部阻断路径)
1 parent 9f7aeaf commit 297d78c

5 files changed

Lines changed: 455 additions & 83 deletions

File tree

src/flowrun/gate.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,23 @@ export function canCreatePR(task: TaskState): GateResult {
152152
return ok()
153153
}
154154

155+
/**
156+
* 检查 FlowRun 是否满足合并收尾条件。
157+
*
158+
* 新语义(v2):
159+
* - FlowRun 状态为 running 或 merging
160+
* - 所有 Task 状态均为 merged
161+
* - 不再要求 review Stage 为 pass(Task-local merge 已独立)
162+
*
163+
* 用于 flow_run finalize。
164+
*/
155165
export function canMerge(flowRun: FlowRun): GateResult {
156166
if (flowRun.status !== "running" && flowRun.status !== "merging") {
157167
return block("FlowRun is not in a mergeable state")
158168
}
159169

160-
const reviewStage = flowRun.stages.review
161-
if (reviewStage.status !== "pass") {
162-
return block("Review stage is not complete")
170+
if (!allTasksComplete(flowRun)) {
171+
return block("Not all tasks merged (finalize requires all tasks to be merged first)")
163172
}
164173

165174
return ok()

src/flowrun/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export {
5151

5252
export {
5353
checkBranchProtection, validateCheckpoint, validatePRCheckpoints,
54-
canAutoMergeTask, mergePR, createRevertPR,
54+
canMergeTaskPR, mergePR, mergeTaskPR, createRevertPR, setMergeGhExecutor,
5555
type MergeGateResult,
5656
} from "./merge.js"
5757

src/flowrun/merge.ts

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
11
import type {
2-
FlowRun, PRCheckpoints, Checkpoint, TaskState,
2+
PRCheckpoints, Checkpoint, TaskState,
33
} from "./types.js"
4-
import { canMerge } from "./gate.js"
5-
import { gh } from "../util/gh.js"
4+
5+
// ─── 可替换的 gh executor(用于测试) ───
6+
7+
type GhFn = (args: string) => Promise<{ stdout: string; stderr: string }>
8+
9+
let mergeGhExecutor: GhFn | null = null
10+
11+
export function setMergeGhExecutor(fn: GhFn) {
12+
mergeGhExecutor = fn
13+
}
14+
15+
async function gh(args: string): Promise<{ stdout: string; stderr: string }> {
16+
if (mergeGhExecutor) {
17+
return mergeGhExecutor(args)
18+
}
19+
const mod = await import("../util/gh.js")
20+
return mod.gh(args)
21+
}
22+
23+
// ─── 类型 ───
624

725
export interface BranchProtectionStatus {
826
exists: boolean
@@ -17,6 +35,8 @@ export interface MergeGateResult {
1735
checkpointResults: Record<string, "pending" | "pass" | "fail" | "skipped">
1836
}
1937

38+
// ─── Branch Protection ───
39+
2040
export async function checkBranchProtection(owner: string, repo: string): Promise<BranchProtectionStatus> {
2141
try {
2242
const { stdout } = await gh(`api repos/${owner}/${repo}/branches/main/protection`)
@@ -46,6 +66,8 @@ export async function checkBranchProtection(owner: string, repo: string): Promis
4666
}
4767
}
4868

69+
// ─── Checkpoint 验证 ───
70+
4971
export function validateCheckpoint(cp: Checkpoint): "pending" | "pass" | "fail" {
5072
if (cp.status === "pass") return "pass"
5173
if (cp.status === "fail") return "fail"
@@ -88,12 +110,22 @@ export function validatePRCheckpoints(checkpoints: PRCheckpoints): MergeGateResu
88110
return { allowed: true, checkpointResults: results }
89111
}
90112

91-
export function canAutoMergeTask(flowRun: FlowRun, task: TaskState, protection: BranchProtectionStatus): MergeGateResult {
92-
const flowGate = canMerge(flowRun)
93-
if (!flowGate.allowed) {
94-
return { allowed: false, reason: flowGate.reason, checkpointResults: {} }
95-
}
96-
113+
// ─── Task-local Merge Gate ───
114+
115+
/**
116+
* Task-local merge gate(不依赖全局 canMerge)。
117+
*
118+
* 检查条件(按顺序):
119+
* 1. Task 状态为 "reviewing"
120+
* 2. 存在 PR Checkpoints
121+
* 3. 所有 checkpoint 状态为 pass
122+
* 4. TDD compliance 为 pass / waived / null(无 TDD 要求)
123+
* 5. Branch Protection 已启用
124+
*
125+
* 不要求全局 review Stage 为 pass。
126+
*/
127+
export function canMergeTaskPR(task: TaskState, hasBranchProtection: boolean): MergeGateResult {
128+
// 1. Task 状态检查
97129
if (task.status === "merged") {
98130
return { allowed: false, reason: "Task already merged", checkpointResults: {} }
99131
}
@@ -102,14 +134,32 @@ export function canAutoMergeTask(flowRun: FlowRun, task: TaskState, protection:
102134
return { allowed: false, reason: `Task status is ${task.status}, expected reviewing`, checkpointResults: {} }
103135
}
104136

137+
// 2. PR Checkpoints 存在性
105138
if (!task.prCheckpoints) {
106139
return { allowed: false, reason: "No PR checkpoints recorded", checkpointResults: {} }
107140
}
108141

142+
// 3. 标准 checkpoint 验证
109143
const prResult = validatePRCheckpoints(task.prCheckpoints)
110144
if (!prResult.allowed) return prResult
111145

112-
if (!protection.exists) {
146+
// 4. TDD compliance 验证
147+
const tdd = task.prCheckpoints.tddCompliance
148+
if (tdd !== null && tdd.status === "fail") {
149+
prResult.checkpointResults["tddCompliance"] = "fail"
150+
return {
151+
allowed: false,
152+
reason: `TDD compliance failed: ${tdd.summary}`,
153+
checkpointResults: prResult.checkpointResults,
154+
}
155+
}
156+
// tdd === null → 无 TDD 要求,视为通过
157+
// tdd.status === "pass" / "waived" → 通过
158+
prResult.checkpointResults["tddCompliance"] =
159+
tdd === null ? "skipped" : (tdd.status as "pass" | "fail" | "pending")
160+
161+
// 5. Branch Protection
162+
if (!hasBranchProtection) {
113163
prResult.checkpointResults.branchProtection = "fail"
114164
return {
115165
allowed: false,
@@ -122,6 +172,11 @@ export function canAutoMergeTask(flowRun: FlowRun, task: TaskState, protection:
122172
return prResult
123173
}
124174

175+
// ─── PR 合并操作 ───
176+
177+
/**
178+
* 标准化合并 PR(无安全检查,向后兼容)。
179+
*/
125180
export async function mergePR(prNumber: number): Promise<{ success: boolean; error?: string }> {
126181
try {
127182
await gh(`pr merge ${prNumber} --squash --delete-branch`)
@@ -131,6 +186,30 @@ export async function mergePR(prNumber: number): Promise<{ success: boolean; err
131186
}
132187
}
133188

189+
/**
190+
* Task-local PR 合并(带 --match-head-commit 安全检查)。
191+
*
192+
* 步骤:
193+
* 1. 验证 verifiedSha 非空
194+
* 2. 使用 gh pr merge --squash --delete-branch --match-head-commit <verifiedSha>
195+
* 3. 失败时返回错误
196+
*/
197+
export async function mergeTaskPR(prNumber: number, verifiedSha: string): Promise<{ success: boolean; error?: string }> {
198+
if (!verifiedSha) {
199+
return { success: false, error: "verifiedSha is required for merge --match-head-commit" }
200+
}
201+
202+
try {
203+
// --match-head-commit 确保只有 verified 的 commit 才会被合并
204+
await gh(`pr merge ${prNumber} --squash --delete-branch --match-head-commit ${verifiedSha}`)
205+
return { success: true }
206+
} catch (err) {
207+
return { success: false, error: String(err) }
208+
}
209+
}
210+
211+
// ─── Revert ───
212+
134213
export async function createRevertPR(prNumber: number, reason: string): Promise<{ prNumber?: number; error?: string }> {
135214
try {
136215
const { stdout } = await gh(`pr view ${prNumber} --json headRefName,headRepository,body,title`)

test/flowrun/gate.test.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,40 @@ describe("canCompleteTask", () => {
187187
})
188188

189189
describe("canMerge", () => {
190-
it("blocks if review stage not pass", () => {
191-
const run = withStage(runningRun(), "review", "pending")
192-
expect(canMerge(run).allowed).toBe(false)
190+
it("allows when flow is running and all tasks are merged", () => {
191+
const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any)
192+
expect(canMerge(run).allowed).toBe(true)
193193
})
194194

195-
it("allows when review is pass", () => {
196-
const run = withStage(runningRun(), "review", "pass")
197-
expect(canMerge(run).allowed).toBe(true)
195+
it("allows when flow is merging and all tasks merged", () => {
196+
const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any)
197+
const run2 = { ...run, status: "merging" as const }
198+
expect(canMerge(run2).allowed).toBe(true)
199+
})
200+
201+
it("blocks when some tasks not merged", () => {
202+
const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any)
203+
const run2 = withTask(run, { id: "b", status: "running", dependsOn: [], expectedFiles: [] } as any)
204+
expect(canMerge(run2).allowed).toBe(false)
205+
expect(canMerge(run2).reason).toContain("all tasks merged")
206+
})
207+
208+
it("blocks when no tasks exist", () => {
209+
expect(canMerge(runningRun()).allowed).toBe(false)
210+
})
211+
212+
it("blocks when flow is not in mergeable state", () => {
213+
const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any)
214+
const run2 = { ...run, status: "completed" as const }
215+
expect(canMerge(run2).allowed).toBe(false)
216+
})
217+
218+
// 新语义:不再要求 review stage pass
219+
it("allows merge even when review stage is not pass", () => {
220+
const run = withTask(runningRun(), { id: "a", status: "merged", dependsOn: [], expectedFiles: [] } as any)
221+
const run2 = withStage(run, "review", "pending")
222+
// review stage pending 不应阻止 canMerge
223+
expect(canMerge(run2).allowed).toBe(true)
198224
})
199225
})
200226

0 commit comments

Comments
 (0)