@icode-js/icode 3.0.4 → 3.2.0

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -5
  3. package/bin/icode.js +4 -4
  4. package/package.json +1 -1
  5. package/src/cli.js +60 -23
  6. package/src/commands/ai.js +4 -4
  7. package/src/commands/checkout.js +2 -2
  8. package/src/commands/clean.js +5 -2
  9. package/src/commands/codereview.js +5 -2
  10. package/src/commands/completion.js +93 -0
  11. package/src/commands/config.js +123 -10
  12. package/src/commands/doctor.js +80 -0
  13. package/src/commands/explain.js +6 -3
  14. package/src/commands/help.js +17 -0
  15. package/src/commands/info.js +5 -2
  16. package/src/commands/migrate.js +40 -3
  17. package/src/commands/push.js +72 -24
  18. package/src/commands/ready.js +110 -0
  19. package/src/commands/save.js +98 -0
  20. package/src/commands/sync.js +2 -2
  21. package/src/commands/tag.js +2 -2
  22. package/src/commands/undo.js +73 -10
  23. package/src/core/{ai-client.js → ai/client.js} +3 -3
  24. package/src/core/{ai-config.js → ai/config.js} +2 -2
  25. package/src/core/ai/diff-prompt.js +204 -0
  26. package/src/core/{ai-diff-range.js → ai/diff-range.js} +2 -2
  27. package/src/core/{args.js → cli/args.js} +23 -2
  28. package/src/core/cli/completion.js +720 -0
  29. package/src/core/cli/update-notifier.js +138 -0
  30. package/src/core/{shell.js → commands/shell.js} +13 -1
  31. package/src/core/{config-store.js → config/store.js} +53 -3
  32. package/src/core/{git-context.js → git/context.js} +24 -10
  33. package/src/core/git/preconditions.js +88 -0
  34. package/src/core/{git-service.js → git/service.js} +195 -5
  35. package/src/core/remote-merge/client.js +42 -0
  36. package/src/core/remote-merge/providers/gitlab.js +596 -0
  37. package/src/core/remote-merge/shared.js +49 -0
  38. package/src/core/tools/interactive.js +368 -0
  39. package/src/core/{loading.js → tools/loading.js} +5 -1
  40. package/src/core/{logger.js → tools/logger.js} +5 -0
  41. package/src/workflows/ai-codereview-workflow.js +18 -6
  42. package/src/workflows/ai-commit-workflow.js +20 -12
  43. package/src/workflows/ai-conflict-workflow.js +4 -4
  44. package/src/workflows/ai-explain-workflow.js +4 -4
  45. package/src/workflows/ai-risk-review-workflow.js +1 -1
  46. package/src/workflows/checkout-workflow.js +17 -5
  47. package/src/workflows/clean-workflow.js +92 -75
  48. package/src/workflows/doctor-workflow.js +131 -0
  49. package/src/workflows/info-workflow.js +4 -4
  50. package/src/workflows/migrate-workflow.js +269 -62
  51. package/src/workflows/push-workflow.js +475 -58
  52. package/src/workflows/ready-workflow.js +278 -0
  53. package/src/workflows/rollback-workflow.js +24 -9
  54. package/src/workflows/save-workflow.js +152 -0
  55. package/src/workflows/sync-workflow.js +22 -6
  56. package/src/workflows/tag-workflow.js +4 -4
  57. package/src/workflows/undo-workflow.js +37 -12
  58. package/src/core/prompts.js +0 -152
  59. /package/src/core/{ai-commit-summary.js → ai/commit-summary.js} +0 -0
  60. /package/src/core/{commit-conventions.js → git/commit-conventions.js} +0 -0
  61. /package/src/core/{hook-diagnostics.js → git/hook-diagnostics.js} +0 -0
