@icode-js/icode 3.0.3 → 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.
- package/LICENSE +21 -0
- package/README.md +40 -3
- package/bin/icode.js +4 -4
- package/package.json +1 -1
- package/src/cli.js +54 -23
- package/src/commands/ai.js +4 -4
- package/src/commands/checkout.js +2 -2
- package/src/commands/clean.js +1 -1
- package/src/commands/codereview.js +1 -1
- package/src/commands/completion.js +90 -0
- package/src/commands/config.js +117 -7
- package/src/commands/explain.js +2 -2
- package/src/commands/help.js +7 -0
- package/src/commands/info.js +1 -1
- package/src/commands/migrate.js +2 -2
- package/src/commands/push.js +29 -8
- package/src/commands/sync.js +2 -2
- package/src/commands/tag.js +2 -2
- package/src/commands/undo.js +60 -9
- package/src/core/{ai-client.js → ai/client.js} +3 -3
- package/src/core/{ai-config.js → ai/config.js} +2 -2
- package/src/core/{ai-diff-range.js → ai/diff-range.js} +2 -2
- package/src/core/cli/completion.js +687 -0
- package/src/core/cli/update-notifier.js +138 -0
- package/src/core/{shell.js → commands/shell.js} +13 -1
- package/src/core/{config-store.js → config/store.js} +55 -3
- package/src/core/{git-context.js → git/context.js} +25 -10
- package/src/core/{git-service.js → git/service.js} +78 -6
- package/src/core/remote-merge/client.js +42 -0
- package/src/core/remote-merge/providers/gitlab.js +537 -0
- package/src/core/remote-merge/shared.js +49 -0
- package/src/core/{prompts.js → tools/interactive.js} +46 -5
- package/src/workflows/ai-codereview-workflow.js +4 -4
- package/src/workflows/ai-commit-workflow.js +30 -15
- package/src/workflows/ai-conflict-workflow.js +4 -4
- package/src/workflows/ai-explain-workflow.js +4 -4
- package/src/workflows/ai-risk-review-workflow.js +1 -1
- package/src/workflows/checkout-workflow.js +3 -3
- package/src/workflows/clean-workflow.js +5 -5
- package/src/workflows/info-workflow.js +4 -4
- package/src/workflows/migrate-workflow.js +4 -4
- package/src/workflows/push-workflow.js +259 -24
- package/src/workflows/rollback-workflow.js +4 -4
- package/src/workflows/sync-workflow.js +4 -4
- package/src/workflows/tag-workflow.js +4 -4
- package/src/workflows/undo-workflow.js +12 -5
- /package/src/core/{ai-commit-summary.js → ai/commit-summary.js} +0 -0
- /package/src/core/{args.js → cli/args.js} +0 -0
- /package/src/core/{commit-conventions.js → git/commit-conventions.js} +0 -0
- /package/src/core/{hook-diagnostics.js → git/hook-diagnostics.js} +0 -0
- /package/src/core/{loading.js → tools/loading.js} +0 -0
- /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
|
-
|
|
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
|
|
123
|
-
const selectedAction = await chooseOne(
|
|
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
|
|
2
|
-
import { resolveAiDiffRange } from '../core/ai
|
|
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
|
|
5
|
-
import { GitService } from '../core/git
|
|
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 || ''
|