Skip to content

Commit 67ffd16

Browse files
committed
Refactor commit message generation to use configurable API providers and modular utilities
1 parent aac1db7 commit 67ffd16

9 files changed

Lines changed: 413 additions & 38 deletions

‎apps/editor/src/commands/generate-commit-message-command.ts‎

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as vscode from 'vscode'
2+
import {
3+
get_git_repository,
4+
prepare_staged_changes
5+
} from '../../utils/git-repository-utils'
6+
import { get_commit_message_config } from './utils/get-commit-message-config'
7+
import { build_commit_message_prompt } from './utils/build-commit-message-prompt'
8+
import { generate_commit_message_with_api } from './utils/generate-commit-message-with-api'
9+
10+
export const generate_commit_message_command = (
11+
context: vscode.ExtensionContext
12+
) => {
13+
return vscode.commands.registerCommand(
14+
'codeWebChat.generateCommitMessage',
15+
async (source_control?: vscode.SourceControl) => {
16+
const repository = await get_git_repository(source_control)
17+
if (!repository) return
18+
19+
await repository.status()
20+
const was_empty_stage = (repository.state.indexChanges || []).length == 0
21+
22+
const diff = await prepare_staged_changes(repository)
23+
24+
if (!diff) return
25+
26+
const message_prompt = build_commit_message_prompt(diff)
27+
28+
const api_config_data = await get_commit_message_config(
29+
context,
30+
message_prompt
31+
)
32+
if (!api_config_data) {
33+
if (was_empty_stage) {
34+
await vscode.commands.executeCommand('git.unstageAll')
35+
}
36+
return
37+
}
38+
39+
const commit_message = await generate_commit_message_with_api({
40+
endpoint_url: api_config_data.endpoint_url,
41+
provider: api_config_data.provider,
42+
config: api_config_data.config,
43+
message: message_prompt
44+
})
45+
46+
if (commit_message) {
47+
repository.inputBox.value = commit_message
48+
} else if (was_empty_stage) {
49+
await vscode.commands.executeCommand('git.unstageAll')
50+
}
51+
}
52+
)
53+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './generate-commit-message-command'
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import * as vscode from 'vscode'
2+
import { commit_message_instructions } from '@/constants/instructions'
3+
4+
export const build_commit_message_prompt = (diff: string): string => {
5+
const config = vscode.workspace.getConfiguration('codeWebChat')
6+
const instructions = config.get<string>('commitMessageInstructions')
7+
const commit_message_prompt = instructions || commit_message_instructions
8+
9+
const file_diffs = diff.split(/^diff --git /m).filter((d) => d.trim() != '')
10+
11+
let changes_content = '<files>\n'
12+
for (const file_diff_content of file_diffs) {
13+
const full_file_diff = 'diff --git ' + file_diff_content
14+
const lines = full_file_diff.split('\n')
15+
const old_path_line = lines.find((l) => l.startsWith('--- a/'))
16+
const new_path_line = lines.find((l) => l.startsWith('+++ b/'))
17+
const rename_from_line = lines.find((l) => l.startsWith('rename from '))
18+
19+
const old_path = old_path_line
20+
? old_path_line.substring('--- a/'.length)
21+
: undefined
22+
const new_path = new_path_line
23+
? new_path_line.substring('+++ b/'.length)
24+
: undefined
25+
26+
let final_diff_content = full_file_diff
27+
let file_path: string | undefined
28+
let is_deleted = false
29+
30+
if (new_path && new_path != '/dev/null') {
31+
file_path = new_path
32+
} else if (old_path && old_path != '/dev/null') {
33+
file_path = old_path
34+
is_deleted = true
35+
// Shorten diff of a deleted file
36+
const split_diff = full_file_diff.split('+++ /dev/null')
37+
final_diff_content = split_diff[0] + '+++ /dev/null'
38+
} else {
39+
// Fallback: try to identify file from diff header (e.g. binary files)
40+
// diff --git a/path b/path
41+
const diff_header = lines[0]
42+
const match = diff_header.match(/^diff --git a\/(.*) b\/(.*)$/)
43+
if (match) {
44+
file_path = match[2]
45+
}
46+
}
47+
48+
if (file_path) {
49+
let status = 'updated'
50+
51+
if (is_deleted) {
52+
status = 'deleted'
53+
} else if (!old_path && new_path) {
54+
status = 'created'
55+
} else if (rename_from_line) {
56+
status = 'renamed'
57+
} else if (lines.some((l) => l.startsWith('new file mode'))) {
58+
status = 'created'
59+
} else if (lines.some((l) => l.startsWith('deleted file mode'))) {
60+
status = 'deleted'
61+
is_deleted = true
62+
}
63+
64+
let old_path_attr: string | undefined
65+
if (status == 'renamed') {
66+
if (rename_from_line) {
67+
old_path_attr = rename_from_line
68+
.substring('rename from '.length)
69+
.trim()
70+
}
71+
}
72+
73+
changes_content += `<file path="${file_path}" status="${status}"`
74+
if (old_path_attr) {
75+
changes_content += ` old_path="${old_path_attr}"`
76+
}
77+
changes_content += '>\n'
78+
changes_content += `<![CDATA[\n${final_diff_content}\n]]>\n`
79+
changes_content += `</file>\n`
80+
}
81+
}
82+
83+
changes_content += '</files>'
84+
85+
return `${commit_message_prompt}\n${changes_content}\n${commit_message_prompt}`
86+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as vscode from 'vscode'
2+
import axios from 'axios'
3+
import { apply_reasoning_effort } from '@/utils/apply-reasoning-effort'
4+
import { dictionary } from '@shared/constants/dictionary'
5+
import { make_api_request } from '@/utils/make-api-request'
6+
import { process_single_trailing_dot } from '@/utils/process-single-trailing-dot/process-single-trailing-dot'
7+
import { Logger } from '@shared/utils/logger'
8+
import { strip_wrapping_quotes } from './strip-wrapping-quotes'
9+
import { CommitMessageConfig } from './get-commit-message-config'
10+
11+
export const generate_commit_message_with_api = async (params: {
12+
endpoint_url: string
13+
provider: any
14+
config: CommitMessageConfig
15+
message: string
16+
}): Promise<string | null> => {
17+
const messages = [
18+
{
19+
role: 'user',
20+
content: params.message
21+
}
22+
]
23+
24+
const body = {
25+
messages,
26+
model: params.config.model,
27+
temperature: params.config.temperature
28+
} as any
29+
30+
apply_reasoning_effort(body, params.provider, params.config.reasoning_effort)
31+
32+
const cancel_token_source = axios.CancelToken.source()
33+
34+
return await vscode.window.withProgress(
35+
{
36+
location: vscode.ProgressLocation.Notification,
37+
title: dictionary.api_call.WAITING_FOR_RESPONSE,
38+
cancellable: true
39+
},
40+
async (progress, token) => {
41+
token.onCancellationRequested(() => {
42+
cancel_token_source.cancel('Operation cancelled by user')
43+
})
44+
45+
let wait_time = 0
46+
const wait_timer = setInterval(() => {
47+
progress.report({
48+
message: `${(wait_time / 10).toFixed(1)}s`
49+
})
50+
wait_time++
51+
}, 100)
52+
53+
try {
54+
const response_result = await make_api_request({
55+
endpoint_url: params.endpoint_url,
56+
api_key: params.provider.api_key,
57+
body,
58+
cancellation_token: cancel_token_source.token
59+
})
60+
61+
if (!response_result) {
62+
if (token.isCancellationRequested) {
63+
return null
64+
}
65+
vscode.window.showErrorMessage(
66+
dictionary.error_message.FAILED_TO_GENERATE_COMMIT_MESSAGE
67+
)
68+
return null
69+
} else {
70+
let commit_message = process_single_trailing_dot(
71+
response_result.response
72+
)
73+
commit_message = strip_wrapping_quotes(commit_message)
74+
// Sanitize to prevent shell syntax errors
75+
commit_message = commit_message.replace(/[<>`$()]/g, '')
76+
return commit_message
77+
}
78+
} catch (error) {
79+
if (axios.isCancel(error)) {
80+
return null
81+
}
82+
Logger.error({
83+
function_name: 'generate_commit_message_with_api',
84+
message: 'Error during API request',
85+
data: error
86+
})
87+
throw error
88+
} finally {
89+
clearInterval(wait_timer)
90+
}
91+
}
92+
)
93+
}

0 commit comments

Comments
 (0)