@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,77 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { IcodeError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
function stringifyCommand(command, args) {
|
|
5
|
+
return [command, ...args].join(' ')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function formatFailureMessage(command, args, result) {
|
|
9
|
+
const baseMessage = `命令执行失败(${result.exitCode}): ${stringifyCommand(command, args)}`
|
|
10
|
+
const detailLines = [result.stderr, result.stdout].map((item) => item.trim()).filter(Boolean)
|
|
11
|
+
|
|
12
|
+
if (!detailLines.length) {
|
|
13
|
+
return baseMessage
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return `${baseMessage}\n${detailLines.join('\n')}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runCommand(command, args = [], options = {}) {
|
|
20
|
+
const { cwd = process.cwd(), env = process.env, allowFailure = false } = options
|
|
21
|
+
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const child = spawn(command, args, {
|
|
24
|
+
cwd,
|
|
25
|
+
env,
|
|
26
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
let stdout = ''
|
|
30
|
+
let stderr = ''
|
|
31
|
+
|
|
32
|
+
child.stdout.on('data', (chunk) => {
|
|
33
|
+
stdout += chunk.toString()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
child.stderr.on('data', (chunk) => {
|
|
37
|
+
stderr += chunk.toString()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
child.on('error', (error) => {
|
|
41
|
+
reject(
|
|
42
|
+
new IcodeError(`执行命令失败: ${stringifyCommand(command, args)}`, {
|
|
43
|
+
cause: error,
|
|
44
|
+
code: 'COMMAND_SPAWN_ERROR',
|
|
45
|
+
meta: {
|
|
46
|
+
command,
|
|
47
|
+
args,
|
|
48
|
+
cwd
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
child.on('close', (exitCode) => {
|
|
55
|
+
const result = {
|
|
56
|
+
command,
|
|
57
|
+
args,
|
|
58
|
+
cwd,
|
|
59
|
+
exitCode,
|
|
60
|
+
stdout,
|
|
61
|
+
stderr
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (exitCode === 0 || allowFailure) {
|
|
65
|
+
resolve(result)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
reject(
|
|
70
|
+
new IcodeError(formatFailureMessage(command, args, result), {
|
|
71
|
+
code: 'COMMAND_EXEC_ERROR',
|
|
72
|
+
meta: result
|
|
73
|
+
})
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { askAi } from '../core/ai-client.js'
|
|
2
|
+
import { resolveAiDiffRange } from '../core/ai-diff-range.js'
|
|
3
|
+
import { IcodeError } from '../core/errors.js'
|
|
4
|
+
import { resolveGitContext } from '../core/git-context.js'
|
|
5
|
+
import { GitService } from '../core/git-service.js'
|
|
6
|
+
|
|
7
|
+
function truncate(value, limit) {
|
|
8
|
+
const text = value || ''
|
|
9
|
+
if (text.length <= limit) {
|
|
10
|
+
return text
|
|
11
|
+
}
|
|
12
|
+
return `${text.slice(0, limit)}\n\n...<truncated>`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function joinSections(sections) {
|
|
16
|
+
return sections
|
|
17
|
+
.map((item) => (item || '').trim())
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.join('\n\n')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runAiCodeReviewWorkflow(options) {
|
|
23
|
+
const context = await resolveGitContext({
|
|
24
|
+
cwd: options.cwd,
|
|
25
|
+
repoMode: options.repoMode
|
|
26
|
+
})
|
|
27
|
+
const git = new GitService(context)
|
|
28
|
+
|
|
29
|
+
const explicitBase = Boolean((options.baseRef || '').trim())
|
|
30
|
+
const explicitHead = Boolean((options.headRef || '').trim())
|
|
31
|
+
const useRangeMode = explicitBase || explicitHead
|
|
32
|
+
const headRef = options.headRef || 'HEAD'
|
|
33
|
+
|
|
34
|
+
let rangeSpec = ''
|
|
35
|
+
let diff = ''
|
|
36
|
+
let stat = ''
|
|
37
|
+
let nameStatus = ''
|
|
38
|
+
let diffSource = 'three-dot-range'
|
|
39
|
+
let rangeError = null
|
|
40
|
+
|
|
41
|
+
if (useRangeMode) {
|
|
42
|
+
try {
|
|
43
|
+
const rangeResult = await resolveAiDiffRange({
|
|
44
|
+
git,
|
|
45
|
+
context,
|
|
46
|
+
baseRef: options.baseRef,
|
|
47
|
+
headRef,
|
|
48
|
+
explicitHead,
|
|
49
|
+
label: 'Code Review'
|
|
50
|
+
})
|
|
51
|
+
rangeSpec = rangeResult.rangeSpec
|
|
52
|
+
diff = rangeResult.diff
|
|
53
|
+
} catch (error) {
|
|
54
|
+
rangeError = error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (diff.trim()) {
|
|
59
|
+
stat = await git.diffStat(rangeSpec)
|
|
60
|
+
nameStatus = await git.diffNameStatus(rangeSpec)
|
|
61
|
+
} else {
|
|
62
|
+
if (rangeError && useRangeMode) {
|
|
63
|
+
throw rangeError
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 默认回退策略: 当范围 diff 为空时,自动审查“未提交代码(暂存 + 工作区)”。
|
|
67
|
+
const stagedDiff = await git.diffStaged()
|
|
68
|
+
const workingDiff = await git.diffWorkingTree()
|
|
69
|
+
|
|
70
|
+
if (rangeError && !stagedDiff.trim() && !workingDiff.trim()) {
|
|
71
|
+
throw rangeError
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!stagedDiff.trim() && !workingDiff.trim()) {
|
|
75
|
+
if (!useRangeMode) {
|
|
76
|
+
throw new IcodeError('暂存区/工作区没有代码改动。若要评审分支差异,请显式传入 --base 或 --head。', {
|
|
77
|
+
code: 'AI_CODEREVIEW_EMPTY_DIFF',
|
|
78
|
+
exitCode: 2
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new IcodeError(`范围 ${rangeSpec} 内没有代码差异,且暂存区/工作区也没有改动。`, {
|
|
83
|
+
code: 'AI_CODEREVIEW_EMPTY_DIFF',
|
|
84
|
+
exitCode: 2
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
diffSource = 'uncommitted'
|
|
89
|
+
rangeSpec = 'uncommitted(staged+working-tree)'
|
|
90
|
+
diff = joinSections([
|
|
91
|
+
stagedDiff ? `--- STAGED DIFF ---\n${stagedDiff}` : '',
|
|
92
|
+
workingDiff ? `--- WORKING TREE DIFF ---\n${workingDiff}` : ''
|
|
93
|
+
])
|
|
94
|
+
stat = joinSections([
|
|
95
|
+
stagedDiff ? `--- STAGED STAT ---\n${await git.diffStagedStat()}` : '',
|
|
96
|
+
workingDiff ? `--- WORKING TREE STAT ---\n${await git.diffStat()}` : ''
|
|
97
|
+
])
|
|
98
|
+
nameStatus = joinSections([
|
|
99
|
+
stagedDiff ? `--- STAGED NAME STATUS ---\n${await git.diffStagedNameStatus()}` : '',
|
|
100
|
+
workingDiff ? `--- WORKING TREE NAME STATUS ---\n${await git.diffNameStatus()}` : ''
|
|
101
|
+
])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const review = await askAi(
|
|
105
|
+
{
|
|
106
|
+
systemPrompt: '你是严格的软件代码审查工程师,请优先关注 bug、安全风险、行为回归、缺失测试。输出中文 Markdown。',
|
|
107
|
+
userPrompt: `请按如下结构输出:\n1. Findings(按严重度从高到低)\n2. Open Questions\n3. Summary\n\nFocus: ${options.focus || 'general'}\nRange: ${rangeSpec}\nDiff Source: ${diffSource}\n\nDiff Stat:\n${truncate(stat, 3000)}\n\nName Status:\n${truncate(nameStatus, 3000)}\n\nUnified Diff:\n${truncate(diff, 18000)}`
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
profile: options.profile,
|
|
111
|
+
dumpResponse: options.dumpResponse
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (!review || !review.trim()) {
|
|
116
|
+
throw new IcodeError('AI Code Review 返回为空,请检查 AI profile/model 是否可用后重试。', {
|
|
117
|
+
code: 'AI_CODEREVIEW_EMPTY_RESPONSE',
|
|
118
|
+
exitCode: 2
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
rangeSpec,
|
|
124
|
+
review
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { IcodeError } from '../core/errors.js'
|
|
2
|
+
import { resolveGitContext } from '../core/git-context.js'
|
|
3
|
+
import { GitService } from '../core/git-service.js'
|
|
4
|
+
import { logger } from '../core/logger.js'
|
|
5
|
+
import { askAiJson } from '../core/ai-client.js'
|
|
6
|
+
import { scanCommitConventions } from '../core/commit-conventions.js'
|
|
7
|
+
import { confirm } from '../core/prompts.js'
|
|
8
|
+
|
|
9
|
+
function normalizeCommitType(value) {
|
|
10
|
+
const allowed = new Set(['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'build', 'ci', 'revert'])
|
|
11
|
+
const normalized = (value || '').trim().toLowerCase()
|
|
12
|
+
return allowed.has(normalized) ? normalized : 'chore'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildCommitMessage(parsed) {
|
|
16
|
+
const type = normalizeCommitType(parsed.type)
|
|
17
|
+
const scope = (parsed.scope || '').trim()
|
|
18
|
+
const subject = (parsed.subject || '').trim().replace(/\n/g, ' ')
|
|
19
|
+
|
|
20
|
+
if (!subject) {
|
|
21
|
+
throw new IcodeError('AI 未返回有效的提交标题(subject)。', {
|
|
22
|
+
code: 'AI_COMMIT_SUBJECT_EMPTY',
|
|
23
|
+
exitCode: 2
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const header = scope ? `${type}(${scope}): ${subject}` : `${type}: ${subject}`
|
|
28
|
+
const body = (parsed.body || '').trim()
|
|
29
|
+
if (!body) {
|
|
30
|
+
return header
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `${header}\n\n${body}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runAiCommitWorkflow(options) {
|
|
37
|
+
const context = await resolveGitContext({
|
|
38
|
+
cwd: options.cwd,
|
|
39
|
+
repoMode: options.repoMode
|
|
40
|
+
})
|
|
41
|
+
const git = new GitService(context)
|
|
42
|
+
|
|
43
|
+
if (!options.silentContextLog) {
|
|
44
|
+
logger.info(`仓库根目录: ${context.topLevelPath}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let diff = await git.diffStaged()
|
|
48
|
+
let diffSource = 'staged'
|
|
49
|
+
|
|
50
|
+
if (!diff.trim()) {
|
|
51
|
+
diff = await git.diffWorkingTree()
|
|
52
|
+
diffSource = 'working-tree'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!diff.trim()) {
|
|
56
|
+
throw new IcodeError('没有可用于生成提交信息的代码改动。', {
|
|
57
|
+
code: 'AI_COMMIT_EMPTY_DIFF',
|
|
58
|
+
exitCode: 2
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const limitedDiff = diff.length > 12000 ? `${diff.slice(0, 12000)}\n\n...<truncated>` : diff
|
|
63
|
+
const conventionContext = scanCommitConventions(context)
|
|
64
|
+
const conventionPrompt = conventionContext.hasConventions
|
|
65
|
+
? `Local commit conventions were detected from repository hooks/config files. Follow these local rules first when generating the commit message.\n\n${conventionContext.summary}\n\n`
|
|
66
|
+
: ''
|
|
67
|
+
|
|
68
|
+
const language = (options.lang || 'zh').trim().toLowerCase() === 'en' ? 'English' : 'Chinese'
|
|
69
|
+
|
|
70
|
+
if (conventionContext.hasConventions && !options.silentContextLog) {
|
|
71
|
+
logger.info(`检测到提交规范配置,AI 将优先参考: ${conventionContext.sources.join(', ')}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { parsed, text } = await askAiJson(
|
|
75
|
+
{
|
|
76
|
+
systemPrompt: `You are a senior software engineer. Generate a concise Conventional Commit message. Output JSON only. Language: ${language}.`,
|
|
77
|
+
userPrompt: `${conventionPrompt}Based on the following git diff, return JSON with fields:\n{\"type\":\"feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert\",\"scope\":\"optional\",\"subject\":\"required one-line summary\",\"body\":\"optional details\"}\n\nDiff Source: ${diffSource}\n\nDiff:\n${limitedDiff}`
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
profile: options.profile
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const commitMessage = buildCommitMessage(parsed)
|
|
85
|
+
|
|
86
|
+
logger.success(`AI 建议提交信息:\n${commitMessage}`)
|
|
87
|
+
|
|
88
|
+
if (!options.apply) {
|
|
89
|
+
return {
|
|
90
|
+
applied: false,
|
|
91
|
+
commitMessage,
|
|
92
|
+
raw: text
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!options.yes) {
|
|
97
|
+
const accepted = await confirm('是否应用该提交信息并执行 commit ?', true)
|
|
98
|
+
if (!accepted) {
|
|
99
|
+
return {
|
|
100
|
+
applied: false,
|
|
101
|
+
commitMessage,
|
|
102
|
+
raw: text,
|
|
103
|
+
canceled: true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (diffSource === 'working-tree') {
|
|
109
|
+
// 从 working-tree 生成信息时,提交前统一暂存,避免 commit 为空。
|
|
110
|
+
await git.stageAll()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await git.commit(commitMessage, {
|
|
114
|
+
noVerify: options.noVerify
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const commitId = await git.revParseShort('HEAD')
|
|
118
|
+
if (commitId) {
|
|
119
|
+
logger.success(`AI commit 已创建: ${commitId}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
applied: true,
|
|
124
|
+
commitId,
|
|
125
|
+
commitMessage,
|
|
126
|
+
raw: text
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { askAi } from '../core/ai-client.js'
|
|
4
|
+
import { IcodeError } from '../core/errors.js'
|
|
5
|
+
import { resolveGitContext } from '../core/git-context.js'
|
|
6
|
+
import { GitService } from '../core/git-service.js'
|
|
7
|
+
import { logger } from '../core/logger.js'
|
|
8
|
+
|
|
9
|
+
function extractConflictBlocks(content, maxBlocks = 3, maxLines = 80) {
|
|
10
|
+
const lines = content.split('\n')
|
|
11
|
+
const blocks = []
|
|
12
|
+
let index = 0
|
|
13
|
+
|
|
14
|
+
while (index < lines.length && blocks.length < maxBlocks) {
|
|
15
|
+
if (!lines[index].startsWith('<<<<<<<')) {
|
|
16
|
+
index += 1
|
|
17
|
+
continue
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const start = index
|
|
21
|
+
let end = index
|
|
22
|
+
|
|
23
|
+
while (end < lines.length && !lines[end].startsWith('>>>>>>>')) {
|
|
24
|
+
end += 1
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (end < lines.length) {
|
|
28
|
+
end += 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const segment = lines.slice(start, Math.min(end, start + maxLines)).join('\n')
|
|
32
|
+
blocks.push(segment)
|
|
33
|
+
index = end
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return blocks
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runAiConflictWorkflow(options) {
|
|
40
|
+
const context = await resolveGitContext({
|
|
41
|
+
cwd: options.cwd,
|
|
42
|
+
repoMode: options.repoMode
|
|
43
|
+
})
|
|
44
|
+
const git = new GitService(context)
|
|
45
|
+
|
|
46
|
+
const conflictedFiles = await git.listConflictedFiles()
|
|
47
|
+
if (!conflictedFiles.length) {
|
|
48
|
+
throw new IcodeError('当前没有检测到冲突文件。', {
|
|
49
|
+
code: 'AI_CONFLICT_NONE',
|
|
50
|
+
exitCode: 2
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const snippets = []
|
|
55
|
+
for (const filePath of conflictedFiles.slice(0, 10)) {
|
|
56
|
+
const absolutePath = path.resolve(context.topLevelPath, filePath)
|
|
57
|
+
if (!fs.existsSync(absolutePath)) {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const content = fs.readFileSync(absolutePath, 'utf8')
|
|
62
|
+
const blocks = extractConflictBlocks(content)
|
|
63
|
+
if (!blocks.length) {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
snippets.push({
|
|
68
|
+
filePath,
|
|
69
|
+
blocks
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!snippets.length) {
|
|
74
|
+
throw new IcodeError('冲突文件中没有检测到可解析的冲突块。', {
|
|
75
|
+
code: 'AI_CONFLICT_BLOCKS_EMPTY',
|
|
76
|
+
exitCode: 2
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rawPayload = snippets
|
|
81
|
+
.map((item) => `FILE: ${item.filePath}\n${item.blocks.map((block, index) => `--- block ${index + 1} ---\n${block}`).join('\n')}`)
|
|
82
|
+
.join('\n\n')
|
|
83
|
+
|
|
84
|
+
const payload = rawPayload.length > 14000 ? `${rawPayload.slice(0, 14000)}\n\n...<truncated>` : rawPayload
|
|
85
|
+
|
|
86
|
+
logger.info(`检测到冲突文件: ${conflictedFiles.join(', ')}`)
|
|
87
|
+
|
|
88
|
+
const suggestion = await askAi(
|
|
89
|
+
{
|
|
90
|
+
systemPrompt: '你是资深代码合并助手。请基于冲突块给出可执行的合并方案。输出中文 Markdown。',
|
|
91
|
+
userPrompt: `请按以下格式输出:\n1) 每个文件的冲突原因\n2) 推荐保留哪一侧或如何融合\n3) 具体手工修改步骤\n4) 修改后需要执行的 git 命令\n\n冲突内容:\n${payload}`
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
profile: options.profile
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
conflictedFiles,
|
|
100
|
+
suggestion
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { askAi } from '../core/ai-client.js'
|
|
2
|
+
import { resolveAiDiffRange } from '../core/ai-diff-range.js'
|
|
3
|
+
import { IcodeError } from '../core/errors.js'
|
|
4
|
+
import { resolveGitContext } from '../core/git-context.js'
|
|
5
|
+
import { GitService } from '../core/git-service.js'
|
|
6
|
+
|
|
7
|
+
function truncate(value, limit) {
|
|
8
|
+
const text = value || ''
|
|
9
|
+
if (text.length <= limit) {
|
|
10
|
+
return text
|
|
11
|
+
}
|
|
12
|
+
return `${text.slice(0, limit)}\n\n...<truncated>`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function joinSections(sections) {
|
|
16
|
+
return sections
|
|
17
|
+
.map((item) => (item || '').trim())
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.join('\n\n')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runAiExplainWorkflow(options) {
|
|
23
|
+
const context = await resolveGitContext({
|
|
24
|
+
cwd: options.cwd,
|
|
25
|
+
repoMode: options.repoMode
|
|
26
|
+
})
|
|
27
|
+
const git = new GitService(context)
|
|
28
|
+
|
|
29
|
+
const headRef = options.headRef || 'HEAD'
|
|
30
|
+
const explicitBase = Boolean((options.baseRef || '').trim())
|
|
31
|
+
const explicitHead = Boolean((options.headRef || '').trim())
|
|
32
|
+
const explicitRange = explicitBase || explicitHead
|
|
33
|
+
|
|
34
|
+
let rangeSpec = ''
|
|
35
|
+
let diff = ''
|
|
36
|
+
let stat = ''
|
|
37
|
+
let nameStatus = ''
|
|
38
|
+
let diffSource = 'three-dot-range'
|
|
39
|
+
let rangeError = null
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const rangeResult = await resolveAiDiffRange({
|
|
43
|
+
git,
|
|
44
|
+
context,
|
|
45
|
+
baseRef: options.baseRef,
|
|
46
|
+
headRef,
|
|
47
|
+
explicitHead,
|
|
48
|
+
label: 'Explain'
|
|
49
|
+
})
|
|
50
|
+
rangeSpec = rangeResult.rangeSpec
|
|
51
|
+
diff = rangeResult.diff
|
|
52
|
+
} catch (error) {
|
|
53
|
+
rangeError = error
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (diff.trim()) {
|
|
57
|
+
stat = await git.diffStat(rangeSpec)
|
|
58
|
+
nameStatus = await git.diffNameStatus(rangeSpec)
|
|
59
|
+
} else {
|
|
60
|
+
if (rangeError && explicitRange) {
|
|
61
|
+
throw rangeError
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stagedDiff = await git.diffStaged()
|
|
65
|
+
const workingDiff = await git.diffWorkingTree()
|
|
66
|
+
|
|
67
|
+
if (rangeError && !stagedDiff.trim() && !workingDiff.trim()) {
|
|
68
|
+
throw rangeError
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!stagedDiff.trim() && !workingDiff.trim()) {
|
|
72
|
+
throw new IcodeError(`范围 ${rangeSpec} 内没有代码差异,且暂存区/工作区也没有改动。`, {
|
|
73
|
+
code: 'AI_EXPLAIN_EMPTY_DIFF',
|
|
74
|
+
exitCode: 2
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
diffSource = 'uncommitted'
|
|
79
|
+
rangeSpec = 'uncommitted(staged+working-tree)'
|
|
80
|
+
diff = joinSections([
|
|
81
|
+
stagedDiff ? `--- STAGED DIFF ---\n${stagedDiff}` : '',
|
|
82
|
+
workingDiff ? `--- WORKING TREE DIFF ---\n${workingDiff}` : ''
|
|
83
|
+
])
|
|
84
|
+
stat = joinSections([
|
|
85
|
+
stagedDiff ? `--- STAGED STAT ---\n${await git.diffStagedStat()}` : '',
|
|
86
|
+
workingDiff ? `--- WORKING TREE STAT ---\n${await git.diffStat()}` : ''
|
|
87
|
+
])
|
|
88
|
+
nameStatus = joinSections([
|
|
89
|
+
stagedDiff ? `--- STAGED NAME STATUS ---\n${await git.diffStagedNameStatus()}` : '',
|
|
90
|
+
workingDiff ? `--- WORKING TREE NAME STATUS ---\n${await git.diffNameStatus()}` : ''
|
|
91
|
+
])
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const explanation = await askAi(
|
|
95
|
+
{
|
|
96
|
+
systemPrompt: '你是资深软件工程师,擅长把 Git diff 用自然语言讲清楚。',
|
|
97
|
+
userPrompt: `请用中文自然语言解释以下 Git diff,输出简洁清晰,面向不熟悉代码的同事。\n要求:\n1. 先给整体改动概览\n2. 再按文件或模块说明主要改动\n3. 如有可能影响行为/兼容性/风险点,请指出但不要过度推测\n4. 不要输出 JSON 或代码块,只输出自然语言(可用简短项目符号)\n\nRange: ${rangeSpec}\nDiff Source: ${diffSource}\n\nDiff Stat:\n${truncate(stat, 3000)}\n\nName Status:\n${truncate(nameStatus, 3000)}\n\nUnified Diff:\n${truncate(diff, 18000)}`
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
profile: options.profile,
|
|
101
|
+
dumpResponse: options.dumpResponse
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if (!explanation || !explanation.trim()) {
|
|
106
|
+
throw new IcodeError('AI Explain 返回为空,请检查 AI profile/model 是否可用后重试。', {
|
|
107
|
+
code: 'AI_EXPLAIN_EMPTY_RESPONSE',
|
|
108
|
+
exitCode: 2
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
rangeSpec,
|
|
114
|
+
explanation
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { askAiJson } from '../core/ai-client.js'
|
|
2
|
+
|
|
3
|
+
function normalizeDecision(value) {
|
|
4
|
+
const normalized = (value || '').trim().toLowerCase()
|
|
5
|
+
if (['allow', 'warn', 'block'].includes(normalized)) {
|
|
6
|
+
return normalized
|
|
7
|
+
}
|
|
8
|
+
return 'warn'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeRisk(value) {
|
|
12
|
+
const normalized = (value || '').trim().toLowerCase()
|
|
13
|
+
if (['low', 'medium', 'high'].includes(normalized)) {
|
|
14
|
+
return normalized
|
|
15
|
+
}
|
|
16
|
+
return 'medium'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function truncate(value, limit) {
|
|
20
|
+
if (!value || value.length <= limit) {
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return `${value.slice(0, limit)}\n\n...<truncated>`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runAiRiskReviewWorkflow({ git, context, currentBranch, targetBranches, profile }) {
|
|
28
|
+
const stat = await git.diffStat()
|
|
29
|
+
const nameStatus = await git.diffNameStatus()
|
|
30
|
+
const recentLog = await git.logOneline('', 25)
|
|
31
|
+
|
|
32
|
+
const { text, parsed } = await askAiJson(
|
|
33
|
+
{
|
|
34
|
+
systemPrompt: '你是资深发布风控助手。请根据改动给出 push 风险评估。输出 JSON。',
|
|
35
|
+
userPrompt: `请仅返回 JSON:\n{\"decision\":\"allow|warn|block\",\"riskLevel\":\"low|medium|high\",\"reasons\":[\"...\"],\"checks\":[\"...\"]}\n\n仓库根目录: ${context.topLevelPath}\n当前分支: ${currentBranch}\n目标分支: ${targetBranches.join(', ')}\n\nDiff Stat:\n${truncate(stat, 2500)}\n\nName Status:\n${truncate(nameStatus, 2500)}\n\nRecent Commits:\n${truncate(recentLog, 2500)}`
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
profile
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
raw: text,
|
|
44
|
+
decision: normalizeDecision(parsed.decision),
|
|
45
|
+
riskLevel: normalizeRisk(parsed.riskLevel),
|
|
46
|
+
reasons: Array.isArray(parsed.reasons) ? parsed.reasons : [],
|
|
47
|
+
checks: Array.isArray(parsed.checks) ? parsed.checks : []
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { IcodeError } from '../core/errors.js'
|
|
2
|
+
import { GitService } from '../core/git-service.js'
|
|
3
|
+
import { resolveGitContext } from '../core/git-context.js'
|
|
4
|
+
import { logger } from '../core/logger.js'
|
|
5
|
+
|
|
6
|
+
export async function runCheckoutWorkflow(input) {
|
|
7
|
+
if (!input.branchName) {
|
|
8
|
+
throw new IcodeError('缺少分支名: icode checkout <branch> [base]', {
|
|
9
|
+
code: 'CHECKOUT_BRANCH_REQUIRED',
|
|
10
|
+
exitCode: 2
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const context = await resolveGitContext({
|
|
15
|
+
cwd: input.cwd,
|
|
16
|
+
repoMode: input.repoMode
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const git = new GitService(context)
|
|
20
|
+
const branchName = input.branchName.trim()
|
|
21
|
+
const baseBranchName = input.baseBranchName?.trim() || context.defaultBranch
|
|
22
|
+
|
|
23
|
+
logger.info(`仓库根目录: ${context.topLevelPath}`)
|
|
24
|
+
if (context.inheritedFromParent) {
|
|
25
|
+
logger.warn('当前目录继承了父级 Git 仓库,命令将基于父仓库根目录执行。')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await git.fetch()
|
|
29
|
+
|
|
30
|
+
const localExists = await git.branchExistsLocal(branchName)
|
|
31
|
+
const remoteExists = await git.branchExistsRemote(branchName)
|
|
32
|
+
|
|
33
|
+
if (localExists) {
|
|
34
|
+
logger.info(`切换本地分支: ${branchName}`)
|
|
35
|
+
await git.checkout(branchName)
|
|
36
|
+
} else if (remoteExists) {
|
|
37
|
+
logger.info(`创建并跟踪远程分支: ${branchName}`)
|
|
38
|
+
await git.checkoutTracking(branchName)
|
|
39
|
+
} else {
|
|
40
|
+
const baseLocalExists = await git.branchExistsLocal(baseBranchName)
|
|
41
|
+
const baseRemoteExists = await git.branchExistsRemote(baseBranchName)
|
|
42
|
+
|
|
43
|
+
if (!baseLocalExists && !baseRemoteExists) {
|
|
44
|
+
throw new IcodeError(`基线分支不存在: ${baseBranchName}`, {
|
|
45
|
+
code: 'CHECKOUT_BASE_MISSING',
|
|
46
|
+
exitCode: 2
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fromRef = baseLocalExists ? baseBranchName : `origin/${baseBranchName}`
|
|
51
|
+
logger.info(`从 ${fromRef} 新建分支: ${branchName}`)
|
|
52
|
+
await git.checkoutNewBranch(branchName, fromRef)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (input.pullMain && context.defaultBranch !== branchName) {
|
|
56
|
+
logger.info(`同步主分支到当前分支: ${context.defaultBranch}`)
|
|
57
|
+
await git.pull(context.defaultBranch, {
|
|
58
|
+
allowUnrelatedHistories: true,
|
|
59
|
+
noRebase: true
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (remoteExists) {
|
|
64
|
+
logger.info(`拉取远程分支: ${branchName}`)
|
|
65
|
+
await git.pull(branchName, {
|
|
66
|
+
allowUnrelatedHistories: true,
|
|
67
|
+
noRebase: true
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (input.pushOrigin && !remoteExists) {
|
|
72
|
+
logger.info(`推送新分支到远程: ${branchName}`)
|
|
73
|
+
await git.push(branchName, {
|
|
74
|
+
setUpstream: true,
|
|
75
|
+
noVerify: input.noVerify
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
branchName,
|
|
81
|
+
baseBranchName,
|
|
82
|
+
remoteExists,
|
|
83
|
+
repoRoot: context.topLevelPath
|
|
84
|
+
}
|
|
85
|
+
}
|