@icode-js/icode 3.0.4 → 3.1.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -3
  3. package/bin/icode.js +4 -4
  4. package/package.json +1 -1
  5. package/src/cli.js +54 -23
  6. package/src/commands/ai.js +4 -4
  7. package/src/commands/checkout.js +2 -2
  8. package/src/commands/clean.js +1 -1
  9. package/src/commands/codereview.js +1 -1
  10. package/src/commands/completion.js +90 -0
  11. package/src/commands/config.js +117 -7
  12. package/src/commands/explain.js +2 -2
  13. package/src/commands/help.js +7 -0
  14. package/src/commands/info.js +1 -1
  15. package/src/commands/migrate.js +2 -2
  16. package/src/commands/push.js +29 -8
  17. package/src/commands/sync.js +2 -2
  18. package/src/commands/tag.js +2 -2
  19. package/src/commands/undo.js +60 -9
  20. package/src/core/{ai-client.js → ai/client.js} +3 -3
  21. package/src/core/{ai-config.js → ai/config.js} +2 -2
  22. package/src/core/{ai-diff-range.js → ai/diff-range.js} +2 -2
  23. package/src/core/cli/completion.js +687 -0
  24. package/src/core/cli/update-notifier.js +138 -0
  25. package/src/core/{shell.js → commands/shell.js} +13 -1
  26. package/src/core/{config-store.js → config/store.js} +55 -3
  27. package/src/core/{git-context.js → git/context.js} +25 -10
  28. package/src/core/{git-service.js → git/service.js} +44 -4
  29. package/src/core/remote-merge/client.js +42 -0
  30. package/src/core/remote-merge/providers/gitlab.js +537 -0
  31. package/src/core/remote-merge/shared.js +49 -0
  32. package/src/core/{prompts.js → tools/interactive.js} +46 -5
  33. package/src/workflows/ai-codereview-workflow.js +4 -4
  34. package/src/workflows/ai-commit-workflow.js +6 -6
  35. package/src/workflows/ai-conflict-workflow.js +4 -4
  36. package/src/workflows/ai-explain-workflow.js +4 -4
  37. package/src/workflows/ai-risk-review-workflow.js +1 -1
  38. package/src/workflows/checkout-workflow.js +3 -3
  39. package/src/workflows/clean-workflow.js +5 -5
  40. package/src/workflows/info-workflow.js +4 -4
  41. package/src/workflows/migrate-workflow.js +4 -4
  42. package/src/workflows/push-workflow.js +259 -24
  43. package/src/workflows/rollback-workflow.js +4 -4
  44. package/src/workflows/sync-workflow.js +4 -4
  45. package/src/workflows/tag-workflow.js +4 -4
  46. package/src/workflows/undo-workflow.js +12 -5
  47. /package/src/core/{ai-commit-summary.js → ai/commit-summary.js} +0 -0
  48. /package/src/core/{args.js → cli/args.js} +0 -0
  49. /package/src/core/{commit-conventions.js → git/commit-conventions.js} +0 -0
  50. /package/src/core/{hook-diagnostics.js → git/hook-diagnostics.js} +0 -0
  51. /package/src/core/{loading.js → tools/loading.js} +0 -0
  52. /package/src/core/{logger.js → tools/logger.js} +0 -0
