@icode-js/icode 3.0.2
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/README.md +346 -0
- package/bin/icode.js +6 -0
- package/package.json +34 -0
- package/src/cli.js +131 -0
- package/src/commands/ai.js +287 -0
- package/src/commands/checkout.js +59 -0
- package/src/commands/clean.js +65 -0
- package/src/commands/codereview.js +52 -0
- package/src/commands/config.js +513 -0
- package/src/commands/explain.js +80 -0
- package/src/commands/help.js +49 -0
- package/src/commands/info.js +57 -0
- package/src/commands/migrate.js +86 -0
- package/src/commands/push.js +125 -0
- package/src/commands/sync.js +74 -0
- package/src/commands/tag.js +53 -0
- package/src/commands/undo.js +66 -0
- package/src/core/ai-client.js +1125 -0
- package/src/core/ai-commit-summary.js +18 -0
- package/src/core/ai-config.js +342 -0
- package/src/core/ai-diff-range.js +117 -0
- package/src/core/args.js +47 -0
- package/src/core/commit-conventions.js +169 -0
- package/src/core/config-store.js +194 -0
- package/src/core/errors.js +25 -0
- package/src/core/git-context.js +105 -0
- package/src/core/git-service.js +428 -0
- package/src/core/hook-diagnostics.js +23 -0
- package/src/core/loading.js +36 -0
- package/src/core/logger.js +55 -0
- package/src/core/prompts.js +152 -0
- package/src/core/shell.js +77 -0
- package/src/workflows/ai-codereview-workflow.js +126 -0
- package/src/workflows/ai-commit-workflow.js +128 -0
- package/src/workflows/ai-conflict-workflow.js +102 -0
- package/src/workflows/ai-explain-workflow.js +116 -0
- package/src/workflows/ai-risk-review-workflow.js +49 -0
- package/src/workflows/checkout-workflow.js +85 -0
- package/src/workflows/clean-workflow.js +131 -0
- package/src/workflows/info-workflow.js +30 -0
- package/src/workflows/migrate-workflow.js +449 -0
- package/src/workflows/push-workflow.js +276 -0
- package/src/workflows/rollback-workflow.js +84 -0
- package/src/workflows/sync-workflow.js +141 -0
- package/src/workflows/tag-workflow.js +64 -0
- package/src/workflows/undo-workflow.js +328 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format an AI commit result so the commit id is kept on the first line and
|
|
3
|
+
* multiline body content is preserved for later lines.
|
|
4
|
+
*/
|
|
5
|
+
export function formatAiCommitSummary(commitId, commitMessage) {
|
|
6
|
+
const normalizedMessage = String(commitMessage || '').trim()
|
|
7
|
+
|
|
8
|
+
if (!normalizedMessage) {
|
|
9
|
+
return commitId ? String(commitId).trim() : '无标题'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!commitId) {
|
|
13
|
+
return normalizedMessage
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [header, ...restLines] = normalizedMessage.split('\n')
|
|
17
|
+
return [`${String(commitId).trim()} ${header}`, ...restLines].join('\n').trim()
|
|
18
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { IcodeError } from './errors.js'
|
|
2
|
+
import { readConfig, writeConfig } from './config-store.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PROFILE = {
|
|
5
|
+
provider: 'custom',
|
|
6
|
+
format: 'openai',
|
|
7
|
+
baseUrl: '',
|
|
8
|
+
apiKey: '',
|
|
9
|
+
model: '',
|
|
10
|
+
temperature: 0.2,
|
|
11
|
+
maxTokens: 1200,
|
|
12
|
+
headers: {},
|
|
13
|
+
requestBody: {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ALLOWED_OPTION_SCOPES = new Set(['commit', 'conflict', 'explain', 'push'])
|
|
17
|
+
|
|
18
|
+
function clone(value) {
|
|
19
|
+
return JSON.parse(JSON.stringify(value))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeFormat(format) {
|
|
23
|
+
const normalized = (format || 'openai').trim().toLowerCase()
|
|
24
|
+
if (!['openai', 'anthropic', 'ollama'].includes(normalized)) {
|
|
25
|
+
throw new IcodeError(`不支持的 AI 接口格式: ${format}`, {
|
|
26
|
+
code: 'AI_FORMAT_INVALID',
|
|
27
|
+
exitCode: 2
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
return normalized
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureAiSection(config) {
|
|
34
|
+
if (!config.ai || typeof config.ai !== 'object') {
|
|
35
|
+
config.ai = {
|
|
36
|
+
activeProfile: '',
|
|
37
|
+
profiles: {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!config.ai.profiles || typeof config.ai.profiles !== 'object') {
|
|
42
|
+
config.ai.profiles = {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!config.ai.options || typeof config.ai.options !== 'object' || Array.isArray(config.ai.options)) {
|
|
46
|
+
config.ai.options = {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof config.ai.activeProfile !== 'string') {
|
|
50
|
+
config.ai.activeProfile = ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return config
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeProfile(profile = {}) {
|
|
57
|
+
const next = {
|
|
58
|
+
...clone(DEFAULT_PROFILE),
|
|
59
|
+
...profile,
|
|
60
|
+
provider: (profile.provider || DEFAULT_PROFILE.provider).trim(),
|
|
61
|
+
format: normalizeFormat(profile.format || DEFAULT_PROFILE.format),
|
|
62
|
+
baseUrl: (profile.baseUrl || '').trim(),
|
|
63
|
+
apiKey: (profile.apiKey || '').trim(),
|
|
64
|
+
model: (profile.model || '').trim(),
|
|
65
|
+
temperature: Number.isFinite(Number(profile.temperature)) ? Number(profile.temperature) : DEFAULT_PROFILE.temperature,
|
|
66
|
+
maxTokens: Number.isFinite(Number(profile.maxTokens)) ? Number(profile.maxTokens) : DEFAULT_PROFILE.maxTokens,
|
|
67
|
+
headers: profile.headers && typeof profile.headers === 'object' ? profile.headers : {},
|
|
68
|
+
requestBody: profile.requestBody && typeof profile.requestBody === 'object' && !Array.isArray(profile.requestBody) ? profile.requestBody : {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return next
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveActiveProfileName(aiConfig) {
|
|
75
|
+
const active = (aiConfig.activeProfile || '').trim()
|
|
76
|
+
if (active && aiConfig.profiles[active]) {
|
|
77
|
+
return active
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const names = Object.keys(aiConfig.profiles)
|
|
81
|
+
return names[0] || ''
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function maskKey(value) {
|
|
85
|
+
const raw = (value || '').trim()
|
|
86
|
+
if (!raw) {
|
|
87
|
+
return ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (raw.length <= 8) {
|
|
91
|
+
return `${raw.slice(0, 2)}****${raw.slice(-1)}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return `${raw.slice(0, 4)}****${raw.slice(-4)}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getAiConfig() {
|
|
98
|
+
const config = ensureAiSection(readConfig())
|
|
99
|
+
const activeProfile = resolveActiveProfileName(config.ai)
|
|
100
|
+
const sanitizedOptions = sanitizeAiCommandOptions(config.ai.options)
|
|
101
|
+
const activeProfileChanged = activeProfile && config.ai.activeProfile !== activeProfile
|
|
102
|
+
const optionsChanged = JSON.stringify(config.ai.options || {}) !== JSON.stringify(sanitizedOptions)
|
|
103
|
+
|
|
104
|
+
if (activeProfileChanged) {
|
|
105
|
+
config.ai.activeProfile = activeProfile
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (optionsChanged) {
|
|
109
|
+
config.ai.options = sanitizedOptions
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (activeProfileChanged || optionsChanged) {
|
|
113
|
+
writeConfig(config)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return config.ai
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sanitizeAiCommandOptions(optionsMap) {
|
|
120
|
+
const source = optionsMap && typeof optionsMap === 'object' && !Array.isArray(optionsMap) ? optionsMap : {}
|
|
121
|
+
const next = {}
|
|
122
|
+
|
|
123
|
+
Object.entries(source).forEach(([scope, value]) => {
|
|
124
|
+
if (!ALLOWED_OPTION_SCOPES.has(scope)) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
next[scope] = clone(value)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return next
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeOptionScope(scopeName) {
|
|
137
|
+
const scope = (scopeName || '').trim().toLowerCase()
|
|
138
|
+
if (!scope) {
|
|
139
|
+
throw new IcodeError('缺少 options 作用域。可选: commit|conflict|explain|push', {
|
|
140
|
+
code: 'AI_OPTIONS_SCOPE_EMPTY',
|
|
141
|
+
exitCode: 2
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!ALLOWED_OPTION_SCOPES.has(scope)) {
|
|
146
|
+
throw new IcodeError(`不支持的 options 作用域: ${scopeName}`, {
|
|
147
|
+
code: 'AI_OPTIONS_SCOPE_INVALID',
|
|
148
|
+
exitCode: 2
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return scope
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeOptionsPayload(payload) {
|
|
156
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
157
|
+
throw new IcodeError('options 内容必须是 JSON 对象', {
|
|
158
|
+
code: 'AI_OPTIONS_PAYLOAD_INVALID',
|
|
159
|
+
exitCode: 2
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return payload
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function listAiProfiles() {
|
|
167
|
+
const aiConfig = getAiConfig()
|
|
168
|
+
|
|
169
|
+
return Object.entries(aiConfig.profiles).map(([name, profile]) => {
|
|
170
|
+
const normalized = normalizeProfile(profile)
|
|
171
|
+
const { apiKey, ...rest } = normalized
|
|
172
|
+
return {
|
|
173
|
+
name,
|
|
174
|
+
...rest,
|
|
175
|
+
hasApiKey: Boolean(apiKey),
|
|
176
|
+
apiKeyMasked: maskKey(apiKey),
|
|
177
|
+
isActive: aiConfig.activeProfile === name
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function listAiCommandOptions() {
|
|
183
|
+
const aiConfig = getAiConfig()
|
|
184
|
+
return sanitizeAiCommandOptions(aiConfig.options)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function getAiCommandOptions(scopeName = '') {
|
|
188
|
+
const aiConfig = getAiConfig()
|
|
189
|
+
if (!scopeName) {
|
|
190
|
+
return sanitizeAiCommandOptions(aiConfig.options)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const scope = normalizeOptionScope(scopeName)
|
|
194
|
+
const scoped = aiConfig.options?.[scope]
|
|
195
|
+
if (!scoped || typeof scoped !== 'object' || Array.isArray(scoped)) {
|
|
196
|
+
return {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return clone(scoped)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function upsertAiCommandOptions(scopeName, payload, options = {}) {
|
|
203
|
+
const scope = normalizeOptionScope(scopeName)
|
|
204
|
+
const normalizedPayload = normalizeOptionsPayload(payload)
|
|
205
|
+
const replace = options.replace === true
|
|
206
|
+
|
|
207
|
+
const config = ensureAiSection(readConfig())
|
|
208
|
+
const current = config.ai.options?.[scope]
|
|
209
|
+
const currentObject = current && typeof current === 'object' && !Array.isArray(current) ? current : {}
|
|
210
|
+
|
|
211
|
+
config.ai.options[scope] = replace
|
|
212
|
+
? clone(normalizedPayload)
|
|
213
|
+
: {
|
|
214
|
+
...currentObject,
|
|
215
|
+
...clone(normalizedPayload)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
writeConfig(config)
|
|
219
|
+
return clone(config.ai.options[scope])
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function removeAiCommandOptions(scopeName) {
|
|
223
|
+
const scope = normalizeOptionScope(scopeName)
|
|
224
|
+
const config = ensureAiSection(readConfig())
|
|
225
|
+
delete config.ai.options[scope]
|
|
226
|
+
writeConfig(config)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function getAiProfile(profileName = '') {
|
|
230
|
+
const aiConfig = getAiConfig()
|
|
231
|
+
const name = (profileName || aiConfig.activeProfile || '').trim()
|
|
232
|
+
|
|
233
|
+
if (!name) {
|
|
234
|
+
throw new IcodeError('未配置 AI profile。请先执行: icode config ai set <name> ...', {
|
|
235
|
+
code: 'AI_PROFILE_EMPTY',
|
|
236
|
+
exitCode: 2
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rawProfile = aiConfig.profiles[name]
|
|
241
|
+
if (!rawProfile) {
|
|
242
|
+
throw new IcodeError(`AI profile 不存在: ${name}`, {
|
|
243
|
+
code: 'AI_PROFILE_MISSING',
|
|
244
|
+
exitCode: 2
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
name,
|
|
250
|
+
...normalizeProfile(rawProfile)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function upsertAiProfile(profileName, partialProfile) {
|
|
255
|
+
const name = (profileName || '').trim()
|
|
256
|
+
if (!name) {
|
|
257
|
+
throw new IcodeError('profile 名称不能为空', {
|
|
258
|
+
code: 'AI_PROFILE_NAME_EMPTY',
|
|
259
|
+
exitCode: 2
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const config = ensureAiSection(readConfig())
|
|
264
|
+
const current = config.ai.profiles[name] || {}
|
|
265
|
+
const nextProfile = normalizeProfile({
|
|
266
|
+
...current,
|
|
267
|
+
...partialProfile
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
config.ai.profiles[name] = nextProfile
|
|
271
|
+
if (!config.ai.activeProfile) {
|
|
272
|
+
config.ai.activeProfile = name
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
writeConfig(config)
|
|
276
|
+
return {
|
|
277
|
+
name,
|
|
278
|
+
...nextProfile
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function removeAiProfile(profileName) {
|
|
283
|
+
const name = (profileName || '').trim()
|
|
284
|
+
if (!name) {
|
|
285
|
+
throw new IcodeError('profile 名称不能为空', {
|
|
286
|
+
code: 'AI_PROFILE_NAME_EMPTY',
|
|
287
|
+
exitCode: 2
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const config = ensureAiSection(readConfig())
|
|
292
|
+
if (!config.ai.profiles[name]) {
|
|
293
|
+
throw new IcodeError(`AI profile 不存在: ${name}`, {
|
|
294
|
+
code: 'AI_PROFILE_MISSING',
|
|
295
|
+
exitCode: 2
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
delete config.ai.profiles[name]
|
|
300
|
+
|
|
301
|
+
if (config.ai.activeProfile === name) {
|
|
302
|
+
config.ai.activeProfile = Object.keys(config.ai.profiles)[0] || ''
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
writeConfig(config)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function useAiProfile(profileName) {
|
|
309
|
+
const name = (profileName || '').trim()
|
|
310
|
+
if (!name) {
|
|
311
|
+
throw new IcodeError('profile 名称不能为空', {
|
|
312
|
+
code: 'AI_PROFILE_NAME_EMPTY',
|
|
313
|
+
exitCode: 2
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const config = ensureAiSection(readConfig())
|
|
318
|
+
if (!config.ai.profiles[name]) {
|
|
319
|
+
throw new IcodeError(`AI profile 不存在: ${name}`, {
|
|
320
|
+
code: 'AI_PROFILE_MISSING',
|
|
321
|
+
exitCode: 2
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
config.ai.activeProfile = name
|
|
326
|
+
writeConfig(config)
|
|
327
|
+
return getAiProfile(name)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function getAiProfileForDisplay(profileName = '') {
|
|
331
|
+
const profile = getAiProfile(profileName)
|
|
332
|
+
const { apiKey, ...rest } = profile
|
|
333
|
+
return {
|
|
334
|
+
...rest,
|
|
335
|
+
hasApiKey: Boolean(apiKey),
|
|
336
|
+
apiKeyMasked: maskKey(apiKey)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function maskApiKey(value) {
|
|
341
|
+
return maskKey(value)
|
|
342
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { IcodeError } from './errors.js'
|
|
2
|
+
import { logger } from './logger.js'
|
|
3
|
+
|
|
4
|
+
function unique(values) {
|
|
5
|
+
return [...new Set(values.filter(Boolean))]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function readCommandErrorOutput(error) {
|
|
9
|
+
return `${error?.meta?.stderr || ''}\n${error?.meta?.stdout || ''}\n${error?.message || ''}`.trim()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function summarizeGitError(error) {
|
|
13
|
+
const text = readCommandErrorOutput(error)
|
|
14
|
+
if (!text) {
|
|
15
|
+
return ''
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const lines = text
|
|
19
|
+
.split('\n')
|
|
20
|
+
.map((item) => item.trim())
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
|
|
23
|
+
return lines[0] || ''
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function resolveAiDiffRange({ git, context, baseRef = '', headRef = 'HEAD', label = 'AI', explicitHead = false }) {
|
|
27
|
+
const explicitBase = Boolean((baseRef || '').trim())
|
|
28
|
+
const remoteDefaultBase = `origin/${context.defaultBranch}`
|
|
29
|
+
const localDefaultBase = context.defaultBranch || ''
|
|
30
|
+
const candidates = explicitBase
|
|
31
|
+
? [(baseRef || '').trim()]
|
|
32
|
+
: unique([remoteDefaultBase, localDefaultBase])
|
|
33
|
+
|
|
34
|
+
if (explicitHead) {
|
|
35
|
+
const headResult = await git.exec(['rev-parse', '--verify', `${headRef}^{commit}`], {
|
|
36
|
+
allowFailure: true
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
if (headResult.exitCode !== 0) {
|
|
40
|
+
const reason = summarizeGitError({
|
|
41
|
+
meta: {
|
|
42
|
+
stderr: headResult.stderr || '',
|
|
43
|
+
stdout: headResult.stdout || ''
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
throw new IcodeError(`指定终点不可用: ${headRef}${reason ? ` (${reason})` : ''}`, {
|
|
48
|
+
code: 'AI_DIFF_HEAD_INVALID',
|
|
49
|
+
exitCode: 2,
|
|
50
|
+
meta: {
|
|
51
|
+
headRef,
|
|
52
|
+
reason,
|
|
53
|
+
stderr: headResult.stderr || '',
|
|
54
|
+
stdout: headResult.stdout || ''
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let lastError = null
|
|
61
|
+
|
|
62
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
63
|
+
const candidateBase = candidates[index]
|
|
64
|
+
try {
|
|
65
|
+
const rangeResult = await git.diffBetween(candidateBase, headRef, { style: 'three-dot' })
|
|
66
|
+
|
|
67
|
+
if (!explicitBase && index > 0) {
|
|
68
|
+
logger.warn(`${label} 默认基线 ${remoteDefaultBase} 不可用,已回退到本地分支 ${candidateBase}。`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
...rangeResult,
|
|
73
|
+
baseRef: candidateBase,
|
|
74
|
+
explicitBase
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
lastError = error
|
|
78
|
+
if (explicitBase) {
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const reason = summarizeGitError(lastError)
|
|
85
|
+
|
|
86
|
+
if (explicitBase) {
|
|
87
|
+
throw new IcodeError(`指定基线不可用: ${baseRef}${reason ? ` (${reason})` : ''}`, {
|
|
88
|
+
code: 'AI_DIFF_BASE_INVALID',
|
|
89
|
+
exitCode: 2,
|
|
90
|
+
cause: lastError,
|
|
91
|
+
meta: {
|
|
92
|
+
baseRef,
|
|
93
|
+
headRef,
|
|
94
|
+
reason,
|
|
95
|
+
stderr: lastError?.meta?.stderr || '',
|
|
96
|
+
stdout: lastError?.meta?.stdout || ''
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new IcodeError(
|
|
102
|
+
`默认基线 ${remoteDefaultBase} 不可用,且无法回退到本地分支 ${localDefaultBase || '(empty)'}${reason ? ` (${reason})` : ''}`,
|
|
103
|
+
{
|
|
104
|
+
code: 'AI_DIFF_BASE_UNAVAILABLE',
|
|
105
|
+
exitCode: 2,
|
|
106
|
+
cause: lastError,
|
|
107
|
+
meta: {
|
|
108
|
+
baseRef: remoteDefaultBase,
|
|
109
|
+
fallbackBase: localDefaultBase,
|
|
110
|
+
headRef,
|
|
111
|
+
reason,
|
|
112
|
+
stderr: lastError?.meta?.stderr || '',
|
|
113
|
+
stdout: lastError?.meta?.stdout || ''
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
}
|
package/src/core/args.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const LEGACY_ALIAS_MAP = new Map([
|
|
2
|
+
['-pm', '--pull-main'],
|
|
3
|
+
['--pullMainBranch', '--pull-main'],
|
|
4
|
+
['--pushOrigin', '--push-origin'],
|
|
5
|
+
['--notPushCurrent', '--not-push-current'],
|
|
6
|
+
['--repoMode', '--repo-mode'],
|
|
7
|
+
['--noVerify', '--no-verify'],
|
|
8
|
+
['--allLocal', '--all-local'],
|
|
9
|
+
['--mergeMain', '--merge-main'],
|
|
10
|
+
['--aiReview', '--ai-review'],
|
|
11
|
+
['--aiProfile', '--ai-profile'],
|
|
12
|
+
['--aiCommit', '--ai-commit']
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
export function normalizeLegacyArgs(argv = []) {
|
|
16
|
+
return argv.map((arg) => LEGACY_ALIAS_MAP.get(arg) || arg)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseConfigValue(raw) {
|
|
20
|
+
if (raw === 'true') {
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
if (raw === 'false') {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
if (raw === 'null') {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
if (raw === 'undefined') {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const numberValue = Number(raw)
|
|
34
|
+
if (Number.isFinite(numberValue) && raw.trim() !== '') {
|
|
35
|
+
return numberValue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(raw)
|
|
41
|
+
} catch {
|
|
42
|
+
return raw
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return raw
|
|
47
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const RELEVANT_HOOK_NAMES = ['commit-msg', 'prepare-commit-msg', 'pre-commit']
|
|
5
|
+
const RELEVANT_CONFIG_FILES = [
|
|
6
|
+
'commitlint.config.js',
|
|
7
|
+
'commitlint.config.cjs',
|
|
8
|
+
'commitlint.config.mjs',
|
|
9
|
+
'commitlint.config.ts',
|
|
10
|
+
'.commitlintrc',
|
|
11
|
+
'.commitlintrc.json',
|
|
12
|
+
'.commitlintrc.js',
|
|
13
|
+
'.commitlintrc.cjs',
|
|
14
|
+
'.commitlintrc.mjs',
|
|
15
|
+
'.commitlintrc.yaml',
|
|
16
|
+
'.commitlintrc.yml'
|
|
17
|
+
]
|
|
18
|
+
const MAX_FILE_SNIPPET = 1200
|
|
19
|
+
const MAX_PACKAGE_SNIPPET = 1600
|
|
20
|
+
const MAX_SUMMARY_LENGTH = 4000
|
|
21
|
+
|
|
22
|
+
function unique(values) {
|
|
23
|
+
return [...new Set(values)]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fileExists(filePath) {
|
|
27
|
+
try {
|
|
28
|
+
return fs.statSync(filePath).isFile()
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readTextSnippet(filePath, limit) {
|
|
35
|
+
const text = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n').trim()
|
|
36
|
+
if (!text) {
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (text.length <= limit) {
|
|
41
|
+
return text
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return `${text.slice(0, limit)}\n...<truncated>`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function collectHookFiles(context) {
|
|
48
|
+
const candidateDirs = []
|
|
49
|
+
|
|
50
|
+
if (context.hasHuskyFolder) {
|
|
51
|
+
candidateDirs.push(path.resolve(context.topLevelPath, '.husky'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (context.hasHookPath) {
|
|
55
|
+
candidateDirs.push(context.hookPath)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return unique(candidateDirs)
|
|
59
|
+
.flatMap((dirPath) => RELEVANT_HOOK_NAMES.map((fileName) => path.resolve(dirPath, fileName)))
|
|
60
|
+
.filter(fileExists)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function collectConfigFiles(topLevelPath) {
|
|
64
|
+
return RELEVANT_CONFIG_FILES
|
|
65
|
+
.map((fileName) => path.resolve(topLevelPath, fileName))
|
|
66
|
+
.filter(fileExists)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractPackageJsonSnippet(topLevelPath) {
|
|
70
|
+
const packageJsonPath = path.resolve(topLevelPath, 'package.json')
|
|
71
|
+
if (!fileExists(packageJsonPath)) {
|
|
72
|
+
return ''
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
|
77
|
+
const relevant = {}
|
|
78
|
+
const scripts = Object.entries(pkg.scripts || {})
|
|
79
|
+
.filter(([name]) => /commit|lint|husky/i.test(name))
|
|
80
|
+
.slice(0, 8)
|
|
81
|
+
|
|
82
|
+
if (scripts.length) {
|
|
83
|
+
relevant.scripts = Object.fromEntries(scripts)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (pkg.commitlint && typeof pkg.commitlint === 'object') {
|
|
87
|
+
relevant.commitlint = pkg.commitlint
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (pkg.husky && typeof pkg.husky === 'object') {
|
|
91
|
+
relevant.husky = pkg.husky
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (pkg['lint-staged'] && typeof pkg['lint-staged'] === 'object') {
|
|
95
|
+
relevant['lint-staged'] = pkg['lint-staged']
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (pkg.config?.commitizen && typeof pkg.config.commitizen === 'object') {
|
|
99
|
+
relevant.commitizen = pkg.config.commitizen
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!Object.keys(relevant).length) {
|
|
103
|
+
return ''
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const text = JSON.stringify(relevant, null, 2)
|
|
107
|
+
if (text.length <= MAX_PACKAGE_SNIPPET) {
|
|
108
|
+
return text
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return `${text.slice(0, MAX_PACKAGE_SNIPPET)}\n...<truncated>`
|
|
112
|
+
} catch {
|
|
113
|
+
return ''
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildFileSection(topLevelPath, filePath, limit = MAX_FILE_SNIPPET) {
|
|
118
|
+
const snippet = readTextSnippet(filePath, limit)
|
|
119
|
+
if (!snippet) {
|
|
120
|
+
return ''
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return `[${path.relative(topLevelPath, filePath) || path.basename(filePath)}]\n${snippet}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function truncateSummary(text) {
|
|
127
|
+
if (text.length <= MAX_SUMMARY_LENGTH) {
|
|
128
|
+
return text
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return `${text.slice(0, MAX_SUMMARY_LENGTH)}\n\n...<truncated>`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function scanCommitConventions(context) {
|
|
135
|
+
const hookFiles = collectHookFiles(context)
|
|
136
|
+
const hookSections = hookFiles
|
|
137
|
+
.map((filePath) => buildFileSection(context.topLevelPath, filePath))
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
|
|
140
|
+
const configFiles = collectConfigFiles(context.topLevelPath)
|
|
141
|
+
const configSections = configFiles
|
|
142
|
+
.map((filePath) => buildFileSection(context.topLevelPath, filePath))
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
|
|
145
|
+
const packageJsonSnippet = extractPackageJsonSnippet(context.topLevelPath)
|
|
146
|
+
const sections = []
|
|
147
|
+
|
|
148
|
+
if (hookSections.length) {
|
|
149
|
+
sections.push(`Relevant Git hooks:\n${hookSections.join('\n\n')}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (configSections.length) {
|
|
153
|
+
sections.push(`Relevant commit config files:\n${configSections.join('\n\n')}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (packageJsonSnippet) {
|
|
157
|
+
sections.push(`Relevant package.json fields:\n[package.json]\n${packageJsonSnippet}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
hasConventions: sections.length > 0,
|
|
162
|
+
summary: truncateSummary(sections.join('\n\n')),
|
|
163
|
+
sources: [
|
|
164
|
+
...hookFiles.map((filePath) => path.relative(context.topLevelPath, filePath) || path.basename(filePath)),
|
|
165
|
+
...configFiles.map((filePath) => path.relative(context.topLevelPath, filePath) || path.basename(filePath)),
|
|
166
|
+
...(packageJsonSnippet ? ['package.json'] : [])
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
}
|