Skip to content

Commit 70cb9ad

Browse files
committed
fix(plugin): release_control — caller 门禁 + 人工批准 + statusCheckRollup CI 校验
1 parent 9957455 commit 70cb9ad

2 files changed

Lines changed: 69 additions & 13 deletions

File tree

src/plugin/release-control.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { gh as ghCli } from "../util/gh.js"
77
import { escapeShellArg } from "../util/shell.js"
88
import { readProjectProfile } from "../kernel/profile.js"
99
import type { ProjectProfile } from "../kernel/profile.js"
10+
import { requireCaller } from "../kernel/caller.js"
11+
import type { CallerSessionClient } from "../kernel/caller.js"
1012
import {
1113
classifyChanges,
1214
buildTag,
@@ -55,6 +57,7 @@ export type ReleaseControlOp = "propose-version" | "open-release-pr" | "merge-re
5557

5658
export interface ReleaseControlDeps {
5759
projectDir: string
60+
sessionClient?: CallerSessionClient
5861
}
5962

6063
function releaseBranch(version: string): string {
@@ -119,9 +122,22 @@ Ops:
119122
.describe("Release control operation"),
120123
proposed_version: tool.schema.string().describe("Proposed semantic version (x.y.z), required for open-release-pr / merge-release-pr"),
121124
release_notes: tool.schema.string().describe("Optional override for the Release Notes body"),
125+
user_confirmed: tool.schema.boolean().describe("Human approval — required for merge-release-pr (release is a manual flow)"),
122126
},
123-
async execute(args: Record<string, any>): Promise<string> {
127+
async execute(args: Record<string, any>, ctx: any): Promise<string> {
124128
const op = args.op as ReleaseControlOp
129+
130+
// caller 门禁:release 为人工流程,仅 primary 可调用(§2.2/§2.3)
131+
if (deps.sessionClient && ctx?.sessionID) {
132+
const denied = await requireCaller(
133+
{ agent: ctx.agent, sessionID: ctx.sessionID },
134+
["primary"],
135+
op,
136+
deps.sessionClient,
137+
)
138+
if (denied) return `Error: ${denied}`
139+
}
140+
125141
const profile = await readProjectProfile(deps.projectDir)
126142

127143
switch (op) {
@@ -248,16 +264,35 @@ async function mergeReleasePrOp(profile: ProjectProfile, args: Record<string, an
248264
return "Error: proposed_version 必填且必须为 x.y.z"
249265
}
250266

267+
// 人工批准门禁:release 为人工流程(R9),必须显式 user_confirmed
268+
if (args.user_confirmed !== true) {
269+
return "Error: merge-release-pr 需要人工批准(user_confirmed: true)。请先审查 Release PR 与版本提议后再确认。"
270+
}
271+
251272
const branch = releaseBranch(version)
252273
const prNumber = (await runGh(`pr list --head ${branch} --json number --jq '.[0].number'`)).stdout.trim()
253274
if (!prNumber || prNumber === "null") {
254275
return "Error: 未找到对应 Release PR(branch: " + branch + ")"
255276
}
256277

278+
// CI 校验:读取 statusCheckRollup,必须存在 SUCCESS check 且无失败项
279+
const rollup = (await runGh(
280+
`pr view ${prNumber} --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'`,
281+
)).stdout.trim()
282+
let conclusions: string[]
257283
try {
258-
await runGh(`pr checks ${prNumber}`)
284+
conclusions = JSON.parse(rollup) as string[]
259285
} catch {
260-
return `Error: PR #${prNumber} 的 CI checks 未通过,无法合并`
286+
return `Error: 无法解析 PR #${prNumber} 的 CI checks 状态`
287+
}
288+
if (conclusions.length === 0) {
289+
return `Error: PR #${prNumber} 没有任何 CI checks 报告,无法自动合并(需要 CI 或人工介入)`
290+
}
291+
if (conclusions.includes("FAILURE") || conclusions.includes("CANCELLED") || conclusions.includes("ACTION_REQUIRED")) {
292+
return `Error: PR #${prNumber} 的 CI checks 存在失败项(${conclusions.join(", ")}),无法合并`
293+
}
294+
if (!conclusions.every(c => c === "SUCCESS" || c === "NEUTRAL" || c === "SKIPPED")) {
295+
return `Error: PR #${prNumber} 的 CI checks 尚未全部完成(conclusions: ${conclusions.join(", ")}),请稍后重试`
261296
}
262297

263298
try {

test/plugin/release-control.test.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe("release_control tool", () => {
6464
extra: Record<string, unknown> = {},
6565
) {
6666
const tool = createReleaseControlTool({ projectDir: dir })
67-
return tool.execute({ op, ...extra }, {} as any)
67+
return tool.execute({ op, ...extra } as any, {} as any)
6868
}
6969

7070
it("rejects an unknown op", async () => {
@@ -204,43 +204,64 @@ describe("release_control tool", () => {
204204
const ghCalls: string[] = []
205205
mockGh({
206206
"pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12",
207-
"pr checks 12": "",
207+
"pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'":
208+
'["SUCCESS"]',
208209
"pr merge 12 --squash --delete-branch": "",
209210
"pr view 12 --json mergeCommit --jq '.mergeCommit.oid'": "deadbeef",
210211
"api repos/{owner}/{repo}/tags --jq '.[] | select(.name == \"v1.5.0\") | .commit.sha'": "",
211212
"api repos/{owner}/{repo}/git/refs -f ref=refs/tags/v1.5.0 -f sha=deadbeef": "",
212213
}, ghCalls)
213214

214-
const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" }))
215+
const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true }))
215216
expect(out).toContain("Merged #12")
216217
expect(out).toContain("tagged v1.5.0")
217218
expect(out).toContain("deadbee")
218-
expect(ghCalls.some(c => c.startsWith("pr checks"))).toBe(true)
219+
expect(ghCalls.some(c => c.includes("statusCheckRollup"))).toBe(true)
219220
expect(ghCalls.some(c => c.includes("git/refs"))).toBe(true)
220221
})
221222
})
222223

224+
it("refuses without human approval (user_confirmed)", async () => {
225+
await withProject(PROFILE, { version: "1.4.2" }, async dir => {
226+
const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" }))
227+
expect(out).toContain("Error")
228+
expect(out).toContain("user_confirmed")
229+
})
230+
})
231+
223232
it("refuses when the release PR does not exist", async () => {
224233
await withProject(PROFILE, { version: "1.4.2" }, async dir => {
225234
mockGh({ "pr list --head release/v1.5.0 --json number --jq '.[0].number'": "" })
226-
const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" }))
235+
const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true }))
227236
expect(out).toContain("Error")
228237
})
229238
})
230239

