Skip to content

Commit e6fbc2d

Browse files
author
HelloWorldU
committed
feat(agent-engine): auto-generate PR title & body from changed files
- github-api.ts: createPullRequest now accepts optional body param - git.ts: add getStagedFiles() to list staged files before commit - agent.ts: add generateCommitAndPrBody() method - Primary: use Kimi CLI to generate conventional commit message + PR description - Fallback: rule-based generation from file paths (scope/action inference) - submitForReview: replace hardcoded 'feat: {name}' with AI-generated content PR titles now look like 'feat(frontend): add SwarmConfirmModal...' instead of 'feat: 前端专家'. PR body lists changed files with descriptions.
1 parent e018397 commit e6fbc2d

3 files changed

Lines changed: 138 additions & 6 deletions

File tree

kimi-code-swarm/agent-engine/src/agent.ts

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AgentState, LogEntry, ReviewEntry, TaskStatus, EngineEvent } from './types.js'
22
import { runKimi, detectKimiCli, type KimiProcess } from './kimi.js'
3-
import { getChangedFiles, getFileDiff, gitAdd, gitCommit, gitPush, createBranch, cloneRepo, gitFetch, getBranchDiff, gitDeleteRemoteBranch } from './git.js'
3+
import { getChangedFiles, getStagedFiles, getFileDiff, gitAdd, gitCommit, gitPush, createBranch, cloneRepo, gitFetch, getBranchDiff, gitDeleteRemoteBranch } from './git.js'
44
import { createPullRequest, mergePullRequest, getPullRequest, getCheckRuns, getCheckRunLogs } from './github-api.js'
55

