@raolin2025/claude-code-node 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * PR 合并策略引擎
3
+ * 根据项目的合并规则决定是否允许合并,以及如何合并
4
+ */
5
+
6
+ import { GitHubAPI } from './github-api.js'
7
+
8
+ /**
9
+ * 合并策略配置
10
+ */
11
+ export const DEFAULT_MERGE_POLICY = {
12
+ requiredApprovals: 1, // 最少需要多少个 approve
13
+ requireCI: true, // 是否要求所有 CI 检查通过
14
+ requireNoChangesRequested: true, // 是否要求无 changes_requested
15
+ requireReview: true, // 是否要求至少一个 review 且是 approve
16
+ allowedMergeMethods: ['merge', 'squash', 'rebase'],
17
+ defaultMergeMethod: 'merge',
18
+ autoMergeLabels: ['auto-merge', 'ready-to-merge', 'automerge'], // 有这些标签则允许自动合并
19
+ ignoreLabels: ['do-not-merge', 'wip', 'work-in-progress'], // 忽略这些标签
20
+ bannedBranches: ['main', 'master', 'develop'], // 禁止合并到这些分支(可配置)
21
+ checkProtectedBranch: true, // 是否检查分支保护规则
22
+ allowSelfApprove: false, // 是否允许 PR 作者自己 approve
23
+ maxReviewDays: 30, // 审查时间上限(天)
24
+ requireDescription: true, // 是否要求 PR 描述不为空
25
+ minDescriptionLength: 20, // PR 描述最小长度
26
+ requireLinkedIssue: false, // 是否要求关联 issue
27
+ blockOnConflicts: true // 有冲突是否阻止合并
28
+ }
29
+
30
+ /**
31
+ * PR 合并策略检查器
32
+ */
33
+ export class PRMergePolicy {
34
+ /**
35
+ * @param {GitHubAPI} github
36
+ * @param {Object} userPolicy - 用户自定义策略
37
+ */
38
+ constructor(github, userPolicy = {}) {
39
+ this.github = github
40
+ this.policy = { ...DEFAULT_MERGE_POLICY, ...userPolicy }
41
+ }
42
+
43
+ /**
44
+ * 检查 PR 是否可合并
45
+ * @param {number} prNumber
46
+ * @returns {Promise<MergeCheckResult>}
47
+ */
48
+ async checkMergeable(prNumber) {
49
+ const pr = await this.github.getPR(prNumber)
50
+ const result = {
51
+ prNumber,
52
+ mergeable: true,
53
+ checks: {},
54
+ violations: [],
55
+ warnings: [],
56
+ metadata: {}
57
+ }
58
+
59
+ // 1. 基础检查
60
+ await this.checkBasicState(pr, result)
61
+ if (!result.mergeable) return result
62
+
63
+ // 2. 审查状态检查
64
+ await this.checkReviewStatus(pr, result)
65
+ if (!result.mergeable) return result
66
+
67
+ // 3. CI 状态检查
68
+ if (this.policy.requireCI) {
69
+ await this.checkCIStatus(pr, result)
70
+ if (!result.mergeable) return result
71
+ }
72
+
73
+ // 4. 分支保护检查
74
+ if (this.policy.checkProtectedBranch) {
75
+ await this.checkBranchProtection(pr, result)
76
+ if (!result.mergeable) return result
77
+ }
78
+
79
+ // 5. 自动合并标签检查
80
+ await this.checkAutoMergeLabels(pr, result)
81
+
82
+ // 6. 忽略标签检查
83
+ await this.checkIgnoreLabels(pr, result)
84
+
85
+ // 7. 合并冲突检查
86
+ if (this.policy.blockOnConflicts) {
87
+ const isMergeable = await this.github.isMergeable(prNumber)
88
+ if (isMergeable === false) {
89
+ result.violations.push({
90
+ code: 'MERGE_CONFLICT',
91
+ message: 'PR has merge conflicts that need to be resolved'
92
+ })
93
+ result.mergeable = false
94
+ }
95
+ }
96
+
97
+ return result
98
+ }
99
+
100
+ /**
101
+ * 执行合并(已通过检查)
102
+ * @param {number} prNumber
103
+ * @param {Object} options - 合并选项
104
+ * @returns {Promise<MergeResult>}
105
+ */
106
+ async merge(prNumber, options = {}) {
107
+ const checkResult = await this.checkMergeable(prNumber)
108
+ if (!checkResult.mergeable) {
109
+ throw new Error(`Cannot merge PR ${prNumber}: ${checkResult.violations.map(v => v.message).join('; ')}`)
110
+ }
111
+
112
+ const pr = await this.github.getPR(prNumber)
113
+ const mergeMethod = options.method || this.policy.defaultMergeMethod
114
+
115
+ if (!this.policy.allowedMergeMethods.includes(mergeMethod)) {
116
+ throw new Error(`Merge method '${mergeMethod}' not allowed. Allowed: ${this.policy.allowedMergeMethods.join(', ')}`)
117
+ }
118
+
119
+ try {
120
+ const result = await this.github.mergePR(prNumber, {
121
+ mergeMethod,
122
+ commitTitle: options.commitTitle || pr.title,
123
+ commitMessage: options.commitMessage || pr.body
124
+ })
125
+
126
+ return {
127
+ prNumber,
128
+ merged: true,
129
+ method: mergeMethod,
130
+ message: result?.message || 'Merged successfully',
131
+ sha: result?.sha || null
132
+ }
133
+ } catch (error) {
134
+ if (error.status === 405) {
135
+ throw new Error(`Merge blocked by branch protection rules: ${error.message}`)
136
+ }
137
+ throw error
138
+ }
139
+ }
140
+
141
+ // ============ 检查器 ============
142
+
143
+ async checkBasicState(pr, result) {
144
+ result.metadata.prTitle = pr.title
145
+ result.metadata.prBody = pr.body
146
+ result.metadata.baseBranch = pr.base.ref
147
+ result.metadata.headBranch = pr.head.ref
148
+ result.metadata.state = pr.state
149
+
150
+ // 关闭的 PR 无法合并
151
+ if (pr.state !== 'open') {
152
+ result.violations.push({
153
+ code: 'PR_CLOSED',
154
+ message: `PR is ${pr.state}, cannot merge`
155
+ })
156
+ result.mergeable = false
157
+ return
158
+ }
159
+
160
+ // 检查目标分支 — bannedBranches 只限制合并操作,不影响审查
161
+ // 但如果明确禁止,给出警告而非阻止
162
+ if (this.policy.bannedBranches.includes(pr.base.ref)) {
163
+ result.warnings.push({
164
+ code: 'BANNED_TARGET_BRANCH',
165
+ message: `Merge to '${pr.base.ref}' is not allowed. Set bannedBranches: [] to override.`
166
+ })
167
+ // 不 set mergeable = false,只警告
168
+ }
169
+
170
+ // 检查 PR 描述
171
+ if (this.policy.requireDescription && (!pr.body || pr.body.trim().length < this.policy.minDescriptionLength)) {
172
+ result.violations.push({
173
+ code: 'SHORT_DESCRIPTION',
174
+ message: `PR description too short (min ${this.policy.minDescriptionLength} chars)`
175
+ })
176
+ result.mergeable = false
177
+ }
178
+ }
179
+
180
+ async checkReviewStatus(pr, result) {
181
+ const reviews = await this.github.listReviews(pr.number)
182
+ result.metadata.reviews = reviews
183
+
184
+ // 计数:approved / changes_requested / commented
185
+ const approvedReviews = reviews.filter(r => r.state === 'APPROVED')
186
+ const changesRequestedReviews = reviews.filter(r => r.state === 'CHANGES_REQUESTED')
187
+ const commentedReviews = reviews.filter(r => r.state === 'COMMENTED')
188
+
189
+ result.checks.approvals = approvedReviews.length
190
+ result.checks.changesRequested = changesRequestedReviews.length
191
+ result.checks.reviews = reviews.length
192
+
193
+ // 检查 changes_requested
194
+ if (this.policy.requireNoChangesRequested && changesRequestedReviews.length > 0) {
195
+ result.violations.push({
196
+ code: 'CHANGES_REQUESTED',
197
+ message: `${changesRequestedReviews.length} review(s) requested changes`
198
+ })
199
+ result.mergeable = false
200
+ return
201
+ }
202
+
203
+ // 检查 approvals 数量
204
+ if (approvedReviews.length < this.policy.requiredApprovals) {
205
+ result.violations.push({
206
+ code: 'INSUFFICIENT_APPROVALS',
207
+ message: `Need ${this.policy.requiredApprovals} approval(s), have ${approvedReviews.length}`
208
+ })
209
+ result.mergeable = false
210
+ return
211
+ }
212
+
213
+ // 检查是否自 approve(如果禁止)
214
+ if (!this.policy.allowSelfApprove && pr?.user?.login) {
215
+ const selfApproved = approvedReviews.some(r => r.user?.login === pr.user.login)
216
+ if (selfApproved) {
217
+ result.warnings.push({
218
+ code: 'SELF_APPROVE',
219
+ message: 'PR author self-approved (allowSelfApprove is false)'
220
+ })
221
+ }
222
+ }
223
+ }
224
+
225
+ async checkCIStatus(pr, result) {
226
+ try {
227
+ const prSha = pr.head?.sha
228
+ if (!prSha) throw new Error('PR has no head commit SHA')
229
+ const status = await this.github.getCombinedStatus(prSha)
230
+ result.metadata.ciStatus = status
231
+
232
+ if (!status) {
233
+ result.warnings.push({
234
+ code: 'CI_UNAVAILABLE',
235
+ message: 'Unable to fetch CI status'
236
+ })
237
+ return
238
+ }
239
+
240
+ if (status.state !== 'success') {
241
+ const failingContexts = (status.statuses || []).filter(s => s.state !== 'success' && s.state !== 'pending')
242
+ if (failingContexts.length > 0) {
243
+ result.violations.push({
244
+ code: 'CI_FAILED',
245
+ message: `CI checks failing: ${failingContexts.map(c => c.context).join(', ')}`,
246
+ details: failingContexts
247
+ })
248
+ result.mergeable = false
249
+ }
250
+ } else {
251
+ result.checks.ciPassed = true
252
+ result.checks.ciContexts = (status.statuses || []).map(s => s.context)
253
+ }
254
+ } catch (error) {
255
+ result.warnings.push({
256
+ code: 'CI_FAILED',
257
+ message: `CI check unavailable: ${error.message}`
258
+ })
259
+ // 不阻止合并,只告警
260
+ }
261
+ }
262
+
263
+ async checkBranchProtection(pr, result) {
264
+ try {
265
+ const protection = await this.github.request(
266
+ `/repos/${this.github.owner}/${this.github.repo}/branches/${pr.base.ref}/protection`
267
+ )
268
+
269
+ result.metadata.branchProtection = protection
270
+
271
+ // 检查 required_pull_request_reviews
272
+ if (protection.required_pull_request_reviews) {
273
+ const req = protection.required_pull_request_reviews
274
+ if (req.required_approving_review_count > result.checks.approvals) {
275
+ result.violations.push({
276
+ code: 'BRANCH_PROTECTION_APPROVALS',
277
+ message: `Branch requires ${req.required_approving_review_count} approvals`
278
+ })
279
+ result.mergeable = false
280
+ }
281
+ }
282
+
283
+ // 检查 required_status_checks
284
+ if (protection.required_status_checks?.contexts?.length > 0) {
285
+ const requiredContexts = protection.required_status_checks.contexts
286
+ const ciContexts = result.checks.ciContexts || []
287
+ const missing = requiredContexts.filter(c => !ciContexts.includes(c))
288
+ if (missing.length > 0) {
289
+ result.violations.push({
290
+ code: 'MISSING_REQUIRED_CHECKS',
291
+ message: `Missing required status checks: ${missing.join(', ')}`
292
+ })
293
+ result.mergeable = false
294
+ }
295
+ }
296
+ } catch (error) {
297
+ if (error.status === 404) {
298
+ // 无分支保护规则,继续
299
+ result.metadata.branchProtection = null
300
+ } else {
301
+ throw error
302
+ }
303
+ }
304
+ }
305
+
306
+ async checkAutoMergeLabels(pr, result) {
307
+ const labels = pr.labels?.map(l => l.name) || []
308
+ const hasAutoMerge = labels.some(l => this.policy.autoMergeLabels.includes(l))
309
+
310
+ if (hasAutoMerge) {
311
+ result.checks.autoMergeLabel = true
312
+ // 不自动合并,只是标记 Eligible
313
+ result.metadata.autoMergeEligible = true
314
+ }
315
+ }
316
+
317
+ async checkIgnoreLabels(pr, result) {
318
+ const labels = pr.labels?.map(l => l.name) || []
319
+ const hasIgnore = labels.some(l => this.policy.ignoreLabels.includes(l))
320
+
321
+ if (hasIgnore) {
322
+ result.warnings.push({
323
+ code: 'IGNORED_LABEL',
324
+ message: 'PR has ignore label, consider not merging automatically'
325
+ })
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 快速检查:PR 是否可合并(简化版)
332
+ */
333
+ export async function isPRMergeable(github, prNumber, policy = {}) {
334
+ const checker = new PRMergePolicy(github, policy)
335
+ const result = await checker.checkMergeable(prNumber)
336
+ return result.mergeable
337
+ }
338
+
339
+ /**
340
+ * 批量检查:找出所有待处理可自动合并的 PR
341
+ */
342
+ export async function findEligiblePRs(github, options = {}) {
343
+ const { label, limit = 50 } = options
344
+ if (!github || typeof github.listPRs !== 'function') {
345
+ throw new Error('Invalid GitHub API instance')
346
+ }
347
+ const prs = await github.listPRs({ state: 'open', per_page: limit })
348
+ const checker = new PRMergePolicy(github)
349
+
350
+ const eligible = []
351
+ for (const pr of (prs || [])) {
352
+ if (label && !pr.labels?.some(l => l.name === label)) {
353
+ continue
354
+ }
355
+
356
+ try {
357
+ const result = await checker.checkMergeable(pr.number)
358
+ if (result.mergeable && result.metadata.autoMergeEligible) {
359
+ eligible.push({ pr, check: result })
360
+ }
361
+ } catch (error) {
362
+ console.error(`Error checking PR ${pr.number}:`, error.message)
363
+ }
364
+ }
365
+
366
+ return eligible
367
+ }