231-
it("refuses when CI checks have not passed", async () => {
240+
it("refuses when CI checks have failed", async () => {
232241
await withProject(PROFILE, { version: "1.4.2" }, async dir => {
233242
mockGh({
234243
"pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12",
235-
"pr checks 12": () => {
236-
throw new Error("checks failed")
237-
},
244+
"pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'":
245+
'["FAILURE"]',
238246
})
239-
const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0" }))
247+
const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true }))
240248
expect(out).toContain("Error")
241249
expect(out).toContain("CI")
242250
})
243251
})
252+
253+
it("refuses when no CI checks are reported", async () => {
254+
await withProject(PROFILE, { version: "1.4.2" }, async dir => {
255+
mockGh({
256+
"pr list --head release/v1.5.0 --json number --jq '.[0].number'": "12",
257+
"pr view 12 --json statusCheckRollup --jq '[.statusCheckRollup[] | select(.conclusion != null)] | map(.conclusion) | unique'":
258+
"[]",
259+
})
260+
const out = String(await call(dir, "merge-release-pr", { proposed_version: "1.5.0", user_confirmed: true }))
261+
expect(out).toContain("Error")
262+
expect(out).toContain("没有任何 CI checks")
263+
})
264+
})
244265
})
245266

246267
describe("monitor", () => {

0 commit comments

Comments
 (0)