@@ -0,0 +1,42 @@
1
+ import { IcodeError } from '../errors.js'
2
+ import { requestGitLabRemoteMerge, resolveGitLabProjectUrlFromRemote } from './providers/gitlab.js'
3
+
4
+ const REMOTE_MERGE_PROVIDERS = {
5
+ gitlab: {
6
+ requestRemoteMerge: requestGitLabRemoteMerge,
7
+ resolveProjectUrlFromRemote: resolveGitLabProjectUrlFromRemote
8
+ }
9
+ }
10
+
11
+ // 按 provider 读取远程合并实现,缺失或不支持时统一抛出明确错误。
12
+ function getRemoteMergeProvider(provider) {
13
+ const normalizedProvider = String(provider || '').trim()
14
+ if (!normalizedProvider) {
15
+ throw new IcodeError('远程合并缺少 provider', {
16
+ code: 'REMOTE_MERGE_PROVIDER_EMPTY',
17
+ exitCode: 2
18
+ })
19
+ }
20
+
21
+ const handler = REMOTE_MERGE_PROVIDERS[normalizedProvider]
22
+ if (!handler) {
23
+ throw new IcodeError(`当前仅支持: ${Object.keys(REMOTE_MERGE_PROVIDERS).join(', ')},收到 provider=${normalizedProvider}`, {
24
+ code: 'REMOTE_MERGE_PROVIDER_UNSUPPORTED',
25
+ exitCode: 2
26
+ })
27
+ }
28
+
29
+ return handler
30
+ }
31
+
32
+ // 统一根据 provider 从 remote URL 解析项目地址,避免工作流层感知平台细节。
33
+ export function resolveRemoteMergeProjectUrl(options = {}) {
34
+ const handler = getRemoteMergeProvider(options.provider)
35
+ return handler.resolveProjectUrlFromRemote(options.remoteUrl || '')
36
+ }
37
+
38
+ // 统一按 provider 调度远程合并请求,后续新增平台只需补 provider 实现。
39
+ export async function requestRemoteMerge(options = {}) {
40
+ const handler = getRemoteMergeProvider(options.provider)
41
+ return handler.requestRemoteMerge(options)
42
+ }
@@ -0,0 +1,596 @@
1
+ import { IcodeError } from '../../errors.js'
2
+ import { withSpinner } from '../../tools/loading.js'
3
+ import { logger } from '../../tools/logger.js'
4
+ import { normalizeRemoteMergeReason, parseRemoteMergePayload } from '../shared.js'
5
+
6
+ const GITLAB_MERGE_STATUS_POLL_INTERVAL_MS = 1000
7
+ const GITLAB_MERGE_STATUS_MAX_ATTEMPTS = 60
8
+ const GITLAB_PENDING_MERGE_STATUSES = new Set([
9
+ 'unchecked',
10
+ 'checking',
11
+ 'preparing',
12
+ 'approvals_syncing'
13
+ ])
14
+ const GITLAB_CONFLICT_MERGE_STATUSES = new Set([
15
+ 'cannot_be_merged',
16
+ 'conflict'
17
+ ])
18
+ const GITLAB_ACCEPTED_MERGE_STATUSES = new Set([
19
+ 'mergeable',
20
+ 'ci_still_running',
21
+ 'ci_must_pass',
22
+ 'merge_time'
23
+ ])
24
+
25
+ // 生成可读的 GitLab MR 标签,输入 MR id 与 URL,输出适合日志展示的短文本。
26
+ function formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl) {
27
+ if (mergeRequestUrl) {
28
+ return `${logger.color('green', mergeRequestUrl)} (#${mergeRequestId})`
29
+ }
30
+
31
+ return `#${mergeRequestId}`
32
+ }
33
+
34
+ // 根据 Git remote URL 生成标准 GitLab 项目 URL,无法识别时返回空串。
35
+ export function resolveGitLabProjectUrlFromRemote(remoteUrl = '') {
36
+ const raw = String(remoteUrl || '').trim()
37
+ if (!raw) {
38
+ return ''
39
+ }
40
+
41
+ if (raw.startsWith('http://') || raw.startsWith('https://')) {
42
+ return raw.replace(/\.git$/i, '').replace(/\/+$/g, '')
43
+ }
44
+
45
+ if (raw.startsWith('ssh://')) {
46
+ try {
47
+ const parsed = new URL(raw)
48
+ const repoPath = parsed.pathname.replace(/^\/+/, '').replace(/\.git$/i, '')
49
+ if (!parsed.hostname || !repoPath) {
50
+ return ''
51
+ }
52
+ return `https://${parsed.hostname}/${repoPath}`
53
+ } catch {
54
+ return ''
55
+ }
56
+ }
57
+
58
+ const sshMatch = raw.match(/^git@([^:]+):(.+)$/i)
59
+ if (!sshMatch) {
60
+ return ''
61
+ }
62
+
63
+ const host = sshMatch[1]
64
+ const repoPath = sshMatch[2].replace(/\.git$/i, '')
65
+ return `https://${host}/${repoPath}`
66
+ }
67
+
68
+ // 解析 GitLab 项目 URL,按官方 REST 规范生成 host 级 API 地址与完整项目 path。
69
+ function parseGitLabProject(projectUrl = '') {
70
+ const normalizedProjectUrl = String(projectUrl || '').trim().replace(/\/+$/g, '')
71
+ if (!normalizedProjectUrl) {
72
+ throw new IcodeError('远程合并缺少 GitLab 项目 URL', {
73
+ code: 'REMOTE_MERGE_PROJECT_URL_EMPTY',
74
+ exitCode: 2
75
+ })
76
+ }
77
+
78
+ let parsedUrl
79
+ try {
80
+ parsedUrl = new URL(normalizedProjectUrl)
81
+ } catch (error) {
82
+ throw new IcodeError(`GitLab 项目 URL 无效: ${normalizedProjectUrl}`, {
83
+ code: 'REMOTE_MERGE_PROJECT_URL_INVALID',
84
+ exitCode: 2,
85
+ cause: error
86
+ })
87
+ }
88
+
89
+ const projectPath = parsedUrl.pathname.replace(/^\/+/, '').replace(/\/+$/g, '')
90
+ if (!projectPath) {
91
+ throw new IcodeError(`GitLab 项目 URL 缺少仓库路径: ${normalizedProjectUrl}`, {
92
+ code: 'REMOTE_MERGE_PROJECT_PATH_EMPTY',
93
+ exitCode: 2
94
+ })
95
+ }
96
+
97
+ return {
98
+ baseUrl: `${parsedUrl.protocol}//${parsedUrl.host}`,
99
+ projectId: encodeURIComponent(projectPath)
100
+ }
101
+ }
102
+
103
+ // 生成 GitLab 创建 MR 所需标题,确保最小必填字段始终完整。
104
+ function buildGitLabMergeRequestTitle(options = {}) {
105
+ const title = String(options.mergeRequestTitle || '').trim()
106
+ if (!title) {
107
+ throw new IcodeError('GitLab 远程合并缺少 mergeRequestTitle', {
108
+ code: 'REMOTE_MERGE_TITLE_REQUIRED',
109
+ exitCode: 2
110
+ })
111
+ }
112
+
113
+ return title
114
+ }
115
+
116
+ // 生成 GitLab 创建 MR 所需正文,正文完全使用待合并 commit 内容。
117
+ function buildGitLabMergeRequestDescription(options = {}) {
118
+ const description = String(options.mergeRequestDescription || '').trim()
119
+ if (!description) {
120
+ throw new IcodeError('GitLab 远程合并缺少 mergeRequestDescription', {
121
+ code: 'REMOTE_MERGE_DESCRIPTION_REQUIRED',
122
+ exitCode: 2
123
+ })
124
+ }
125
+
126
+ return description
127
+ }
128
+
129
+ // 等待指定毫秒,供轮询 GitLab 异步合并状态时复用。
130
+ function delay(ms) {
131
+ return new Promise((resolve) => {
132
+ setTimeout(resolve, ms)
133
+ })
134
+ }
135
+
136
+ // 合并检查轮询间隔,测试可显式传入 0 以避免等待;输入 options,输出毫秒数。
137
+ function resolvePollIntervalMs(options = {}) {
138
+ if (Number.isInteger(options.pollIntervalMs) && options.pollIntervalMs >= 0) {
139
+ return options.pollIntervalMs
140
+ }
141
+
142
+ return GITLAB_MERGE_STATUS_POLL_INTERVAL_MS
143
+ }
144
+
145
+ // 合并检查最大轮询次数,测试或调用方可显式缩短;输入 options,输出正整数次数。
146
+ function resolveMaxPollAttempts(options = {}) {
147
+ if (Number.isInteger(options.maxPollAttempts) && options.maxPollAttempts > 0) {
148
+ return options.maxPollAttempts
149
+ }
150
+
151
+ return GITLAB_MERGE_STATUS_MAX_ATTEMPTS
152
+ }
153
+
154
+ // 执行一次 GitLab JSON 请求,并统一返回响应对象、原始文本和解析后的负载。
155
+ async function performGitLabJsonRequest(endpoint, requestOptions, errorMessage) {
156
+ let response
157
+
158
+ try {
159
+ response = await fetch(endpoint, requestOptions)
160
+ } catch (error) {
161
+ throw new IcodeError(`${errorMessage}: ${error?.message || '未知网络错误'}`, {
162
+ code: 'REMOTE_MERGE_FETCH_ERROR',
163
+ exitCode: 2,
164
+ cause: error
165
+ })
166
+ }
167
+
168
+ const rawText = await response.text()
169
+ const payload = parseRemoteMergePayload(rawText)
170
+
171
+ return {
172
+ response,
173
+ rawText,
174
+ payload
175
+ }
176
+ }
177
+
178
+ // 把 GitLab HTTP 错误响应归一化成统一的远程合并失败结果。
179
+ function buildGitLabFailedResult(response, rawText, payload, extra = {}) {
180
+ return {
181
+ ok: false,
182
+ conflict: response.status === 409,
183
+ status: response.status,
184
+ reason: normalizeRemoteMergeReason(payload, `HTTP ${response.status}${rawText ? `: ${rawText.trim()}` : ''}`),
185
+ payload,
186
+ ...extra
187
+ }
188
+ }
189
+
190
+ // 读取 GitLab MR 当前状态,触发一次 merge status 重算后返回详情。
191
+ async function fetchGitLabMergeRequest(project, mergeRequestId, apiKey) {
192
+ const endpoint = new URL(`${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}`)
193
+ endpoint.searchParams.set('with_merge_status_recheck', 'true')
194
+
195
+ const { response, rawText, payload } = await performGitLabJsonRequest(
196
+ endpoint.toString(),
197
+ {
198
+ method: 'GET',
199
+ headers: {
200
+ 'PRIVATE-TOKEN': apiKey
201
+ }
202
+ },
203
+ 'GitLab 查询合并请求状态失败'
204
+ )
205
+
206
+ return {
207
+ response,
208
+ rawText,
209
+ payload
210
+ }
211
+ }
212
+
213
+ // 构建 GitLab 状态轮询超时结果,输入阶段信息和最后一次响应,输出统一失败结构。
214
+ function buildGitLabPollTimeoutResult(stage, mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload) {
215
+ const status = typeof lastPayload?.detailed_merge_status === 'string' && lastPayload.detailed_merge_status.trim()
216
+ ? lastPayload.detailed_merge_status.trim()
217
+ : (typeof lastPayload?.merge_status === 'string' ? lastPayload.merge_status.trim() : '')
218
+ const reason = status
219
+ ? `GitLab 合并检查超时,最后状态: ${status},已轮询 ${maxAttempts} 次。`
220
+ : `GitLab 合并检查超时,已轮询 ${maxAttempts} 次。`
221
+
222
+ return {
223
+ ok: false,
224
+ conflict: false,
225
+ status: 408,
226
+ reason,
227
+ step: stage,
228
+ mergeRequestId,
229
+ mergeRequestUrl,
230
+ payload: lastPayload
231
+ }
232
+ }
233
+
234
+ // 将 GitLab MR 状态转换成统一结果,保证上层能明确识别冲突分支。
235
+ function buildGitLabMergeStatusResult(payload, mergeRequestId, mergeRequestUrl) {
236
+ const detailedMergeStatus = typeof payload?.detailed_merge_status === 'string'
237
+ ? payload.detailed_merge_status.trim()
238
+ : ''
239
+ const mergeStatus = typeof payload?.merge_status === 'string'
240
+ ? payload.merge_status.trim()
241
+ : ''
242
+ const normalizedStatus = detailedMergeStatus || mergeStatus
243
+ const mergeError = typeof payload?.merge_error === 'string' ? payload.merge_error.trim() : ''
244
+ const state = typeof payload?.state === 'string' ? payload.state.trim() : ''
245
+ const reason = normalizeRemoteMergeReason(
246
+ payload,
247
+ mergeError || normalizedStatus || 'GitLab 未返回明确合并状态'
248
+ )
249
+
250
+ if (payload?.has_conflicts === true || GITLAB_CONFLICT_MERGE_STATUSES.has(normalizedStatus)) {
251
+ return {
252
+ ok: false,
253
+ conflict: true,
254
+ status: 409,
255
+ reason,
256
+ step: 'inspect',
257
+ mergeRequestId,
258
+ mergeRequestUrl,
259
+ payload
260
+ }
261
+ }
262
+
263
+ if (state === 'merged') {
264
+ return {
265
+ ok: true,
266
+ conflict: false,
267
+ status: 200,
268
+ reason: '远程合并已完成',
269
+ mergeRequestId,
270
+ mergeRequestUrl,
271
+ payload
272
+ }
273
+ }
274
+
275
+ if (payload?.merge_when_pipeline_succeeds === true || payload?.auto_merge_enabled === true) {
276
+ return {
277
+ ok: true,
278
+ conflict: false,
279
+ status: 202,
280
+ reason: '远程合并已提交,等待 GitLab 完成异步合并检查',
281
+ mergeRequestId,
282
+ mergeRequestUrl,
283
+ payload
284
+ }
285
+ }
286
+
287
+ if (GITLAB_ACCEPTED_MERGE_STATUSES.has(normalizedStatus)) {
288
+ return {
289
+ ok: true,
290
+ conflict: false,
291
+ status: 200,
292
+ reason: '远程合并校验通过',
293
+ mergeRequestId,
294
+ mergeRequestUrl,
295
+ payload
296
+ }
297
+ }
298
+
299
+ if (GITLAB_PENDING_MERGE_STATUSES.has(normalizedStatus)) {
300
+ return null
301
+ }
302
+
303
+ return {
304
+ ok: false,
305
+ conflict: false,
306
+ status: 409,
307
+ reason,
308
+ step: 'inspect',
309
+ mergeRequestId,
310
+ mergeRequestUrl,
311
+ payload
312
+ }
313
+ }
314
+
315
+ // 将 GitLab MR 状态转换成“是否可以开始执行 merge”的结果。
316
+ function buildGitLabMergeReadinessResult(payload, mergeRequestId, mergeRequestUrl) {
317
+ const detailedMergeStatus = typeof payload?.detailed_merge_status === 'string'
318
+ ? payload.detailed_merge_status.trim()
319
+ : ''
320
+ const mergeStatus = typeof payload?.merge_status === 'string'
321
+ ? payload.merge_status.trim()
322
+ : ''
323
+ const normalizedStatus = detailedMergeStatus || mergeStatus
324
+ const mergeError = typeof payload?.merge_error === 'string' ? payload.merge_error.trim() : ''
325
+ const state = typeof payload?.state === 'string' ? payload.state.trim() : ''
326
+ const reason = normalizeRemoteMergeReason(
327
+ payload,
328
+ mergeError || normalizedStatus || 'GitLab 未返回明确合并状态'
329
+ )
330
+
331
+ if (payload?.has_conflicts === true || GITLAB_CONFLICT_MERGE_STATUSES.has(normalizedStatus)) {
332
+ return {
333
+ ok: false,
334
+ conflict: true,
335
+ status: 409,
336
+ reason,
337
+ step: 'inspect',
338
+ mergeRequestId,
339
+ mergeRequestUrl,
340
+ payload
341
+ }
342
+ }
343
+
344
+ if (state === 'merged') {
345
+ return {
346
+ ok: true,
347
+ conflict: false,
348
+ status: 200,
349
+ reason: '远程合并已完成',
350
+ mergeRequestId,
351
+ mergeRequestUrl,
352
+ payload
353
+ }
354
+ }
355
+
356
+ if (GITLAB_ACCEPTED_MERGE_STATUSES.has(normalizedStatus)) {
357
+ return {
358
+ ok: true,
359
+ conflict: false,
360
+ status: 200,
361
+ reason: 'GitLab 已完成合并前检查',
362
+ mergeRequestId,
363
+ mergeRequestUrl,
364
+ payload
365
+ }
366
+ }
367
+
368
+ if (GITLAB_PENDING_MERGE_STATUSES.has(normalizedStatus)) {
369
+ return null
370
+ }
371
+
372
+ return {
373
+ ok: false,
374
+ conflict: false,
375
+ status: 409,
376
+ reason,
377
+ step: 'inspect',
378
+ mergeRequestId,
379
+ mergeRequestUrl,
380
+ payload
381
+ }
382
+ }
383
+
384
+ // 轮询 GitLab MR,直到“是否允许 merge”检查完成。
385
+ async function waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey, options = {}) {
386
+ const maxAttempts = resolveMaxPollAttempts(options)
387
+ const pollIntervalMs = resolvePollIntervalMs(options)
388
+ let lastPayload = null
389
+
390
+ const pollResult = await withSpinner(
391
+ `等待 GitLab 完成合并前检查 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
392
+ async () => {
393
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
394
+ const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
395
+ lastPayload = mergeRequest.payload
396
+ if (!mergeRequest.response.ok) {
397
+ return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
398
+ step: 'inspect',
399
+ mergeRequestId,
400
+ mergeRequestUrl,
401
+ payload: {
402
+ create: null,
403
+ merge: null,
404
+ inspect: mergeRequest.payload
405
+ }
406
+ })
407
+ }
408
+
409
+ const result = buildGitLabMergeReadinessResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
410
+ if (result) {
411
+ return result
412
+ }
413
+
414
+ if (attempt < maxAttempts) {
415
+ await delay(pollIntervalMs)
416
+ }
417
+ }
418
+
419
+ return null
420
+ }
421
+ )
422
+ if (pollResult) {
423
+ return pollResult
424
+ }
425
+
426
+ return buildGitLabPollTimeoutResult('inspect', mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload)
427
+ }
428
+
429
+ // 轮询 GitLab MR,直到异步冲突检查完成并得到可归档的最终状态。
430
+ async function waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey, options = {}) {
431
+ const maxAttempts = resolveMaxPollAttempts(options)
432
+ const pollIntervalMs = resolvePollIntervalMs(options)
433
+ let lastPayload = null
434
+
435
+ const pollResult = await withSpinner(
436
+ `等待 GitLab 完成远程合并 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
437
+ async () => {
438
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
439
+ const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
440
+ lastPayload = mergeRequest.payload
441
+ if (!mergeRequest.response.ok) {
442
+ return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
443
+ step: 'inspect',
444
+ mergeRequestId,
445
+ mergeRequestUrl,
446
+ payload: {
447
+ create: null,
448
+ merge: null,
449
+ inspect: mergeRequest.payload
450
+ }
451
+ })
452
+ }
453
+
454
+ const result = buildGitLabMergeStatusResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
455
+ if (result) {
456
+ return result
457
+ }
458
+
459
+ if (attempt < maxAttempts) {
460
+ await delay(pollIntervalMs)
461
+ }
462
+ }
463
+
464
+ return null
465
+ }
466
+ )
467
+ if (pollResult) {
468
+ return pollResult
469
+ }
470
+
471
+ return buildGitLabPollTimeoutResult('inspect', mergeRequestId, mergeRequestUrl, maxAttempts, lastPayload)
472
+ }
473
+
474
+ // 根据创建 MR 的结果提交自动合并,由 GitLab merge 接口按项目审批规则判定是否允许合并。
475
+ async function processGitLabMergeRequest(project, createPayload, apiKey, options = {}) {
476
+ const mergeRequestId = createPayload?.iid ? String(createPayload.iid) : ''
477
+ const mergeRequestUrl = typeof createPayload?.web_url === 'string' ? createPayload.web_url : ''
478
+
479
+ if (!mergeRequestId) {
480
+ return {
481
+ ok: false,
482
+ conflict: false,
483
+ status: 200,
484
+ reason: 'GitLab 创建合并请求成功,但响应缺少 iid,无法继续自动合并。',
485
+ step: 'merge',
486
+ mergeRequestId,
487
+ mergeRequestUrl,
488
+ payload: {
489
+ create: createPayload,
490
+ inspect: null,
491
+ merge: null
492
+ }
493
+ }
494
+ }
495
+
496
+ const sourceSha = typeof createPayload?.sha === 'string' ? createPayload.sha.trim() : ''
497
+ logger.success(`GitLab 合并请求已创建: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
498
+ const readinessResult = await waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey, options)
499
+ if (!readinessResult.ok) {
500
+ return {
501
+ ...readinessResult,
502
+ payload: {
503
+ create: createPayload,
504
+ merge: null,
505
+ inspect: readinessResult.payload
506
+ }
507
+ }
508
+ }
509
+ logger.success(`GitLab 合并前检查通过: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
510
+
511
+ const mergeRequestBody = {
512
+ auto_merge: true
513
+ }
514
+ if (sourceSha) {
515
+ mergeRequestBody.sha = sourceSha
516
+ }
517
+
518
+ const mergeEndpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}/merge`
519
+ const mergeResult = await withSpinner(
520
+ `提交 GitLab 自动合并 ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`,
521
+ () => performGitLabJsonRequest(
522
+ mergeEndpoint,
523
+ {
524
+ method: 'PUT',
525
+ headers: {
526
+ 'Content-Type': 'application/json',
527
+ 'PRIVATE-TOKEN': apiKey
528
+ },
529
+ body: JSON.stringify(mergeRequestBody)
530
+ },
531
+ 'GitLab 自动合并请求失败'
532
+ )
533
+ )
534
+
535
+ if (!mergeResult.response.ok) {
536
+ return buildGitLabFailedResult(mergeResult.response, mergeResult.rawText, mergeResult.payload, {
537
+ step: 'merge',
538
+ mergeRequestId,
539
+ mergeRequestUrl,
540
+ payload: {
541
+ create: createPayload,
542
+ merge: mergeResult.payload
543
+ }
544
+ })
545
+ }
546
+
547
+ logger.success(`GitLab 自动合并已提交: ${formatGitLabMergeRequestLabel(mergeRequestId, mergeRequestUrl)}`)
548
+ const finalResult = await waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey, options)
549
+ return {
550
+ ...finalResult,
551
+ payload: {
552
+ create: createPayload,
553
+ merge: mergeResult.payload,
554
+ inspect: finalResult.payload
555
+ }
556
+ }
557
+ }
558
+
559
+ // 按 GitLab 固定 REST API 发起 MR 创建请求,并返回统一结果结构。
560
+ export async function requestGitLabRemoteMerge(options = {}) {
561
+ const apiKey = String(options.apiKey || '').trim()
562
+ if (!apiKey) {
563
+ throw new IcodeError('远程合并缺少 apiKey', {
564
+ code: 'REMOTE_MERGE_API_KEY_EMPTY',
565
+ exitCode: 2
566
+ })
567
+ }
568
+
569
+ const project = parseGitLabProject(options.projectUrl)
570
+ const endpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests`
571
+ const { response, rawText, payload } = await performGitLabJsonRequest(
572
+ endpoint,
573
+ {
574
+ method: 'POST',
575
+ headers: {
576
+ 'Content-Type': 'application/json',
577
+ 'PRIVATE-TOKEN': apiKey
578
+ },
579
+ body: JSON.stringify({
580
+ source_branch: options.sourceBranch,
581
+ target_branch: options.targetBranch,
582
+ title: buildGitLabMergeRequestTitle(options),
583
+ description: buildGitLabMergeRequestDescription(options)
584
+ })
585
+ },
586
+ 'GitLab 创建合并请求失败'
587
+ )
588
+
589
+ if (!response.ok) {
590
+ return buildGitLabFailedResult(response, rawText, payload, {
591
+ step: 'create'
592
+ })
593
+ }
594
+
595
+ return processGitLabMergeRequest(project, payload, apiKey, options)
596
+ }
@@ -0,0 +1,49 @@
1
+ // 提取错误负载里的可读文本,兼容字符串、数组和对象三类常见结构。
2
+ function collectReasonParts(value) {
3
+ if (typeof value === 'string' && value.trim()) {
4
+ return [value.trim()]
5
+ }
6
+
7
+ if (Array.isArray(value)) {
8
+ return value.flatMap((item) => collectReasonParts(item))
9
+ }
10
+
11
+ if (value && typeof value === 'object') {
12
+ return Object.values(value).flatMap((item) => collectReasonParts(item))
13
+ }
14
+
15
+ return []
16
+ }
17
+
18
+ // 统一归一化远程合并返回原因,优先读取结构化字段,缺失时回退到兜底文案。
19
+ export function normalizeRemoteMergeReason(payload, fallback) {
20
+ const candidates = [
21
+ payload?.reason,
22
+ payload?.message,
23
+ payload?.error,
24
+ payload?.detail
25
+ ]
26
+
27
+ for (const item of candidates) {
28
+ const parts = collectReasonParts(item)
29
+ if (parts.length) {
30
+ return parts.join('; ')
31
+ }
32
+ }
33
+
34
+ return fallback
35
+ }
36
+
37
+ // 尝试把响应文本解析成 JSON,解析失败时返回 null,避免上层重复写 try/catch。
38
+ export function parseRemoteMergePayload(rawText = '') {
39
+ const normalizedText = String(rawText || '')
40
+ if (!normalizedText.trim()) {
41
+ return null
42
+ }
43
+
44
+ try {
45
+ return JSON.parse(normalizedText)
46
+ } catch {
47
+ return null
48
+ }
49
+ }