@ksw8954/git-ai-commit 1.0.0
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/AGENTS.md +38 -0
- package/CRUSH.md +28 -0
- package/Makefile +32 -0
- package/README.md +145 -0
- package/dist/commands/ai.d.ts +35 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +206 -0
- package/dist/commands/ai.js.map +1 -0
- package/dist/commands/commit.d.ts +17 -0
- package/dist/commands/commit.d.ts.map +1 -0
- package/dist/commands/commit.js +126 -0
- package/dist/commands/commit.js.map +1 -0
- package/dist/commands/config.d.ts +33 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +141 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/configCommand.d.ts +20 -0
- package/dist/commands/configCommand.d.ts.map +1 -0
- package/dist/commands/configCommand.js +108 -0
- package/dist/commands/configCommand.js.map +1 -0
- package/dist/commands/git.d.ts +26 -0
- package/dist/commands/git.d.ts.map +1 -0
- package/dist/commands/git.js +150 -0
- package/dist/commands/git.js.map +1 -0
- package/dist/commands/loadEnv.d.ts +2 -0
- package/dist/commands/loadEnv.d.ts.map +1 -0
- package/dist/commands/loadEnv.js +11 -0
- package/dist/commands/loadEnv.js.map +1 -0
- package/dist/commands/prCommand.d.ts +16 -0
- package/dist/commands/prCommand.d.ts.map +1 -0
- package/dist/commands/prCommand.js +61 -0
- package/dist/commands/prCommand.js.map +1 -0
- package/dist/commands/tag.d.ts +17 -0
- package/dist/commands/tag.d.ts.map +1 -0
- package/dist/commands/tag.js +127 -0
- package/dist/commands/tag.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/commit.d.ts +3 -0
- package/dist/prompts/commit.d.ts.map +1 -0
- package/dist/prompts/commit.js +101 -0
- package/dist/prompts/commit.js.map +1 -0
- package/dist/prompts/pr.d.ts +3 -0
- package/dist/prompts/pr.d.ts.map +1 -0
- package/dist/prompts/pr.js +58 -0
- package/dist/prompts/pr.js.map +1 -0
- package/dist/prompts/tag.d.ts +3 -0
- package/dist/prompts/tag.d.ts.map +1 -0
- package/dist/prompts/tag.js +42 -0
- package/dist/prompts/tag.js.map +1 -0
- package/eslint.config.js +35 -0
- package/jest.config.js +16 -0
- package/package.json +51 -0
- package/src/__tests__/ai.test.ts +185 -0
- package/src/__tests__/commitCommand.test.ts +155 -0
- package/src/__tests__/config.test.ts +238 -0
- package/src/__tests__/git.test.ts +88 -0
- package/src/__tests__/integration.test.ts +138 -0
- package/src/__tests__/prCommand.test.ts +121 -0
- package/src/__tests__/tagCommand.test.ts +197 -0
- package/src/commands/ai.ts +266 -0
- package/src/commands/commit.ts +215 -0
- package/src/commands/config.ts +182 -0
- package/src/commands/configCommand.ts +139 -0
- package/src/commands/git.ts +174 -0
- package/src/commands/history.ts +82 -0
- package/src/commands/loadEnv.ts +5 -0
- package/src/commands/log.ts +71 -0
- package/src/commands/prCommand.ts +108 -0
- package/src/commands/tag.ts +230 -0
- package/src/index.ts +29 -0
- package/src/prompts/commit.ts +105 -0
- package/src/prompts/pr.ts +64 -0
- package/src/prompts/tag.ts +48 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { ConfigService, SupportedLanguage } from './config';
|
|
3
|
+
|
|
4
|
+
export interface ConfigOptions {
|
|
5
|
+
show?: boolean;
|
|
6
|
+
language?: string;
|
|
7
|
+
autoPush?: boolean;
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
mode?: 'custom' | 'openai';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ConfigCommand {
|
|
15
|
+
private program: Command;
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
this.program = new Command('config')
|
|
19
|
+
.description('Manage git-ai-commit configuration')
|
|
20
|
+
.option('-s, --show', 'Show current configuration values')
|
|
21
|
+
.option('-l, --language <language>', 'Set default language for AI output (ko | en)')
|
|
22
|
+
.option('--auto-push', 'Enable automatic git push after successful commits created with --commit')
|
|
23
|
+
.option('--no-auto-push', 'Disable automatic git push after successful commits created with --commit')
|
|
24
|
+
.option('-k, --api-key <key>', 'Persist API key for AI requests (overrides environment variables)')
|
|
25
|
+
.option('-b, --base-url <url>', 'Persist API base URL (overrides environment variables)')
|
|
26
|
+
.option('-m, --model <model>', 'Persist default AI model')
|
|
27
|
+
.option('--mode <mode>', 'Persist AI mode (custom | openai)')
|
|
28
|
+
.action(this.handleConfig.bind(this));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private validateLanguage(language: string): SupportedLanguage {
|
|
32
|
+
const normalized = language?.toLowerCase();
|
|
33
|
+
if (normalized !== 'ko' && normalized !== 'en') {
|
|
34
|
+
console.error('Language must be either "ko" or "en".');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private validateMode(mode: string): 'custom' | 'openai' {
|
|
42
|
+
const normalized = mode?.toLowerCase();
|
|
43
|
+
if (normalized !== 'custom' && normalized !== 'openai') {
|
|
44
|
+
console.error('Mode must be either "custom" or "openai".');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return normalized;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private sanitizeStringValue(value?: string): string | undefined {
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async handleConfig(options: ConfigOptions) {
|
|
61
|
+
const updates: {
|
|
62
|
+
apiKey?: string;
|
|
63
|
+
baseURL?: string;
|
|
64
|
+
model?: string;
|
|
65
|
+
mode?: 'custom' | 'openai';
|
|
66
|
+
language?: SupportedLanguage;
|
|
67
|
+
autoPush?: boolean;
|
|
68
|
+
} = {};
|
|
69
|
+
|
|
70
|
+
if (options.language) {
|
|
71
|
+
updates.language = this.validateLanguage(options.language);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (typeof options.autoPush === 'boolean') {
|
|
75
|
+
updates.autoPush = options.autoPush;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options.apiKey !== undefined) {
|
|
79
|
+
updates.apiKey = this.sanitizeStringValue(options.apiKey);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (options.baseUrl !== undefined) {
|
|
83
|
+
updates.baseURL = this.sanitizeStringValue(options.baseUrl);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.model !== undefined) {
|
|
87
|
+
updates.model = this.sanitizeStringValue(options.model);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (options.mode) {
|
|
91
|
+
updates.mode = this.validateMode(options.mode);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const hasUpdates = Object.keys(updates).length > 0;
|
|
95
|
+
|
|
96
|
+
if (!options.show && !hasUpdates) {
|
|
97
|
+
console.log('Usage examples:');
|
|
98
|
+
console.log(' git-ai-commit config --show # Display merged configuration');
|
|
99
|
+
console.log(' git-ai-commit config --language en # Use English prompts and output');
|
|
100
|
+
console.log(' git-ai-commit config --auto-push # Push after commits created with --commit');
|
|
101
|
+
console.log(' git-ai-commit config -k sk-xxx # Persist API key securely on disk');
|
|
102
|
+
console.log(' git-ai-commit config -b https://api.test # Persist custom API base URL');
|
|
103
|
+
console.log(' git-ai-commit config --mode openai # Use OpenAI-compatible environment defaults');
|
|
104
|
+
console.log(' git-ai-commit config --model gpt-4o-mini # Persist preferred AI model');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (hasUpdates) {
|
|
109
|
+
try {
|
|
110
|
+
await ConfigService.updateConfig(updates);
|
|
111
|
+
console.log('Configuration updated successfully.');
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error('Error updating configuration:', error instanceof Error ? error.message : error);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (options.show) {
|
|
120
|
+
try {
|
|
121
|
+
const config = ConfigService.getConfig();
|
|
122
|
+
console.log('Current configuration:');
|
|
123
|
+
console.log(`Language: ${config.language}`);
|
|
124
|
+
console.log(`Auto push: ${config.autoPush ? 'enabled' : 'disabled'}`);
|
|
125
|
+
console.log(`API Key: ${config.apiKey ? '***' + config.apiKey.slice(-4) : 'Not set'}`);
|
|
126
|
+
console.log(`Base URL: ${config.baseURL || 'Not set (using provider default)'}`);
|
|
127
|
+
console.log(`Model: ${config.model || 'zai-org/GLM-4.5-FP8 (default)'}`);
|
|
128
|
+
console.log(`Mode: ${config.mode || 'custom (default)'}`);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('Error reading configuration:', error instanceof Error ? error.message : error);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getCommand(): Command {
|
|
137
|
+
return this.program;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { exec, execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export interface GitDiffResult {
|
|
8
|
+
success: boolean;
|
|
9
|
+
diff?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GitTagResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
tag?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GitLogResult {
|
|
20
|
+
success: boolean;
|
|
21
|
+
log?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class GitService {
|
|
26
|
+
static async getStagedDiff(): Promise<GitDiffResult> {
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execAsync('git diff --staged');
|
|
29
|
+
|
|
30
|
+
if (!stdout.trim()) {
|
|
31
|
+
return {
|
|
32
|
+
success: false,
|
|
33
|
+
error: 'No staged changes found. Please stage your changes first.'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
success: true,
|
|
39
|
+
diff: stdout
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error: error instanceof Error ? error.message : 'Failed to get git diff'
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static async getBranchDiff(base: string, compare: string): Promise<GitDiffResult> {
|
|
50
|
+
try {
|
|
51
|
+
const command = `git diff ${base}...${compare}`;
|
|
52
|
+
const { stdout } = await execAsync(command);
|
|
53
|
+
|
|
54
|
+
if (!stdout.trim()) {
|
|
55
|
+
return {
|
|
56
|
+
success: false,
|
|
57
|
+
error: `No differences found between ${base} and ${compare}.`
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
success: true,
|
|
63
|
+
diff: stdout
|
|
64
|
+
};
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const message = error instanceof Error ? error.message : 'Failed to get branch diff';
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
error: message.includes('unknown revision')
|
|
70
|
+
? `Unable to resolve one of the branches: ${base} or ${compare}.`
|
|
71
|
+
: message
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static async createCommit(message: string): Promise<boolean> {
|
|
77
|
+
try {
|
|
78
|
+
await execFileAsync('git', ['commit', '-m', message]);
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('Failed to create commit:', error instanceof Error ? error.message : error);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static async push(): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
await execFileAsync('git', ['push']);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error('Failed to push to remote:', error instanceof Error ? error.message : error);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static async pushTag(tagName: string): Promise<boolean> {
|
|
97
|
+
try {
|
|
98
|
+
await execFileAsync('git', ['push', 'origin', tagName]);
|
|
99
|
+
return true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('Failed to push tag to remote:', error instanceof Error ? error.message : error);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
static async getLatestTag(): Promise<GitTagResult> {
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await execAsync('git describe --tags --abbrev=0');
|
|
109
|
+
const tag = stdout.trim();
|
|
110
|
+
|
|
111
|
+
if (!tag) {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
error: 'No tags found in the repository.'
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
success: true,
|
|
120
|
+
tag
|
|
121
|
+
};
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
error: 'Failed to determine the latest tag. Provide a base tag explicitly using --base-tag.'
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static async getCommitSummariesSince(tag?: string): Promise<GitLogResult> {
|
|
131
|
+
try {
|
|
132
|
+
const logCommand = tag
|
|
133
|
+
? `git log ${tag}..HEAD --pretty=format:%s`
|
|
134
|
+
: 'git log --pretty=format:%s';
|
|
135
|
+
|
|
136
|
+
const { stdout } = await execAsync(logCommand);
|
|
137
|
+
const trimmed = stdout
|
|
138
|
+
.split('\n')
|
|
139
|
+
.map(line => line.trim())
|
|
140
|
+
.filter(line => line.length > 0);
|
|
141
|
+
|
|
142
|
+
if (trimmed.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
success: false,
|
|
145
|
+
error: tag
|
|
146
|
+
? `No commits found since tag ${tag}.`
|
|
147
|
+
: 'No commits found in the repository.'
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const formattedLog = trimmed.map(entry => `- ${entry}`).join('\n');
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
success: true,
|
|
155
|
+
log: formattedLog
|
|
156
|
+
};
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
error: error instanceof Error ? error.message : 'Failed to read commit history.'
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
static async createAnnotatedTag(tagName: string, message: string): Promise<boolean> {
|
|
166
|
+
try {
|
|
167
|
+
await execFileAsync('git', ['tag', '-a', tagName, '-m', message]);
|
|
168
|
+
return true;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('Failed to create tag:', error instanceof Error ? error.message : error);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import readline from 'readline';
|
|
3
|
+
import { LogService } from './log';
|
|
4
|
+
|
|
5
|
+
export interface HistoryOptions {
|
|
6
|
+
limit?: string;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
clear?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class HistoryCommand {
|
|
12
|
+
private program: Command;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
this.program = new Command('history')
|
|
16
|
+
.description('Manage git-ai-commit command history')
|
|
17
|
+
.option('-l, --limit <n>', 'Limit number of entries to show (most recent first)')
|
|
18
|
+
.option('--json', 'Output in JSON format')
|
|
19
|
+
.option('--clear', 'Clear all stored history (requires confirmation)')
|
|
20
|
+
.action(this.handleHistory.bind(this));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async confirmClear(): Promise<boolean> {
|
|
24
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
25
|
+
const answer: string = await new Promise(resolve => {
|
|
26
|
+
rl.question('This will remove all stored history. Continue? (y/n): ', resolve);
|
|
27
|
+
});
|
|
28
|
+
rl.close();
|
|
29
|
+
const normalized = answer.trim().toLowerCase();
|
|
30
|
+
return normalized === 'y' || normalized === 'yes';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private parseLimit(value?: string): number | undefined {
|
|
34
|
+
if (!value) return undefined;
|
|
35
|
+
const n = Number(value);
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private formatLine(e: any): string {
|
|
40
|
+
const ts = new Date(e.timestamp).toISOString();
|
|
41
|
+
const args = Object.entries(e.args || {})
|
|
42
|
+
.filter(([k, v]) => v !== undefined && v !== null && v !== '')
|
|
43
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
44
|
+
.join(' ');
|
|
45
|
+
return `${ts} ${e.command} ${e.status}${e.durationMs ? ` (${e.durationMs}ms)` : ''}${args ? ` -- ${args}` : ''}${e.details ? `\n > ${e.details}` : ''}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async handleHistory(options: HistoryOptions) {
|
|
49
|
+
if (options.clear) {
|
|
50
|
+
const confirmed = await this.confirmClear();
|
|
51
|
+
if (!confirmed) {
|
|
52
|
+
console.log('History clear cancelled.');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
await LogService.clear();
|
|
56
|
+
console.log('History cleared.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const limit = this.parseLimit(options.limit);
|
|
61
|
+
const entries = await LogService.read(limit);
|
|
62
|
+
|
|
63
|
+
if (options.json) {
|
|
64
|
+
const output = limit ? entries.slice(-limit) : entries;
|
|
65
|
+
console.log(JSON.stringify(output, null, 2));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (entries.length === 0) {
|
|
70
|
+
console.log('No history entries.');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
console.log(this.formatLine(e));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getCommand(): Command {
|
|
80
|
+
return this.program;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
export type LogStatus = 'success' | 'failure' | 'cancelled';
|
|
6
|
+
|
|
7
|
+
export interface HistoryEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
timestamp: string; // ISO
|
|
10
|
+
command: string;
|
|
11
|
+
args: Record<string, unknown>;
|
|
12
|
+
status: LogStatus;
|
|
13
|
+
details?: string;
|
|
14
|
+
durationMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getStorageDir(): string {
|
|
18
|
+
const override = process.env.GIT_AI_COMMIT_CONFIG_PATH;
|
|
19
|
+
return override ? path.dirname(path.resolve(override)) : path.join(os.homedir(), '.git-ai-commit');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getHistoryPath(): string {
|
|
23
|
+
return path.join(getStorageDir(), 'history.jsonl');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function genId(): string {
|
|
27
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class LogService {
|
|
31
|
+
static async append(entry: Omit<HistoryEntry, 'id' | 'timestamp'> & { timestamp?: string; id?: string }): Promise<void> {
|
|
32
|
+
const file = getHistoryPath();
|
|
33
|
+
const dir = path.dirname(file);
|
|
34
|
+
await fs.mkdir(dir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const finalized: HistoryEntry = {
|
|
37
|
+
id: entry.id || genId(),
|
|
38
|
+
timestamp: entry.timestamp || new Date().toISOString(),
|
|
39
|
+
command: entry.command,
|
|
40
|
+
args: entry.args,
|
|
41
|
+
status: entry.status,
|
|
42
|
+
details: entry.details,
|
|
43
|
+
durationMs: entry.durationMs
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const line = JSON.stringify(finalized) + '\n';
|
|
47
|
+
await fs.appendFile(file, line, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static async read(limit?: number): Promise<HistoryEntry[]> {
|
|
51
|
+
const file = getHistoryPath();
|
|
52
|
+
try {
|
|
53
|
+
const raw = await fs.readFile(file, 'utf-8');
|
|
54
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
55
|
+
const parsed = lines.map(l => {
|
|
56
|
+
try { return JSON.parse(l) as HistoryEntry; } catch { return undefined; }
|
|
57
|
+
}).filter((e): e is HistoryEntry => Boolean(e));
|
|
58
|
+
const result = limit && limit > 0 ? parsed.slice(-limit) : parsed;
|
|
59
|
+
return result;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static async clear(): Promise<void> {
|
|
66
|
+
const file = getHistoryPath();
|
|
67
|
+
const dir = path.dirname(file);
|
|
68
|
+
await fs.mkdir(dir, { recursive: true });
|
|
69
|
+
await fs.writeFile(file, '', 'utf-8');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { GitService } from './git';
|
|
3
|
+
import { AIService } from './ai';
|
|
4
|
+
import { ConfigService } from './config';
|
|
5
|
+
import { LogService } from './log';
|
|
6
|
+
|
|
7
|
+
export interface PullRequestOptions {
|
|
8
|
+
base: string;
|
|
9
|
+
compare: string;
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
baseURL?: string;
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PullRequestCommand {
|
|
17
|
+
private program: Command;
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
this.program = new Command('pr')
|
|
21
|
+
.description('Generate a pull request title and summary from branch differences')
|
|
22
|
+
.requiredOption('--base <branch>', 'Base branch to diff against (e.g. main)')
|
|
23
|
+
.requiredOption('--compare <branch>', 'Compare branch to describe (e.g. feature/my-change)')
|
|
24
|
+
.option('-k, --api-key <key>', 'Override API key for this run')
|
|
25
|
+
.option('-b, --base-url <url>', 'Override API base URL')
|
|
26
|
+
.option('--model <model>', 'Override AI model for this run')
|
|
27
|
+
.action(this.handlePullRequest.bind(this));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async handlePullRequest(options: PullRequestOptions) {
|
|
31
|
+
try {
|
|
32
|
+
const existingConfig = ConfigService.getConfig();
|
|
33
|
+
|
|
34
|
+
const mergedApiKey = options.apiKey || existingConfig.apiKey;
|
|
35
|
+
const baseURLOverride = options.baseURL ?? options.baseUrl;
|
|
36
|
+
const mergedBaseURL = baseURLOverride || existingConfig.baseURL;
|
|
37
|
+
const mergedModel = options.model || existingConfig.model;
|
|
38
|
+
|
|
39
|
+
ConfigService.validateConfig({
|
|
40
|
+
apiKey: mergedApiKey,
|
|
41
|
+
language: existingConfig.language
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const diffResult = await GitService.getBranchDiff(options.base, options.compare);
|
|
45
|
+
|
|
46
|
+
if (!diffResult.success || !diffResult.diff) {
|
|
47
|
+
const err = diffResult.error || 'Unable to determine differences between branches.';
|
|
48
|
+
console.error('Error:', err);
|
|
49
|
+
await LogService.append({
|
|
50
|
+
command: 'pr',
|
|
51
|
+
args: { ...options, apiKey: options.apiKey ? '***' : undefined },
|
|
52
|
+
status: 'failure',
|
|
53
|
+
details: err
|
|
54
|
+
});
|
|
55
|
+
process.exit(1);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const aiService = new AIService({
|
|
60
|
+
apiKey: mergedApiKey!,
|
|
61
|
+
baseURL: mergedBaseURL,
|
|
62
|
+
model: mergedModel,
|
|
63
|
+
language: existingConfig.language,
|
|
64
|
+
verbose: false
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const aiResult = await aiService.generatePullRequestMessage(
|
|
68
|
+
options.base,
|
|
69
|
+
options.compare,
|
|
70
|
+
diffResult.diff
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!aiResult.success || !aiResult.message) {
|
|
74
|
+
const err = aiResult.error || 'Failed to generate pull request message.';
|
|
75
|
+
console.error('Error:', err);
|
|
76
|
+
await LogService.append({
|
|
77
|
+
command: 'pr',
|
|
78
|
+
args: { ...options, apiKey: options.apiKey ? '***' : undefined },
|
|
79
|
+
status: 'failure',
|
|
80
|
+
details: err
|
|
81
|
+
});
|
|
82
|
+
process.exit(1);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(aiResult.message);
|
|
87
|
+
await LogService.append({
|
|
88
|
+
command: 'pr',
|
|
89
|
+
args: { ...options, apiKey: options.apiKey ? '***' : undefined },
|
|
90
|
+
status: 'success'
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
console.error('Error:', message);
|
|
95
|
+
await LogService.append({
|
|
96
|
+
command: 'pr',
|
|
97
|
+
args: { ...options, apiKey: options.apiKey ? '***' : undefined },
|
|
98
|
+
status: 'failure',
|
|
99
|
+
details: message
|
|
100
|
+
});
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getCommand(): Command {
|
|
106
|
+
return this.program;
|
|
107
|
+
}
|
|
108
|
+
}
|