66
interface SubmitStep {
@@ -334,6 +334,9 @@ export class Agent {
334334

335335
const steps: SubmitStep[] = []
336336

337+
let prTitle = `feat: ${this.state.name}`
338+
let prBody = `由 Kimi Code Swarm Agent 自动创建`
339+
337340
if (this.state.workspace) {
338341
// git add
339342
const addRes = await gitAdd(this.state.workspace)
@@ -342,8 +345,13 @@ export class Agent {
342345
return { ok: false, steps }
343346
}
344347

348+
// 获取 staged 文件列表,生成规范的 commit message 和 PR 描述
349+
const stagedFiles = await getStagedFiles(this.state.workspace)
350+
const generated = await this.generateCommitAndPrBody(stagedFiles)
351+
this.log('system', `生成提交信息: ${generated.commitMessage}`)
352+
345353
// git commit
346-
const commitRes = await gitCommit(this.state.workspace, `feat: ${this.state.name}`)
354+
const commitRes = await gitCommit(this.state.workspace, generated.commitMessage)
347355
steps.push({ name: 'git commit', stdout: commitRes.stdout, stderr: commitRes.stderr, exitCode: commitRes.exitCode })
348356
if (commitRes.exitCode !== 0) {
349357
return { ok: false, steps }
@@ -357,6 +365,10 @@ export class Agent {
357365
}
358366

359367
this.log('system', '代码已推送至远程')
368+
369+
// 保存生成的 PR 内容(commit 后 staged 文件会被清空,需提前保存)
370+
prTitle = generated.prTitle
371+
prBody = generated.prBody
360372
}
361373

362374
this.setStatus('reviewing')
@@ -371,7 +383,7 @@ export class Agent {
371383
// 如果有 GitHub Token,调用真实 API 创建 PR
372384
if (githubToken) {
373385
try {
374-
const pr = await createPullRequest(githubToken, this.state.repoUrl, this.state.branch, `feat: ${this.state.name}`)
386+
const pr = await createPullRequest(githubToken, this.state.repoUrl, this.state.branch, prTitle, prBody)
375387
if (pr) {
376388
this.state.prStatus = 'open'
377389
this.state.prNumber = pr.number
@@ -677,6 +689,120 @@ export class Agent {
677689
}
678690
}
679691

692+
/**
693+
* 基于变更文件列表生成 commit message 和 PR 描述
694+
* 优先调用 Kimi CLI 生成高质量内容,失败时 fallback 到规则生成
695+
*/
696+
private async generateCommitAndPrBody(files: string[]): Promise<{ commitMessage: string; prTitle: string; prBody: string }> {
697+
// 1. 尝试用 Kimi CLI 生成
698+
const fileList = files.map((f) => `- ${f}`).join('\n')
699+
const prompt = `你是一位资深工程师,请根据以下代码变更文件列表,生成规范的 commit message 和 PR 描述。
700+
701+
变更文件:
702+
${fileList}
703+
704+
要求:
705+
1. commit message 符合 Conventional Commits 规范,格式为 type(scope): description(英文,单行,不超过72字符)
706+
2. PR 标题与 commit message 保持一致
707+
3. PR 描述用中文 Markdown 格式,简要列出每个新增/修改文件的作用(一句话)
708+
709+
请严格按以下格式输出(不要有多余内容):
710+
COMMIT: <commit message>
711+
---
712+
PR_BODY:
713+
<pr body>
714+
`
715+
716+
try {
717+
const output = await this.runInstructionSilent(prompt, 60000)
718+
const commitMatch = output.match(/COMMIT:\s*(.+)/)
719+
const bodyMatch = output.match(/PR_BODY:\s*([\s\S]+)/)
720+
721+
if (commitMatch && bodyMatch) {
722+
const commitMessage = commitMatch[1].trim()
723+
const prBody = bodyMatch[1].trim()
724+
return { commitMessage, prTitle: commitMessage, prBody }
725+
}
726+
} catch {
727+
// Kimi CLI 生成失败,继续 fallback
728+
}
729+
730+
// 2. Fallback:基于规则自动生成
731+
const { scope, action } = this.inferScopeAndAction(files)
732+
const description = this.inferDescription(files, action)
733+
const commitMessage = `${action}${scope ? `(${scope})` : ''}: ${description}`
734+
735+
const prBodyLines = files.map((f) => {
736+
const filename = f.split('/').pop() || f
737+
if (f.endsWith('.spec.ts') || f.endsWith('.test.ts')) return `- 补充 \`${filename}\` 单元测试`
738+
if (f.endsWith('.vue')) return `- 新增/更新 \`${filename}\` 组件`
739+
if (f.endsWith('.ts') || f.endsWith('.js')) return `- 新增/更新 \`${filename}\` 逻辑`
740+
if (f.endsWith('.md')) return `- 更新 \`${filename}\` 文档`
741+
return `- 变更 \`${filename}\``
742+
})
743+
744+
const prBody = prBodyLines.join('\n')
745+
return { commitMessage, prTitle: commitMessage, prBody }
746+
}
747+
748+
/**
749+
* 根据文件路径推断 scope 和 action
750+
*/
751+
private inferScopeAndAction(files: string[]): { scope: string; action: string } {
752+
const scopes = new Set<string>()
753+
let hasNew = false
754+
let hasModify = false
755+
756+
for (const f of files) {
757+
if (f.startsWith('kimi-code-swarm/src/components/') || f.startsWith('kimi-code-swarm/src/composables/') || f.startsWith('kimi-code-swarm/src/App.vue')) {
758+
scopes.add('frontend')
759+
} else if (f.startsWith('kimi-code-swarm/src/store/') || f.startsWith('kimi-code-swarm/src/api/')) {
760+
scopes.add('frontend')
761+
} else if (f.startsWith('agent-engine/src/')) {
762+
scopes.add('agent-engine')
763+
} else if (f.startsWith('docs/')) {
764+
scopes.add('docs')
765+
} else if (f.startsWith('tests/') || f.includes('.spec.ts') || f.includes('.test.ts')) {
766+
scopes.add('test')
767+
} else if (f.startsWith('ci/')) {
768+
scopes.add('ci')
769+
} else if (f.startsWith('ast/')) {
770+
scopes.add('ast')
771+
} else if (f.startsWith('src-tauri/')) {
772+
scopes.add('tauri')
773+
}
774+
// 简单判断新增还是修改(通过文件名特征无法准确判断,默认用 update,如果有测试文件用 add test)
775+
if (f.includes('.spec.ts') || f.includes('.test.ts')) hasNew = true
776+
else hasModify = true
777+
}
778+
779+
const scope = scopes.size === 1 ? Array.from(scopes)[0] : scopes.size > 1 ? 'multi' : ''
780+
const action = hasNew && !hasModify ? 'feat' : hasNew && hasModify ? 'feat' : 'refactor'
781+
return { scope, action }
782+
}
783+
784+
/**
785+
* 根据文件名生成描述
786+
*/
787+
private inferDescription(files: string[], action: string): string {
788+
const names = files
789+
.map((f) => f.split('/').pop() || f)
790+
.filter((f) => !f.endsWith('.spec.ts') && !f.endsWith('.test.ts'))
791+
792+
if (names.length === 0) {
793+
const testFiles = files.map((f) => f.split('/').pop() || f).filter((f) => f.endsWith('.spec.ts') || f.endsWith('.test.ts'))
794+
if (testFiles.length > 0) return `add unit tests for ${testFiles.map((f) => f.replace(/\.(spec|test)\.ts$/, '')).join(', ')}`
795+
}
796+
797+
if (names.length === 1) {
798+
const name = names[0].replace(/\.vue$/, '').replace(/\.ts$/, '').replace(/\.js$/, '')
799+
return action === 'feat' ? `add ${name}` : `update ${name}`
800+
}
801+
802+
const baseNames = names.map((n) => n.replace(/\.vue$/, '').replace(/\.ts$/, '').replace(/\.js$/, ''))
803+
return action === 'feat' ? `add ${baseNames.slice(0, 3).join(', ')}${baseNames.length > 3 ? ' and more' : ''}` : `update multiple files`
804+
}
805+
680806
/**
681807
* 自动审阅指定分支的代码变更
682808
* 返回 { approved, comment }

kimi-code-swarm/agent-engine/src/git.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ export async function getChangedFiles(dir: string): Promise<string[]> {
7676
return out.split('\n').filter((f) => f.trim())
7777
}
7878

79+
export async function getStagedFiles(dir: string): Promise<string[]> {
80+
const out = await execGit(dir, ['diff', '--cached', '--name-only'])
81+
return out.split('\n').filter((f) => f.trim())
82+
}
83+
7984
export async function getFileDiff(dir: string, filePath: string): Promise<string> {
8085
return await execGit(dir, ['diff', '--', filePath])
8186
}

kimi-code-swarm/agent-engine/src/github-api.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,21 @@ export async function createPullRequest(
2828
repoUrl: string,
2929
branch: string,
3030
title: string,
31+
body?: string,
3132
): Promise<{ number: number; html_url: string } | null> {
3233
const repo = parseRepoUrl(repoUrl)
3334
if (!repo) return null
3435

3536
const url = `${GITHUB_API}/repos/${repo.owner}/${repo.repo}/pulls`
36-
const body = JSON.stringify({
37+
const payload = JSON.stringify({
3738
title,
3839
head: branch,
3940
base: 'main',
40-
body: `由 Kimi Code Swarm Agent 自动创建`,
41+
body: body || `由 Kimi Code Swarm Agent 自动创建`,
4142
})
4243

4344
try {
44-
const res = await fetch(url, { method: 'POST', headers: getHeaders(token), body })
45+
const res = await fetch(url, { method: 'POST', headers: getHeaders(token), body: payload })
4546
if (!res.ok) {
4647
const err = await res.text()
4748
throw new Error(`GitHub API ${res.status}: ${err}`)

0 commit comments

Comments
 (0)