@@ -0,0 +1,537 @@
1
+ import { IcodeError } from '../../errors.js'
2
+ import { normalizeRemoteMergeReason, parseRemoteMergePayload } from '../shared.js'
3
+
4
+ const GITLAB_MERGE_STATUS_POLL_INTERVAL_MS = 1000
5
+ const GITLAB_PENDING_MERGE_STATUSES = new Set([
6
+ 'unchecked',
7
+ 'checking',
8
+ 'preparing',
9
+ 'approvals_syncing'
10
+ ])
11
+ const GITLAB_CONFLICT_MERGE_STATUSES = new Set([
12
+ 'cannot_be_merged',
13
+ 'conflict'
14
+ ])
15
+ const GITLAB_ACCEPTED_MERGE_STATUSES = new Set([
16
+ 'mergeable',
17
+ 'ci_still_running',
18
+ 'ci_must_pass',
19
+ 'merge_time'
20
+ ])
21
+
22
+ // 根据 Git remote URL 生成标准 GitLab 项目 URL,无法识别时返回空串。
23
+ export function resolveGitLabProjectUrlFromRemote(remoteUrl = '') {
24
+ const raw = String(remoteUrl || '').trim()
25
+ if (!raw) {
26
+ return ''
27
+ }
28
+
29
+ if (raw.startsWith('http://') || raw.startsWith('https://')) {
30
+ return raw.replace(/\.git$/i, '').replace(/\/+$/g, '')
31
+ }
32
+
33
+ if (raw.startsWith('ssh://')) {
34
+ try {
35
+ const parsed = new URL(raw)
36
+ const repoPath = parsed.pathname.replace(/^\/+/, '').replace(/\.git$/i, '')
37
+ if (!parsed.hostname || !repoPath) {
38
+ return ''
39
+ }
40
+ return `https://${parsed.hostname}/${repoPath}`
41
+ } catch {
42
+ return ''
43
+ }
44
+ }
45
+
46
+ const sshMatch = raw.match(/^git@([^:]+):(.+)$/i)
47
+ if (!sshMatch) {
48
+ return ''
49
+ }
50
+
51
+ const host = sshMatch[1]
52
+ const repoPath = sshMatch[2].replace(/\.git$/i, '')
53
+ return `https://${host}/${repoPath}`
54
+ }
55
+
56
+ // 解析 GitLab 项目 URL,按官方 REST 规范生成 host 级 API 地址与完整项目 path。
57
+ function parseGitLabProject(projectUrl = '') {
58
+ const normalizedProjectUrl = String(projectUrl || '').trim().replace(/\/+$/g, '')
59
+ if (!normalizedProjectUrl) {
60
+ throw new IcodeError('远程合并缺少 GitLab 项目 URL', {
61
+ code: 'REMOTE_MERGE_PROJECT_URL_EMPTY',
62
+ exitCode: 2
63
+ })
64
+ }
65
+
66
+ let parsedUrl
67
+ try {
68
+ parsedUrl = new URL(normalizedProjectUrl)
69
+ } catch (error) {
70
+ throw new IcodeError(`GitLab 项目 URL 无效: ${normalizedProjectUrl}`, {
71
+ code: 'REMOTE_MERGE_PROJECT_URL_INVALID',
72
+ exitCode: 2,
73
+ cause: error
74
+ })
75
+ }
76
+
77
+ const projectPath = parsedUrl.pathname.replace(/^\/+/, '').replace(/\/+$/g, '')
78
+ if (!projectPath) {
79
+ throw new IcodeError(`GitLab 项目 URL 缺少仓库路径: ${normalizedProjectUrl}`, {
80
+ code: 'REMOTE_MERGE_PROJECT_PATH_EMPTY',
81
+ exitCode: 2
82
+ })
83
+ }
84
+
85
+ return {
86
+ baseUrl: `${parsedUrl.protocol}//${parsedUrl.host}`,
87
+ projectId: encodeURIComponent(projectPath)
88
+ }
89
+ }
90
+
91
+ // 生成 GitLab 创建 MR 所需标题,确保最小必填字段始终完整。
92
+ function buildGitLabMergeRequestTitle(options = {}) {
93
+ const title = String(options.mergeRequestTitle || '').trim()
94
+ if (!title) {
95
+ throw new IcodeError('GitLab 远程合并缺少 mergeRequestTitle', {
96
+ code: 'REMOTE_MERGE_TITLE_REQUIRED',
97
+ exitCode: 2
98
+ })
99
+ }
100
+
101
+ return title
102
+ }
103
+
104
+ // 生成 GitLab 创建 MR 所需正文,正文完全使用待合并 commit 内容。
105
+ function buildGitLabMergeRequestDescription(options = {}) {
106
+ const description = String(options.mergeRequestDescription || '').trim()
107
+ if (!description) {
108
+ throw new IcodeError('GitLab 远程合并缺少 mergeRequestDescription', {
109
+ code: 'REMOTE_MERGE_DESCRIPTION_REQUIRED',
110
+ exitCode: 2
111
+ })
112
+ }
113
+
114
+ return description
115
+ }
116
+
117
+ // 等待指定毫秒,供轮询 GitLab 异步合并状态时复用。
118
+ function delay(ms) {
119
+ return new Promise((resolve) => {
120
+ setTimeout(resolve, ms)
121
+ })
122
+ }
123
+
124
+ // 执行一次 GitLab JSON 请求,并统一返回响应对象、原始文本和解析后的负载。
125
+ async function performGitLabJsonRequest(endpoint, requestOptions, errorMessage) {
126
+ let response
127
+
128
+ try {
129
+ response = await fetch(endpoint, requestOptions)
130
+ } catch (error) {
131
+ throw new IcodeError(`${errorMessage}: ${error?.message || '未知网络错误'}`, {
132
+ code: 'REMOTE_MERGE_FETCH_ERROR',
133
+ exitCode: 2,
134
+ cause: error
135
+ })
136
+ }
137
+
138
+ const rawText = await response.text()
139
+ const payload = parseRemoteMergePayload(rawText)
140
+
141
+ return {
142
+ response,
143
+ rawText,
144
+ payload
145
+ }
146
+ }
147
+
148
+ // 把 GitLab HTTP 错误响应归一化成统一的远程合并失败结果。
149
+ function buildGitLabFailedResult(response, rawText, payload, extra = {}) {
150
+ return {
151
+ ok: false,
152
+ conflict: response.status === 409,
153
+ status: response.status,
154
+ reason: normalizeRemoteMergeReason(payload, `HTTP ${response.status}${rawText ? `: ${rawText.trim()}` : ''}`),
155
+ payload,
156
+ ...extra
157
+ }
158
+ }
159
+
160
+ // 读取 GitLab MR 当前状态,触发一次 merge status 重算后返回详情。
161
+ async function fetchGitLabMergeRequest(project, mergeRequestId, apiKey) {
162
+ const endpoint = new URL(`${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}`)
163
+ endpoint.searchParams.set('with_merge_status_recheck', 'true')
164
+
165
+ const { response, rawText, payload } = await performGitLabJsonRequest(
166
+ endpoint.toString(),
167
+ {
168
+ method: 'GET',
169
+ headers: {
170
+ 'PRIVATE-TOKEN': apiKey
171
+ }
172
+ },
173
+ 'GitLab 查询合并请求状态失败'
174
+ )
175
+
176
+ return {
177
+ response,
178
+ rawText,
179
+ payload
180
+ }
181
+ }
182
+
183
+ // 将 GitLab MR 状态转换成统一结果,保证上层能明确识别冲突分支。
184
+ function buildGitLabMergeStatusResult(payload, mergeRequestId, mergeRequestUrl) {
185
+ const detailedMergeStatus = typeof payload?.detailed_merge_status === 'string'
186
+ ? payload.detailed_merge_status.trim()
187
+ : ''
188
+ const mergeStatus = typeof payload?.merge_status === 'string'
189
+ ? payload.merge_status.trim()
190
+ : ''
191
+ const normalizedStatus = detailedMergeStatus || mergeStatus
192
+ const mergeError = typeof payload?.merge_error === 'string' ? payload.merge_error.trim() : ''
193
+ const state = typeof payload?.state === 'string' ? payload.state.trim() : ''
194
+ const reason = normalizeRemoteMergeReason(
195
+ payload,
196
+ mergeError || normalizedStatus || 'GitLab 未返回明确合并状态'
197
+ )
198
+
199
+ if (payload?.has_conflicts === true || GITLAB_CONFLICT_MERGE_STATUSES.has(normalizedStatus)) {
200
+ return {
201
+ ok: false,
202
+ conflict: true,
203
+ status: 409,
204
+ reason,
205
+ step: 'inspect',
206
+ mergeRequestId,
207
+ mergeRequestUrl,
208
+ payload
209
+ }
210
+ }
211
+
212
+ if (state === 'merged') {
213
+ return {
214
+ ok: true,
215
+ conflict: false,
216
+ status: 200,
217
+ reason: '远程合并已完成',
218
+ mergeRequestId,
219
+ mergeRequestUrl,
220
+ payload
221
+ }
222
+ }
223
+
224
+ if (payload?.merge_when_pipeline_succeeds === true || payload?.auto_merge_enabled === true) {
225
+ return {
226
+ ok: true,
227
+ conflict: false,
228
+ status: 202,
229
+ reason: '远程合并已提交,等待 GitLab 完成异步合并检查',
230
+ mergeRequestId,
231
+ mergeRequestUrl,
232
+ payload
233
+ }
234
+ }
235
+
236
+ if (GITLAB_ACCEPTED_MERGE_STATUSES.has(normalizedStatus)) {
237
+ return {
238
+ ok: true,
239
+ conflict: false,
240
+ status: 200,
241
+ reason: '远程合并校验通过',
242
+ mergeRequestId,
243
+ mergeRequestUrl,
244
+ payload
245
+ }
246
+ }
247
+
248
+ if (GITLAB_PENDING_MERGE_STATUSES.has(normalizedStatus)) {
249
+ return null
250
+ }
251
+
252
+ return {
253
+ ok: false,
254
+ conflict: false,
255
+ status: 409,
256
+ reason,
257
+ step: 'inspect',
258
+ mergeRequestId,
259
+ mergeRequestUrl,
260
+ payload
261
+ }
262
+ }
263
+
264
+ // 将 GitLab MR 状态转换成“是否可以开始执行 merge”的结果。
265
+ function buildGitLabMergeReadinessResult(payload, mergeRequestId, mergeRequestUrl) {
266
+ const detailedMergeStatus = typeof payload?.detailed_merge_status === 'string'
267
+ ? payload.detailed_merge_status.trim()
268
+ : ''
269
+ const mergeStatus = typeof payload?.merge_status === 'string'
270
+ ? payload.merge_status.trim()
271
+ : ''
272
+ const normalizedStatus = detailedMergeStatus || mergeStatus
273
+ const mergeError = typeof payload?.merge_error === 'string' ? payload.merge_error.trim() : ''
274
+ const state = typeof payload?.state === 'string' ? payload.state.trim() : ''
275
+ const reason = normalizeRemoteMergeReason(
276
+ payload,
277
+ mergeError || normalizedStatus || 'GitLab 未返回明确合并状态'
278
+ )
279
+
280
+ if (payload?.has_conflicts === true || GITLAB_CONFLICT_MERGE_STATUSES.has(normalizedStatus)) {
281
+ return {
282
+ ok: false,
283
+ conflict: true,
284
+ status: 409,
285
+ reason,
286
+ step: 'inspect',
287
+ mergeRequestId,
288
+ mergeRequestUrl,
289
+ payload
290
+ }
291
+ }
292
+
293
+ if (state === 'merged') {
294
+ return {
295
+ ok: true,
296
+ conflict: false,
297
+ status: 200,
298
+ reason: '远程合并已完成',
299
+ mergeRequestId,
300
+ mergeRequestUrl,
301
+ payload
302
+ }
303
+ }
304
+
305
+ if (GITLAB_ACCEPTED_MERGE_STATUSES.has(normalizedStatus)) {
306
+ return {
307
+ ok: true,
308
+ conflict: false,
309
+ status: 200,
310
+ reason: 'GitLab 已完成合并前检查',
311
+ mergeRequestId,
312
+ mergeRequestUrl,
313
+ payload
314
+ }
315
+ }
316
+
317
+ if (GITLAB_PENDING_MERGE_STATUSES.has(normalizedStatus)) {
318
+ return null
319
+ }
320
+
321
+ return {
322
+ ok: false,
323
+ conflict: false,
324
+ status: 409,
325
+ reason,
326
+ step: 'inspect',
327
+ mergeRequestId,
328
+ mergeRequestUrl,
329
+ payload
330
+ }
331
+ }
332
+
333
+ // 轮询 GitLab MR,直到“是否允许 merge”检查完成。
334
+ async function waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey) {
335
+ while (true) {
336
+ const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
337
+ if (!mergeRequest.response.ok) {
338
+ return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
339
+ step: 'inspect',
340
+ mergeRequestId,
341
+ mergeRequestUrl,
342
+ payload: {
343
+ create: null,
344
+ approve: null,
345
+ merge: null,
346
+ inspect: mergeRequest.payload
347
+ }
348
+ })
349
+ }
350
+
351
+ const result = buildGitLabMergeReadinessResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
352
+ if (result) {
353
+ return result
354
+ }
355
+
356
+ await delay(GITLAB_MERGE_STATUS_POLL_INTERVAL_MS)
357
+ }
358
+ }
359
+
360
+ // 轮询 GitLab MR,直到异步冲突检查完成并得到可归档的最终状态。
361
+ async function waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey) {
362
+ while (true) {
363
+ const mergeRequest = await fetchGitLabMergeRequest(project, mergeRequestId, apiKey)
364
+ if (!mergeRequest.response.ok) {
365
+ return buildGitLabFailedResult(mergeRequest.response, mergeRequest.rawText, mergeRequest.payload, {
366
+ step: 'inspect',
367
+ mergeRequestId,
368
+ mergeRequestUrl,
369
+ payload: {
370
+ create: null,
371
+ approve: null,
372
+ merge: null,
373
+ inspect: mergeRequest.payload
374
+ }
375
+ })
376
+ }
377
+
378
+ const result = buildGitLabMergeStatusResult(mergeRequest.payload, mergeRequestId, mergeRequestUrl)
379
+ if (result) {
380
+ return result
381
+ }
382
+
383
+ await delay(GITLAB_MERGE_STATUS_POLL_INTERVAL_MS)
384
+ }
385
+ }
386
+
387
+ // 根据创建 MR 的结果先执行审批,再提交自动合并,确保完整走完 GitLab 审批合并链路。
388
+ async function processGitLabMergeRequest(project, createPayload, apiKey) {
389
+ const mergeRequestId = createPayload?.iid ? String(createPayload.iid) : ''
390
+ const mergeRequestUrl = typeof createPayload?.web_url === 'string' ? createPayload.web_url : ''
391
+
392
+ if (!mergeRequestId) {
393
+ return {
394
+ ok: false,
395
+ conflict: false,
396
+ status: 200,
397
+ reason: 'GitLab 创建合并请求成功,但响应缺少 iid,无法继续自动合并。',
398
+ step: 'merge',
399
+ mergeRequestId,
400
+ mergeRequestUrl,
401
+ payload: {
402
+ create: createPayload,
403
+ merge: null
404
+ }
405
+ }
406
+ }
407
+
408
+ const sourceSha = typeof createPayload?.sha === 'string' ? createPayload.sha.trim() : ''
409
+ const readinessResult = await waitForGitLabMergeReadiness(project, mergeRequestId, mergeRequestUrl, apiKey)
410
+ if (!readinessResult.ok) {
411
+ return {
412
+ ...readinessResult,
413
+ payload: {
414
+ create: createPayload,
415
+ approve: null,
416
+ merge: null,
417
+ inspect: readinessResult.payload
418
+ }
419
+ }
420
+ }
421
+
422
+ const approveRequestBody = {}
423
+ if (sourceSha) {
424
+ approveRequestBody.sha = sourceSha
425
+ }
426
+
427
+ const approveEndpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}/approve`
428
+ const approveResult = await performGitLabJsonRequest(
429
+ approveEndpoint,
430
+ {
431
+ method: 'POST',
432
+ headers: {
433
+ 'Content-Type': 'application/json',
434
+ 'PRIVATE-TOKEN': apiKey
435
+ },
436
+ body: JSON.stringify(approveRequestBody)
437
+ },
438
+ 'GitLab 合并审批请求失败'
439
+ )
440
+
441
+ if (!approveResult.response.ok) {
442
+ return buildGitLabFailedResult(approveResult.response, approveResult.rawText, approveResult.payload, {
443
+ step: 'approve',
444
+ mergeRequestId,
445
+ mergeRequestUrl,
446
+ payload: {
447
+ create: createPayload,
448
+ approve: approveResult.payload,
449
+ merge: null
450
+ }
451
+ })
452
+ }
453
+
454
+ const mergeRequestBody = {
455
+ auto_merge: true
456
+ }
457
+ if (sourceSha) {
458
+ mergeRequestBody.sha = sourceSha
459
+ }
460
+
461
+ const mergeEndpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests/${mergeRequestId}/merge`
462
+ const mergeResult = await performGitLabJsonRequest(
463
+ mergeEndpoint,
464
+ {
465
+ method: 'PUT',
466
+ headers: {
467
+ 'Content-Type': 'application/json',
468
+ 'PRIVATE-TOKEN': apiKey
469
+ },
470
+ body: JSON.stringify(mergeRequestBody)
471
+ },
472
+ 'GitLab 自动合并请求失败'
473
+ )
474
+
475
+ if (!mergeResult.response.ok) {
476
+ return buildGitLabFailedResult(mergeResult.response, mergeResult.rawText, mergeResult.payload, {
477
+ step: 'merge',
478
+ mergeRequestId,
479
+ mergeRequestUrl,
480
+ payload: {
481
+ create: createPayload,
482
+ approve: approveResult.payload,
483
+ merge: mergeResult.payload
484
+ }
485
+ })
486
+ }
487
+
488
+ const finalResult = await waitForGitLabMergeOutcome(project, mergeRequestId, mergeRequestUrl, apiKey)
489
+ return {
490
+ ...finalResult,
491
+ payload: {
492
+ create: createPayload,
493
+ approve: approveResult.payload,
494
+ merge: mergeResult.payload,
495
+ inspect: finalResult.payload
496
+ }
497
+ }
498
+ }
499
+
500
+ // 按 GitLab 固定 REST API 发起 MR 创建请求,并返回统一结果结构。
501
+ export async function requestGitLabRemoteMerge(options = {}) {
502
+ const apiKey = String(options.apiKey || '').trim()
503
+ if (!apiKey) {
504
+ throw new IcodeError('远程合并缺少 apiKey', {
505
+ code: 'REMOTE_MERGE_API_KEY_EMPTY',
506
+ exitCode: 2
507
+ })
508
+ }
509
+
510
+ const project = parseGitLabProject(options.projectUrl)
511
+ const endpoint = `${project.baseUrl}/api/v4/projects/${project.projectId}/merge_requests`
512
+ const { response, rawText, payload } = await performGitLabJsonRequest(
513
+ endpoint,
514
+ {
515
+ method: 'POST',
516
+ headers: {
517
+ 'Content-Type': 'application/json',
518
+ 'PRIVATE-TOKEN': apiKey
519
+ },
520
+ body: JSON.stringify({
521
+ source_branch: options.sourceBranch,
522
+ target_branch: options.targetBranch,
523
+ title: buildGitLabMergeRequestTitle(options),
524
+ description: buildGitLabMergeRequestDescription(options)
525
+ })
526
+ },
527
+ 'GitLab 创建合并请求失败'
528
+ )
529
+
530
+ if (!response.ok) {
531
+ return buildGitLabFailedResult(response, rawText, payload, {
532
+ step: 'create'
533
+ })
534
+ }
535
+
536
+ return processGitLabMergeRequest(project, payload, apiKey)
537
+ }
@@ -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
+ }
@@ -1,15 +1,44 @@
1
1
  import { createInterface } from 'node:readline/promises'
