Repository navigation
Expand file tree
/
Copy pathcommit-message.ts
More file actions
128 lines (112 loc) · 5.27 KB
/
Copy pathcommit-message.ts
File metadata and controls
128 lines (112 loc) · 5.27 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
import { GitDomainError } from '../../runtime/git/git-domain-error.js';
import type { AgentId } from '../../../common/agents.ts';
import type { CommitMessageOptions, RunSingleQueryOptions } from './commit-generation-types.js';
import { createLogger } from '../../common/log.js';
import { GENERATION_PROVIDER_TIMEOUT_MS } from '../settings/generation-limits.js';
import { AgentIntegrationError } from '@garcon/server-agent-interface';
import {
COMMIT_MESSAGE_DIFF_TOKEN,
COMMIT_MESSAGE_FILES_TOKEN,
DEFAULT_COMMIT_MESSAGE_PROMPT,
} from '../../../common/generation-prompts.js';
const logger = createLogger('git:commit-message');
export const COMMIT_MESSAGE_ERROR_MAP = Object.freeze({
COMMIT_MESSAGE_NO_STAGED_FILES: { status: 400, errorCode: 'commit_message_no_staged_files' },
COMMIT_MESSAGE_AGENT_AUTH_REQUIRED: { status: 401, errorCode: 'commit_message_agent_auth_required' },
COMMIT_MESSAGE_RATE_LIMITED: { status: 429, errorCode: 'commit_message_rate_limited' },
COMMIT_MESSAGE_AGENT_UNAVAILABLE: { status: 503, errorCode: 'commit_message_agent_unavailable' },
COMMIT_MESSAGE_TIMEOUT: { status: 504, errorCode: 'commit_message_timeout' },
COMMIT_MESSAGE_EMPTY_RESPONSE: { status: 502, errorCode: 'commit_message_empty_response' },
COMMIT_MESSAGE_INVALID_RESPONSE: { status: 502, errorCode: 'commit_message_invalid_response' },
COMMIT_MESSAGE_GENERATION_FAILED: { status: 500, errorCode: 'commit_message_generation_failed' },
});
type CommitMessageErrorCode = keyof typeof COMMIT_MESSAGE_ERROR_MAP;
const MAX_DIFF_CHARS = 80_000;
export function isCommitMessageErrorCode(code: string): code is CommitMessageErrorCode {
return Object.prototype.hasOwnProperty.call(COMMIT_MESSAGE_ERROR_MAP, code);
}
function classifyCommitMessageAgentError(error: unknown): CommitMessageErrorCode {
if (!(error instanceof AgentIntegrationError)) return 'COMMIT_MESSAGE_GENERATION_FAILED';
if (error.code === 'AUTH_REQUIRED') return 'COMMIT_MESSAGE_AGENT_AUTH_REQUIRED';
if (error.code === 'RATE_LIMITED') return 'COMMIT_MESSAGE_RATE_LIMITED';
if (error.code === 'TIMEOUT') return 'COMMIT_MESSAGE_TIMEOUT';
if (error.code === 'BINARY_NOT_FOUND' || error.code === 'UNAVAILABLE') {
return 'COMMIT_MESSAGE_AGENT_UNAVAILABLE';
}
return 'COMMIT_MESSAGE_GENERATION_FAILED';
}
// Generates a conventional commit message using the configured agent.
// When customPrompt is non-empty, it is used as the template with
// {{files}} and {{diff}} placeholders substituted in.
export async function generateCommitMessage(
files: string[],
diffContext: string,
agentId: AgentId,
runSingleQueryFn: (prompt: string, options: RunSingleQueryOptions) => Promise<string>,
options: CommitMessageOptions = {},
): Promise<string> {
const filesList = files.map((f) => `- ${f}`).join('\n');
const diffExcerpt = diffContext.substring(0, MAX_DIFF_CHARS);
const {
model,
apiProviderId,
modelEndpointId,
modelProtocol,
thinkingMode,
customPrompt,
} = options;
const template = customPrompt?.trim() ? customPrompt : DEFAULT_COMMIT_MESSAGE_PROMPT;
const prompt = template
.replaceAll(COMMIT_MESSAGE_FILES_TOKEN, () => filesList)
.replaceAll(COMMIT_MESSAGE_DIFF_TOKEN, () => diffExcerpt);
try {
const opts: RunSingleQueryOptions = {
...(options.executorId ? { executorId: options.executorId } : {}),
agentId,
thinkingMode: thinkingMode ?? 'none',
timeoutMs: options.timeoutMs ?? GENERATION_PROVIDER_TIMEOUT_MS,
};
if (options.signal) opts.signal = options.signal;
if (model) opts.model = model;
if (apiProviderId) opts.apiProviderId = apiProviderId;
if (modelEndpointId) opts.modelEndpointId = modelEndpointId;
if (modelProtocol) opts.modelProtocol = modelProtocol;
const responseText = await runSingleQueryFn(prompt, opts);
if (!responseText?.trim()) {
throw new GitDomainError('COMMIT_MESSAGE_EMPTY_RESPONSE', 'Provider returned an empty commit message response.');
}
const cleaned = normalizeCommitMessage(responseText);
if (!cleaned) {
throw new GitDomainError('COMMIT_MESSAGE_INVALID_RESPONSE', 'Provider returned an invalid commit message format.');
}
return cleaned;
} catch (error) {
if (error instanceof GitDomainError) throw error;
logger.error('Failed to generate commit message', { code: classifyCommitMessageAgentError(error) });
throw new GitDomainError(
classifyCommitMessageAgentError(error),
'Failed to generate commit message.',
);
}
}
// Extracts a conventional commit message from AI-generated text by
// stripping fences, markdown headers, and leading non-commit prose.
function normalizeCommitMessage(text: string): string {
if (!text?.trim()) return '';
const lines = text.trim().split('\n');
const cleaned = [];
let foundCommit = false;
for (const raw of lines) {
if (raw.startsWith('```')) continue;
const line = raw.replace(/^#+\s*/, '');
if (!foundCommit && /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:/.test(line)) {
foundCommit = true;
}
if (foundCommit) cleaned.push(line);
}
const result = cleaned.length > 0 ? cleaned : lines.filter((l) => !l.startsWith('```'));
if (result.length > 0) {
result[0] = result[0].replace(/^["']|["']$/g, '');
}
return result.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}