Skip to content

Commit 0c0320b

Browse files
authored
feat(tdd): E2 — CI required checks + Branch Protection 检测 + quality contract digest
实现 RepositoryQualityPolicy 验证: - checkRequiredChecks(): 验证 PR checks context/appId/状态 - validateRequiredWorkflow(): 校验 workflow 配置完整性 - checkBranchProtection(): 复用 merge.ts 已有实现 - computeQualityContractDigest(): JCS+SHA-256 质量契约摘要 测试覆盖: - trusted source (正确/错误 appId) 检测 - pending/fail/pass/EXPECTED 状态分类 - mode off/required 切换行为 - 多重失败类型混合报告 - 确定性 digest 计算
1 parent 297d78c commit 0c0320b

3 files changed

Lines changed: 699 additions & 0 deletions

File tree

src/flowrun/ci.ts

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { createHash } from "node:crypto"
2+
import type {
3+
RepositoryQualityPolicy, TaskCommand, CoveragePolicy,
4+
} from "./types.js"
5+
6+
// ─── 类型 ───
7+
8+
/** 从 gh pr view --json statusCheckRollup 获取的 PR 检查结果 */
9+
export interface PRCheckResult {
10+
context: string
11+
state: "EXPECTED" | "ERROR" | "FAILURE" | "PENDING" | "SUCCESS"
12+
app: { id: number; name: string } | null
13+
}
14+
15+
/** checkRequiredChecks 的返回结果 */
16+
export interface CICheckResult {
17+
allPassed: boolean
18+
missingContexts: string[]
19+
failedContexts: string[]
20+
pendingContexts: string[]
21+
untrustedSources: Array<{
22+
context: string
23+
expectedAppId: number
24+
actualAppId: number | null
25+
}>
26+
}
27+
28+
/** validateRequiredWorkflow 的返回结果 */
29+
export interface RequiredWorkflowValidation {
30+
valid: boolean
31+
errors: string[]
32+
}
33+
34+
// ─── checkRequiredChecks ───
35+
36+
/**
37+
* 验证 PR 的状态检查是否满足 RepositoryQualityPolicy 中定义的 required checks。
38+
*
39+
* 规则:
40+
* - mode 为 "off" 时不校验,直接返回 allPassed: true
41+
* - mode 为 "required" 时:
42+
* 1. 每个 requiredChecks 中的 context 必须在 PR checks 中存在
43+
* 2. 状态必须为 SUCCESS
44+
* 3. 发布该 check 的 GitHub App ID 必须与配置的 appId 匹配
45+
* (appId 为 0 时不校验来源)
46+
*/
47+
export function checkRequiredChecks(
48+
policy: RepositoryQualityPolicy,
49+
prChecks: PRCheckResult[],
50+
): CICheckResult {
51+
// mode off — 不增加额外约束
52+
if (policy.mode === "off") {
53+
return {
54+
allPassed: true,
55+
missingContexts: [],
56+
failedContexts: [],
57+
pendingContexts: [],
58+
untrustedSources: [],
59+
}
60+
}
61+
62+
// mode required — 逐项验证
63+
const prCheckMap = new Map<string, PRCheckResult>()
64+
for (const c of prChecks) {
65+
prCheckMap.set(c.context, c)
66+
}
67+
68+
const missingContexts: string[] = []
69+
const failedContexts: string[] = []
70+
const pendingContexts: string[] = []
71+
const untrustedSources: CICheckResult["untrustedSources"] = []
72+
73+
for (const required of policy.requiredChecks) {
74+
const prCheck = prCheckMap.get(required.context)
75+
76+
// 1. context 缺失
77+
if (!prCheck) {
78+
missingContexts.push(required.context)
79+
continue
80+
}
81+
82+
// 2. 状态检查
83+
if (prCheck.state === "SUCCESS") {
84+
// 通过 — 继续检查来源
85+
} else if (prCheck.state === "PENDING" || prCheck.state === "EXPECTED") {
86+
pendingContexts.push(required.context)
87+
continue
88+
} else {
89+
// FAILURE, ERROR
90+
failedContexts.push(required.context)
91+
continue
92+
}
93+
94+
// 3. 来源验证(appId 为 0 时不校验)
95+
if (required.appId !== 0) {
96+
if (!prCheck.app || prCheck.app.id !== required.appId) {
97+
untrustedSources.push({
98+
context: required.context,
99+
expectedAppId: required.appId,
100+
actualAppId: prCheck.app?.id ?? null,
101+
})
102+
}
103+
}
104+
}
105+
106+
const allPassed =
107+
missingContexts.length === 0 &&
108+
failedContexts.length === 0 &&
109+
pendingContexts.length === 0 &&
110+
untrustedSources.length === 0
111+
112+
return {
113+
allPassed,
114+
missingContexts,
115+
failedContexts,
116+
pendingContexts,
117+
untrustedSources,
118+
}
119+
}
120+
121+
// ─── validateRequiredWorkflow ───
122+
123+
/**
124+
* 验证 RepositoryQualityPolicy 的 requiredChecks 配置有效性。
125+
*
126+
* 规则:
127+
* - mode 为 "off" 时跳过所有校验
128+
* - mode 为 "required" 时:
129+
* 1. requiredChecks 不能为空
130+
* 2. 每个 entry 必须有 workflowPath、workflowRef
131+
* 3. 不允许重复的 context
132+
*/
133+
export function validateRequiredWorkflow(
134+
policy: RepositoryQualityPolicy,
135+
): RequiredWorkflowValidation {
136+
// mode off — 无需校验
137+
if (policy.mode === "off") {
138+
return { valid: true, errors: [] }
139+
}
140+
141+
const errors: string[] = []
142+
143+
// 1. requiredChecks 不能为空
144+
if (policy.requiredChecks.length === 0) {
145+
errors.push("requiredChecks must not be empty when mode is 'required'")
146+
return { valid: false, errors }
147+
}
148+
149+
// 2. 校验每个 entry
150+
const seenContexts = new Set<string>()
151+
152+
for (let i = 0; i < policy.requiredChecks.length; i++) {
153+
const entry = policy.requiredChecks[i]
154+
const prefix = `requiredChecks[${i}]`
155+
156+
// 检查 context 重复
157+
if (seenContexts.has(entry.context)) {
158+
errors.push(`${prefix}: duplicate context "${entry.context}"`)
159+
}
160+
seenContexts.add(entry.context)
161+
162+
// 检查 workflowPath
163+
if (!entry.workflowPath) {
164+
errors.push(`${prefix}: workflowPath must not be empty`)
165+
}
166+
167+
// 检查 workflowRef
168+
if (!entry.workflowRef) {
169+
errors.push(`${prefix}: workflowRef must not be empty`)
170+
}
171+
}
172+
173+
return { valid: errors.length === 0, errors }
174+
}
175+
176+
// ─── checkBranchProtection ───
177+
178+
/**
179+
* checkBranchProtection 委托给 merge.ts 中已有的实现。
180+
* 这里作为 re-export wrapper,保持 CI 模块的 API 统一性。
181+
*/
182+
183+
export { checkBranchProtection } from "./merge.js"
184+
185+
// ─── computeQualityContractDigest ───
186+
187+
/**
188+
* JCS (JSON Canonicalization Scheme) 序列化:
189+
* - 对象的 key 按 UTF-8 字节序排序
190+
* - 无多余空白
191+
* - 数字保留原始精度
192+
*/
193+
function jcsStringify(value: unknown): string {
194+
if (value === null) return "null"
195+
if (typeof value === "boolean") return value ? "true" : "false"
196+
if (typeof value === "number") {
197+
if (Number.isFinite(value)) {
198+
// 使用标准数字序列化
199+
return String(value)
200+
}
201+
return "null"
202+
}
203+
if (typeof value === "string") {
204+
return JSON.stringify(value)
205+
}
206+
if (Array.isArray(value)) {
207+
const items = value.map(jcsStringify)
208+
return `[${items.join(",")}]`
209+
}
210+
if (typeof value === "object") {
211+
// 按 key 排序
212+
const keys = Object.keys(value).sort((a, b) => {
213+
const bufA = Buffer.from(a, "utf-8")
214+
const bufB = Buffer.from(b, "utf-8")
215+
return Buffer.compare(bufA, bufB)
216+
})
217+
const pairs = keys.map(k => {
218+
const v = (value as Record<string, unknown>)[k]
219+
return `${JSON.stringify(k)}:${jcsStringify(v)}`
220+
})
221+
return `{${pairs.join(",")}}`
222+
}
223+
return "null"
224+
}
225+
226+
/**
227+
* 计算质量契约摘要(Quality Contract Digest)。
228+
*
229+
* 绑定 testCommands + verifyCommands + coveragePolicy,
230+
* 使用 JCS (JSON Canonicalization Scheme) + SHA-256 生成确定性摘要。
231+
*
232+
* 用于在 PR checkpoint 中记录期望的契约版本,确保 CI 执行时契约未被修改。
233+
*/
234+
export function computeQualityContractDigest(
235+
testCommands: TaskCommand[],
236+
verifyCommands: TaskCommand[],
237+
coveragePolicy: CoveragePolicy | null,
238+
): string {
239+
const contract = {
240+
testCommands,
241+
verifyCommands,
242+
coveragePolicy,
243+
}
244+
245+
const json = jcsStringify(contract)
246+
return createHash("sha256").update(json, "utf-8").digest("hex")
247+
}

src/flowrun/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,12 @@ export {
116116
type ParsedCoverageData,
117117
type BuildCoverageEvidenceInput,
118118
} from "./coverage.js"
119+
120+
export {
121+
checkRequiredChecks,
122+
validateRequiredWorkflow,
123+
computeQualityContractDigest,
124+
type PRCheckResult,
125+
type CICheckResult,
126+
type RequiredWorkflowValidation,
127+
} from "./ci.js"

0 commit comments

Comments
 (0)