2
2
  import { stdin, stdout } from 'node:process'
3
3
 
4
+ /**
5
+ * 判断当前 stdin/stdout 是否连接到交互式终端。
6
+ * 输入为空,输出为布尔值。
7
+ */
4
8
  function isInteractive() {
5
9
  return Boolean(stdin.isTTY && stdout.isTTY)
6
10
  }
7
11
 
12
+ /**
13
+ * 对外暴露交互终端判断,供工作流决定是否进入交互模式。
14
+ * 输入为空,输出为布尔值。
15
+ */
8
16
  export function isInteractiveTerminal() {
9
17
  return isInteractive()
10
18
  }
11
19
 
12
- export async function confirm(message, defaultValue = true) {
20
+ /**
21
+ * 归一化 confirm 的默认值配置,兼容布尔值与 options.defaultValue 两种调用方式。
22
+ * 输入为 confirm 第二参数,输出为布尔默认值。
23
+ */
24
+ function resolveConfirmDefaultValue(optionsOrDefaultValue) {
25
+ if (typeof optionsOrDefaultValue === 'boolean') {
26
+ return optionsOrDefaultValue
27
+ }
28
+
29
+ if (optionsOrDefaultValue && typeof optionsOrDefaultValue === 'object') {
30
+ return optionsOrDefaultValue.defaultValue === true
31
+ }
32
+
33
+ return false
34
+ }
35
+
36
+ /**
37
+ * 读取确认输入并返回布尔结果;非交互环境直接返回默认值。
38
+ * 输入为提示文案与默认值配置,输出为用户是否确认。
39
+ */
40
+ export async function confirm(message, optionsOrDefaultValue = false) {
41
+ const defaultValue = resolveConfirmDefaultValue(optionsOrDefaultValue)
13
42
  if (!isInteractive()) {
14
43
  return defaultValue
15
44
  }
@@ -23,17 +52,21 @@ export async function confirm(message, defaultValue = true) {
23
52
  return defaultValue
24
53
  }
25
54
 
26
- if (['y', 'yes'].includes(answer)) {
55
+ if (['y', 'yes', '1', 'true', 'ok', 'sure', '是'].includes(answer)) {
27
56
  return true
28
57
  }
29
58
 
30
- if (['n', 'no'].includes(answer)) {
59
+ if (['n', 'no', '0', 'false', '否'].includes(answer)) {
31
60
  return false
32
61
  }
33
62
 
34
63
  return defaultValue
35
64
  }
36
65
 
66
+ /**
67
+ * 读取一行文本输入;非交互环境直接返回默认值。
68
+ * 输入为提示文案与默认值,输出为归一化后的文本。
69
+ */
37
70
  export async function input(message, defaultValue = '') {
38
71
  if (!isInteractive()) {
39
72
  return defaultValue
@@ -46,6 +79,10 @@ export async function input(message, defaultValue = '') {
46
79
  return normalized || defaultValue
47
80
  }
48
81
 
82
+ /**
83
+ * 在给定选项中返回单个值;非交互环境返回默认索引对应值。
84
+ * 输入为提示文案、选项列表与默认索引,输出为选中的 value。
85
+ */
49
86
  export async function chooseOne(message, choices, defaultIndex = 0) {
50
87
  if (!Array.isArray(choices) || choices.length === 0) {
51
88
  throw new Error('chooseOne 需要至少一个可选项')
@@ -77,6 +114,10 @@ export async function chooseOne(message, choices, defaultIndex = 0) {
77
114
  return choices[numeric - 1].value
78
115
  }
79
116
 
117
+ /**
118
+ * 在给定选项中进行多选;非交互环境返回默认值集合。
119
+ * 输入为提示文案、选项列表与多选配置,输出为选中的 value 数组或 null。
120
+ */
80
121
  export async function chooseMany(message, choices, options = {}) {
81
122
  if (!Array.isArray(choices) || choices.length === 0) {
82
123
  throw new Error('chooseMany 需要至少一个可选项')
@@ -119,8 +160,8 @@ export async function chooseMany(message, choices, options = {}) {
119
160
  })
120
161
 
121
162
  const defaultChoiceValue = selected.size >= minSelections ? doneValue : choices[0].value
122
- const defaultIndex = Math.max(0, menuChoices.findIndex((item) => item.value === defaultChoiceValue))
123
- const selectedAction = await chooseOne(`${message}`, menuChoices, defaultIndex)
163
+ const resolvedDefaultIndex = Math.max(0, menuChoices.findIndex((item) => item.value === defaultChoiceValue))
164
+ const selectedAction = await chooseOne(message, menuChoices, resolvedDefaultIndex)
124
165
 
125
166
  if (selectedAction === cancelValue) {
126
167
  return null
@@ -1,8 +1,8 @@
1
- import { askAi } from '../core/ai-client.js'
2
- import { resolveAiDiffRange } from '../core/ai-diff-range.js'
1
+ import { askAi } from '../core/ai/client.js'
2
+ import { resolveAiDiffRange } from '../core/ai/diff-range.js'
3
3
  import { IcodeError } from '../core/errors.js'
4
- import { resolveGitContext } from '../core/git-context.js'
5
- import { GitService } from '../core/git-service.js'
4
+ import { resolveGitContext } from '../core/git/context.js'
5
+ import { GitService } from '../core/git/service.js'
6
6
 
7
7
  function truncate(value, limit) {
8
8
  const text = value || ''