@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.
- package/LICENSE +21 -0
- package/README.md +142 -5
- package/bin/icode.js +4 -4
- package/package.json +1 -1
- package/src/cli.js +60 -23
- package/src/commands/ai.js +4 -4
- package/src/commands/checkout.js +2 -2
- package/src/commands/clean.js +5 -2
- package/src/commands/codereview.js +5 -2
- package/src/commands/completion.js +93 -0
- package/src/commands/config.js +123 -10
- package/src/commands/doctor.js +80 -0
- package/src/commands/explain.js +6 -3
- package/src/commands/help.js +17 -0
- package/src/commands/info.js +5 -2
- package/src/commands/migrate.js +40 -3
- package/src/commands/push.js +72 -24
- package/src/commands/ready.js +110 -0
- package/src/commands/save.js +98 -0
- package/src/commands/sync.js +2 -2
- package/src/commands/tag.js +2 -2
- package/src/commands/undo.js +73 -10
- 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-prompt.js +204 -0
- package/src/core/{ai-diff-range.js → ai/diff-range.js} +2 -2
- package/src/core/{args.js → cli/args.js} +23 -2
- package/src/core/cli/completion.js +720 -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} +53 -3
- package/src/core/{git-context.js → git/context.js} +24 -10
- package/src/core/git/preconditions.js +88 -0
- package/src/core/{git-service.js → git/service.js} +195 -5
- package/src/core/remote-merge/client.js +42 -0
- package/src/core/remote-merge/providers/gitlab.js +596 -0
- package/src/core/remote-merge/shared.js +49 -0
- package/src/core/tools/interactive.js +368 -0
- package/src/core/{loading.js → tools/loading.js} +5 -1
- package/src/core/{logger.js → tools/logger.js} +5 -0
- package/src/workflows/ai-codereview-workflow.js +18 -6
- package/src/workflows/ai-commit-workflow.js +20 -12
- 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 +17 -5
- package/src/workflows/clean-workflow.js +92 -75
- package/src/workflows/doctor-workflow.js +131 -0
- package/src/workflows/info-workflow.js +4 -4
- package/src/workflows/migrate-workflow.js +269 -62
- package/src/workflows/push-workflow.js +475 -58
- package/src/workflows/ready-workflow.js +278 -0
- package/src/workflows/rollback-workflow.js +24 -9
- package/src/workflows/save-workflow.js +152 -0
- package/src/workflows/sync-workflow.js +22 -6
- package/src/workflows/tag-workflow.js +4 -4
- package/src/workflows/undo-workflow.js +37 -12
- package/src/core/prompts.js +0 -152
- /package/src/core/{ai-commit-summary.js → ai/commit-summary.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
|
@@ -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
|
+
}
|