commit-analyzer 1.0.2 → 1.1.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/.claude/settings.local.json +11 -1
- package/README.md +33 -2
- package/commits.csv +2 -0
- package/eslint.config.mts +45 -0
- package/package.json +17 -9
- package/src/1.domain/analysis.ts +93 -0
- package/src/1.domain/analyzed-commit.ts +97 -0
- package/src/1.domain/application-error.ts +32 -0
- package/src/1.domain/category.ts +52 -0
- package/src/1.domain/commit-analysis-service.ts +92 -0
- package/src/1.domain/commit-hash.ts +40 -0
- package/src/1.domain/commit.ts +99 -0
- package/src/1.domain/date-formatting-service.ts +81 -0
- package/src/1.domain/date-range.ts +76 -0
- package/src/1.domain/report-generation-service.ts +292 -0
- package/src/2.application/analyze-commits.usecase.ts +307 -0
- package/src/2.application/generate-report.usecase.ts +204 -0
- package/src/2.application/llm-service.ts +54 -0
- package/src/2.application/resume-analysis.usecase.ts +123 -0
- package/src/3.presentation/analysis-repository.interface.ts +27 -0
- package/src/3.presentation/analyze-command.ts +128 -0
- package/src/3.presentation/cli-application.ts +255 -0
- package/src/3.presentation/command-handler.interface.ts +4 -0
- package/src/3.presentation/commit-analysis-controller.ts +101 -0
- package/src/3.presentation/commit-repository.interface.ts +47 -0
- package/src/3.presentation/console-formatter.ts +129 -0
- package/src/3.presentation/progress-repository.interface.ts +49 -0
- package/src/3.presentation/report-command.ts +50 -0
- package/src/3.presentation/resume-command.ts +59 -0
- package/src/3.presentation/storage-repository.interface.ts +33 -0
- package/src/3.presentation/storage-service.interface.ts +32 -0
- package/src/3.presentation/version-control-service.interface.ts +41 -0
- package/src/4.infrastructure/cache-service.ts +271 -0
- package/src/4.infrastructure/cached-analysis-repository.ts +46 -0
- package/src/4.infrastructure/claude-llm-adapter.ts +124 -0
- package/src/4.infrastructure/csv-service.ts +206 -0
- package/src/4.infrastructure/file-storage-repository.ts +108 -0
- package/src/4.infrastructure/file-system-storage-adapter.ts +87 -0
- package/src/4.infrastructure/gemini-llm-adapter.ts +46 -0
- package/src/4.infrastructure/git-adapter.ts +116 -0
- package/src/4.infrastructure/git-commit-repository.ts +85 -0
- package/src/4.infrastructure/json-progress-tracker.ts +182 -0
- package/src/4.infrastructure/llm-adapter-factory.ts +26 -0
- package/src/4.infrastructure/llm-adapter.ts +455 -0
- package/src/4.infrastructure/llm-analysis-repository.ts +38 -0
- package/src/4.infrastructure/openai-llm-adapter.ts +57 -0
- package/src/di.ts +108 -0
- package/src/main.ts +63 -0
- package/src/utils/app-paths.ts +36 -0
- package/src/utils/concurrency.ts +81 -0
- package/src/utils.ts +77 -0
- package/tsconfig.json +7 -1
- package/src/cli.ts +0 -170
- package/src/csv-reader.ts +0 -180
- package/src/csv.ts +0 -40
- package/src/errors.ts +0 -49
- package/src/git.ts +0 -112
- package/src/index.ts +0 -395
- package/src/llm.ts +0 -396
- package/src/progress.ts +0 -84
- package/src/report-generator.ts +0 -286
- package/src/types.ts +0 -24
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Analysis } from "@domain/analysis"
|
|
2
|
+
import { AnalyzedCommit } from "@domain/analyzed-commit"
|
|
3
|
+
import { Category } from "@domain/category"
|
|
4
|
+
import { Commit } from "@domain/commit"
|
|
5
|
+
import { CommitHash } from "@domain/commit-hash"
|
|
6
|
+
|
|
7
|
+
import { IStorageService } from "@presentation/storage-service.interface"
|
|
8
|
+
|
|
9
|
+
export class CSVService {
|
|
10
|
+
private static readonly CSV_HEADERS = "timestamp,category,summary,description"
|
|
11
|
+
private static readonly CSV_SPECIAL_CHARS = [",", '"', "\n"]
|
|
12
|
+
|
|
13
|
+
constructor(private readonly storageService: IStorageService) {}
|
|
14
|
+
|
|
15
|
+
async exportToCSV(
|
|
16
|
+
commits: AnalyzedCommit[],
|
|
17
|
+
filePath: string,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const csvContent = this.generateCSV(commits)
|
|
20
|
+
await this.storageService.writeFile(filePath, csvContent)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async importFromCSV(filePath: string): Promise<AnalyzedCommit[]> {
|
|
24
|
+
const content = await this.storageService.readFile(filePath)
|
|
25
|
+
return this.parseCSV(content)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private generateCSV(commits: AnalyzedCommit[]): string {
|
|
29
|
+
const rows = commits.map((commit) => this.formatRow(commit))
|
|
30
|
+
return [CSVService.CSV_HEADERS, ...rows].join("\n")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private formatRow(commit: AnalyzedCommit): string {
|
|
34
|
+
const row = commit.toCSVRow()
|
|
35
|
+
return this.joinCsvFields(row)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private joinCsvFields(row: {
|
|
39
|
+
timestamp: string
|
|
40
|
+
category: string
|
|
41
|
+
summary: string
|
|
42
|
+
description: string
|
|
43
|
+
}): string {
|
|
44
|
+
return [
|
|
45
|
+
row.timestamp,
|
|
46
|
+
this.escapeCsvField(row.category),
|
|
47
|
+
this.escapeCsvField(row.summary),
|
|
48
|
+
this.escapeCsvField(row.description),
|
|
49
|
+
].join(",")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private escapeCsvField(field: string): string {
|
|
53
|
+
if (this.needsEscaping(field)) {
|
|
54
|
+
return this.escapeAndQuoteField(field)
|
|
55
|
+
}
|
|
56
|
+
return field
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private needsEscaping(field: string): boolean {
|
|
60
|
+
return CSVService.CSV_SPECIAL_CHARS.some((char) => field.includes(char))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private escapeAndQuoteField(field: string): string {
|
|
64
|
+
return `"${field.replace(/"/g, '""')}"`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private parseCSV(content: string): AnalyzedCommit[] {
|
|
68
|
+
const lines = content.split("\n").filter((line) => line.trim().length > 0)
|
|
69
|
+
|
|
70
|
+
if (lines.length < 2) {
|
|
71
|
+
throw new Error("Invalid CSV format: no data rows found")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate header
|
|
75
|
+
const header = lines[0].toLowerCase()
|
|
76
|
+
const expectedHeader = "timestamp,category,summary,description"
|
|
77
|
+
if (header !== expectedHeader) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Invalid CSV format. Expected header: "${expectedHeader}", got: "${header}"`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Skip header row
|
|
84
|
+
const dataRows = lines.slice(1)
|
|
85
|
+
const commits: AnalyzedCommit[] = []
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < dataRows.length; i++) {
|
|
88
|
+
const row = dataRows[i]
|
|
89
|
+
try {
|
|
90
|
+
const commit = this.parseCSVRow(row)
|
|
91
|
+
commits.push(commit)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn(`Warning: Failed to parse CSV row ${i + 2}: ${row}`)
|
|
94
|
+
console.warn(`Error: ${error}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return commits
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private parseCSVRow(row: string): AnalyzedCommit {
|
|
102
|
+
const fields = this.parseCSVFields(row)
|
|
103
|
+
|
|
104
|
+
if (fields.length !== 4) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Expected 4 fields (timestamp,category,summary,description), got ${fields.length}`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const [timestampStr, category, summary, description] = fields
|
|
111
|
+
|
|
112
|
+
// Validate timestamp
|
|
113
|
+
const timestamp = new Date(timestampStr)
|
|
114
|
+
if (isNaN(timestamp.getTime())) {
|
|
115
|
+
throw new Error(`Invalid timestamp: ${timestampStr}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate category
|
|
119
|
+
if (!this.isValidCategory(category)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Invalid category: ${category}. Must be one of: tweak, feature, process`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Validate required fields
|
|
126
|
+
if (!summary.trim()) {
|
|
127
|
+
throw new Error("Summary field cannot be empty")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!description.trim()) {
|
|
131
|
+
throw new Error("Description field cannot be empty")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Create a minimal commit object for CSV import
|
|
135
|
+
// We'll use placeholder values for hash and diff since they're not in the CSV
|
|
136
|
+
const placeholderHash = CommitHash.create(
|
|
137
|
+
"0000000000000000000000000000000000000000",
|
|
138
|
+
)
|
|
139
|
+
const placeholderDiff = "# Placeholder diff for CSV import\n+1\n-0" // Minimal valid diff
|
|
140
|
+
const placeholderMessage = summary // Use summary as message
|
|
141
|
+
|
|
142
|
+
const commit = new Commit({
|
|
143
|
+
hash: placeholderHash,
|
|
144
|
+
message: placeholderMessage,
|
|
145
|
+
date: timestamp, // Use actual timestamp from CSV
|
|
146
|
+
diff: placeholderDiff,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Create analysis from CSV data
|
|
150
|
+
const analysisCategory = Category.create(category)
|
|
151
|
+
const analysis = new Analysis({
|
|
152
|
+
category: analysisCategory,
|
|
153
|
+
summary: summary.trim(),
|
|
154
|
+
description: description.trim(),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
return new AnalyzedCommit(commit, analysis)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse CSV fields handling quoted fields with commas and escaped quotes
|
|
162
|
+
*/
|
|
163
|
+
private parseCSVFields(line: string): string[] {
|
|
164
|
+
const fields: string[] = []
|
|
165
|
+
let currentField = ""
|
|
166
|
+
let inQuotes = false
|
|
167
|
+
let i = 0
|
|
168
|
+
|
|
169
|
+
while (i < line.length) {
|
|
170
|
+
const char = line[i]
|
|
171
|
+
const nextChar = line[i + 1]
|
|
172
|
+
|
|
173
|
+
if (char === '"') {
|
|
174
|
+
if (inQuotes && nextChar === '"') {
|
|
175
|
+
// Escaped quote inside quoted field
|
|
176
|
+
currentField += '"'
|
|
177
|
+
i += 2
|
|
178
|
+
} else {
|
|
179
|
+
// Start or end of quoted field
|
|
180
|
+
inQuotes = !inQuotes
|
|
181
|
+
i++
|
|
182
|
+
}
|
|
183
|
+
} else if (char === "," && !inQuotes) {
|
|
184
|
+
// Field separator outside quotes
|
|
185
|
+
fields.push(currentField)
|
|
186
|
+
currentField = ""
|
|
187
|
+
i++
|
|
188
|
+
} else {
|
|
189
|
+
// Regular character
|
|
190
|
+
currentField += char
|
|
191
|
+
i++
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Add the last field
|
|
196
|
+
fields.push(currentField)
|
|
197
|
+
|
|
198
|
+
return fields
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private isValidCategory(
|
|
202
|
+
category: string,
|
|
203
|
+
): category is "tweak" | "feature" | "process" {
|
|
204
|
+
return ["tweak", "feature", "process"].includes(category)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { AnalyzedCommit } from "@domain/analyzed-commit"
|
|
2
|
+
|
|
3
|
+
import { IStorageRepository } from "@presentation/storage-repository.interface"
|
|
4
|
+
import { IStorageService } from "@presentation/storage-service.interface"
|
|
5
|
+
|
|
6
|
+
import { CSVService } from "./csv-service"
|
|
7
|
+
|
|
8
|
+
export class FileStorageRepository implements IStorageRepository {
|
|
9
|
+
private readonly csvService: CSVService
|
|
10
|
+
|
|
11
|
+
constructor(private readonly storageService: IStorageService) {
|
|
12
|
+
this.csvService = new CSVService(storageService)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async exportToCSV(
|
|
16
|
+
commits: AnalyzedCommit[],
|
|
17
|
+
filePath: string,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
await this.csvService.exportToCSV(commits, filePath)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async importFromCSV(filePath: string): Promise<AnalyzedCommit[]> {
|
|
23
|
+
return this.csvService.importFromCSV(filePath)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async generateReport(
|
|
27
|
+
commits: AnalyzedCommit[],
|
|
28
|
+
outputPath: string,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
// Generate markdown report content
|
|
31
|
+
const reportContent = this.generateMarkdownReport(commits)
|
|
32
|
+
await this.storageService.writeFile(outputPath, reportContent)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async readCommitHashesFromFile(filePath: string): Promise<string[]> {
|
|
36
|
+
return this.storageService.readLines(filePath)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async ensureDirectoryExists(directoryPath: string): Promise<void> {
|
|
40
|
+
await this.storageService.ensureDirectory(directoryPath)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async writeFile(filePath: string, content: string): Promise<void> {
|
|
44
|
+
await this.storageService.writeFile(filePath, content)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private generateMarkdownReport(commits: AnalyzedCommit[]): string {
|
|
48
|
+
let content = "# Development Summary Report\n\n"
|
|
49
|
+
|
|
50
|
+
// Basic statistics
|
|
51
|
+
const totalCommits = commits.length
|
|
52
|
+
const years = commits.map((c) => c.getYear())
|
|
53
|
+
const minYear = Math.min(...years)
|
|
54
|
+
const maxYear = Math.max(...years)
|
|
55
|
+
|
|
56
|
+
const categoryBreakdown = commits.reduce(
|
|
57
|
+
(acc, commit) => {
|
|
58
|
+
const category = commit.getAnalysis().getCategory().getValue()
|
|
59
|
+
acc[category] = (acc[category] || 0) + 1
|
|
60
|
+
return acc
|
|
61
|
+
},
|
|
62
|
+
{} as Record<string, number>,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
content += `## Analysis Summary\n\n`
|
|
66
|
+
content += `**Total Commits Analyzed:** ${totalCommits}\n`
|
|
67
|
+
content += `**Time Period:** ${minYear} - ${maxYear}\n\n`
|
|
68
|
+
|
|
69
|
+
content += `### Breakdown by Category\n\n`
|
|
70
|
+
content += `- **Features:** ${categoryBreakdown.feature || 0} commits\n`
|
|
71
|
+
content += `- **Process/Infrastructure:** ${categoryBreakdown.process || 0} commits\n`
|
|
72
|
+
content += `- **Tweaks/Fixes:** ${categoryBreakdown.tweak || 0} commits\n\n`
|
|
73
|
+
|
|
74
|
+
// Group by year and add yearly summaries
|
|
75
|
+
const commitsByYear = new Map<number, AnalyzedCommit[]>()
|
|
76
|
+
for (const commit of commits) {
|
|
77
|
+
const year = commit.getYear()
|
|
78
|
+
if (!commitsByYear.has(year)) {
|
|
79
|
+
commitsByYear.set(year, [])
|
|
80
|
+
}
|
|
81
|
+
commitsByYear.get(year)!.push(commit)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
content += `## Yearly Development Highlights\n\n`
|
|
85
|
+
|
|
86
|
+
const sortedYears = Array.from(commitsByYear.keys()).sort((a, b) => b - a)
|
|
87
|
+
for (const year of sortedYears) {
|
|
88
|
+
const yearCommits = commitsByYear.get(year)!
|
|
89
|
+
const features = yearCommits.filter((c) =>
|
|
90
|
+
c.getAnalysis().isFeatureAnalysis(),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
content += `### ${year}\n\n`
|
|
94
|
+
content += `${yearCommits.length} commits total, including ${features.length} new features.\n\n`
|
|
95
|
+
|
|
96
|
+
// Show top features
|
|
97
|
+
if (features.length > 0) {
|
|
98
|
+
content += `**Key Features:**\n`
|
|
99
|
+
for (const feature of features.slice(0, 5)) {
|
|
100
|
+
content += `- ${feature.getAnalysis().getSummary()}\n`
|
|
101
|
+
}
|
|
102
|
+
content += "\n"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return content
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
unlinkSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "fs"
|
|
8
|
+
import { dirname, join } from "path"
|
|
9
|
+
|
|
10
|
+
import { IStorageService } from "@presentation/storage-service.interface"
|
|
11
|
+
|
|
12
|
+
import { getErrorMessage } from "../utils"
|
|
13
|
+
|
|
14
|
+
export class FileSystemStorageAdapter implements IStorageService {
|
|
15
|
+
private static readonly DEFAULT_ENCODING = "utf8"
|
|
16
|
+
|
|
17
|
+
async writeFile(filePath: string, content: string): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
// Ensure directory exists
|
|
20
|
+
const dir = dirname(filePath)
|
|
21
|
+
if (!existsSync(dir)) {
|
|
22
|
+
mkdirSync(dir, { recursive: true })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
writeFileSync(
|
|
26
|
+
filePath,
|
|
27
|
+
content,
|
|
28
|
+
FileSystemStorageAdapter.DEFAULT_ENCODING,
|
|
29
|
+
)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Failed to write file ${filePath}: ${getErrorMessage(error)}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async readFile(filePath: string): Promise<string> {
|
|
38
|
+
try {
|
|
39
|
+
return readFileSync(filePath, FileSystemStorageAdapter.DEFAULT_ENCODING)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Failed to read file ${filePath}: ${getErrorMessage(error)}`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async fileExists(filePath: string): Promise<boolean> {
|
|
48
|
+
return existsSync(filePath)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async ensureDirectory(directoryPath: string): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(directoryPath, { recursive: true })
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Failed to create directory ${directoryPath}: ${getErrorMessage(error)}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async deleteFile(filePath: string): Promise<void> {
|
|
62
|
+
try {
|
|
63
|
+
if (await this.fileExists(filePath)) {
|
|
64
|
+
unlinkSync(filePath)
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Failed to delete file ${filePath}: ${getErrorMessage(error)}`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async readLines(filePath: string): Promise<string[]> {
|
|
74
|
+
const content = await this.readFile(filePath)
|
|
75
|
+
return content
|
|
76
|
+
.split("\n")
|
|
77
|
+
.map((line) => line.trim())
|
|
78
|
+
.filter((line) => line.length > 0)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
resolveOutputPath(filename: string, outputDir?: string): string {
|
|
82
|
+
if (outputDir) {
|
|
83
|
+
return join(outputDir, filename)
|
|
84
|
+
}
|
|
85
|
+
return filename
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { execSync } from "child_process"
|
|
2
|
+
|
|
3
|
+
import { LLMAdapter } from "./llm-adapter"
|
|
4
|
+
|
|
5
|
+
export class GeminiLLMAdapter extends LLMAdapter {
|
|
6
|
+
private static readonly MAX_PROMPT_LENGTH = parseInt(
|
|
7
|
+
process.env.GEMINI_MAX_PROMPT_LENGTH || "100000",
|
|
8
|
+
10,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
protected getMaxPromptLength(): number {
|
|
12
|
+
return GeminiLLMAdapter.MAX_PROMPT_LENGTH
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async detectAvailableModels(): Promise<string[]> {
|
|
16
|
+
try {
|
|
17
|
+
execSync("command -v gemini", { stdio: "ignore" })
|
|
18
|
+
return ["gemini"]
|
|
19
|
+
} catch {
|
|
20
|
+
return []
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async isAvailable(): Promise<boolean> {
|
|
25
|
+
const available = await this.detectAvailableModels()
|
|
26
|
+
return available.length > 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
protected async executeModelCommand(prompt: string): Promise<string> {
|
|
30
|
+
const truncatedPrompt = this.truncatePrompt(prompt)
|
|
31
|
+
|
|
32
|
+
if (this.verbose) {
|
|
33
|
+
console.log(` - Prompt length: ${truncatedPrompt.length} characters`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const modelCommand = this.model || "gemini"
|
|
37
|
+
|
|
38
|
+
return execSync(modelCommand, {
|
|
39
|
+
input: truncatedPrompt,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
42
|
+
timeout: LLMAdapter.DEFAULT_TIMEOUT,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execSync } from "child_process"
|
|
2
|
+
|
|
3
|
+
import { IVersionControlService } from "@presentation/version-control-service.interface"
|
|
4
|
+
|
|
5
|
+
import { getErrorMessage } from "../utils"
|
|
6
|
+
|
|
7
|
+
export class GitAdapter implements IVersionControlService {
|
|
8
|
+
private static readonly LARGE_DIFF_BUFFER = 50 * 1024 * 1024 // 50MB buffer for large diffs
|
|
9
|
+
private static readonly EXEC_OPTIONS = {
|
|
10
|
+
encoding: "utf8" as const,
|
|
11
|
+
stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async getCommitInfo(hash: string): Promise<{
|
|
15
|
+
hash: string
|
|
16
|
+
message: string
|
|
17
|
+
date: Date
|
|
18
|
+
diff: string
|
|
19
|
+
}> {
|
|
20
|
+
try {
|
|
21
|
+
const showOutput = execSync(
|
|
22
|
+
`git show --format="%H|%s|%ci" --no-patch "${hash}"`,
|
|
23
|
+
GitAdapter.EXEC_OPTIONS,
|
|
24
|
+
).trim()
|
|
25
|
+
|
|
26
|
+
const [fullHash, message, dateStr] = showOutput.split("|")
|
|
27
|
+
const date = new Date(dateStr)
|
|
28
|
+
|
|
29
|
+
const diff = execSync(`git show "${hash}"`, {
|
|
30
|
+
...GitAdapter.EXEC_OPTIONS,
|
|
31
|
+
maxBuffer: GitAdapter.LARGE_DIFF_BUFFER,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
hash: fullHash,
|
|
36
|
+
message,
|
|
37
|
+
date,
|
|
38
|
+
diff,
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Failed to get commit info for ${hash}: ${getErrorMessage(error)}`,
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async validateCommitHash(hash: string): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
execSync(`git rev-parse --verify "${hash}"`, GitAdapter.EXEC_OPTIONS)
|
|
50
|
+
return true
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async isValidRepository(): Promise<boolean> {
|
|
57
|
+
try {
|
|
58
|
+
execSync("git rev-parse --git-dir", GitAdapter.EXEC_OPTIONS)
|
|
59
|
+
return true
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getCurrentUserEmail(): Promise<string> {
|
|
66
|
+
try {
|
|
67
|
+
return execSync("git config user.email", GitAdapter.EXEC_OPTIONS).trim()
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Failed to get current user email: ${getErrorMessage(error)}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getCurrentUserName(): Promise<string> {
|
|
76
|
+
try {
|
|
77
|
+
return execSync("git config user.name", GitAdapter.EXEC_OPTIONS).trim()
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Failed to get current user name: ${getErrorMessage(error)}`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getUserAuthoredCommits(params: {
|
|
86
|
+
authorEmail: string
|
|
87
|
+
limit?: number
|
|
88
|
+
since?: string
|
|
89
|
+
until?: string
|
|
90
|
+
}): Promise<string[]> {
|
|
91
|
+
const { authorEmail, limit, since, until } = params
|
|
92
|
+
try {
|
|
93
|
+
const limitFlag = limit ? `--max-count=${limit}` : ""
|
|
94
|
+
const sinceFlag = since ? `--since="${since}"` : ""
|
|
95
|
+
const untilFlag = until ? `--until="${until}"` : ""
|
|
96
|
+
|
|
97
|
+
const output = execSync(
|
|
98
|
+
`git log --author="${authorEmail}" --format="%H" --no-merges ${limitFlag} ${sinceFlag} ${untilFlag}`,
|
|
99
|
+
GitAdapter.EXEC_OPTIONS,
|
|
100
|
+
).trim()
|
|
101
|
+
|
|
102
|
+
return this.parseCommitHashes(output)
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Failed to get user authored commits: ${getErrorMessage(error)}`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private parseCommitHashes(output: string): string[] {
|
|
111
|
+
if (!output) {
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
return output.split("\n").filter((hash) => hash.length > 0)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Commit } from "@domain/commit"
|
|
2
|
+
import { CommitHash } from "@domain/commit-hash"
|
|
3
|
+
|
|
4
|
+
import { ICommitRepository } from "@presentation/commit-repository.interface"
|
|
5
|
+
import { IVersionControlService } from "@presentation/version-control-service.interface"
|
|
6
|
+
|
|
7
|
+
export class GitCommitRepository implements ICommitRepository {
|
|
8
|
+
constructor(private readonly versionControlService: IVersionControlService) {}
|
|
9
|
+
|
|
10
|
+
async getByHash(hash: CommitHash): Promise<Commit> {
|
|
11
|
+
const commitInfo = await this.versionControlService.getCommitInfo(
|
|
12
|
+
hash.getValue(),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
return new Commit({
|
|
16
|
+
hash,
|
|
17
|
+
message: commitInfo.message,
|
|
18
|
+
date: commitInfo.date,
|
|
19
|
+
diff: commitInfo.diff,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getByAuthor(params: {
|
|
24
|
+
authorEmail: string
|
|
25
|
+
limit?: number
|
|
26
|
+
since?: string
|
|
27
|
+
until?: string
|
|
28
|
+
}): Promise<Commit[]> {
|
|
29
|
+
const { authorEmail, limit, since, until } = params
|
|
30
|
+
const commitHashes =
|
|
31
|
+
await this.versionControlService.getUserAuthoredCommits({
|
|
32
|
+
authorEmail,
|
|
33
|
+
limit,
|
|
34
|
+
since,
|
|
35
|
+
until,
|
|
36
|
+
})
|
|
37
|
+
const commits: Commit[] = []
|
|
38
|
+
|
|
39
|
+
for (const hashString of commitHashes) {
|
|
40
|
+
try {
|
|
41
|
+
const hash = CommitHash.create(hashString)
|
|
42
|
+
const commit = await this.getByHash(hash)
|
|
43
|
+
commits.push(commit)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.warn(`Warning: Failed to load commit ${hashString}:`, error)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return commits
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getByHashes(hashes: CommitHash[]): Promise<Commit[]> {
|
|
53
|
+
const commits: Commit[] = []
|
|
54
|
+
|
|
55
|
+
for (const hash of hashes) {
|
|
56
|
+
try {
|
|
57
|
+
const commit = await this.getByHash(hash)
|
|
58
|
+
commits.push(commit)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.warn(
|
|
61
|
+
`Warning: Failed to load commit ${hash.getShortHash()}:`,
|
|
62
|
+
error,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return commits
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async exists(hash: CommitHash): Promise<boolean> {
|
|
71
|
+
return this.versionControlService.validateCommitHash(hash.getValue())
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getCurrentUserEmail(): Promise<string> {
|
|
75
|
+
return this.versionControlService.getCurrentUserEmail()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getCurrentUserName(): Promise<string> {
|
|
79
|
+
return this.versionControlService.getCurrentUserName()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async isValidRepository(): Promise<boolean> {
|
|
83
|
+
return this.versionControlService.isValidRepository()
|
|
84
|
+
}
|
|
85
|
+
}
|