commit-analyzer 1.1.4 → 1.1.6
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/README.md +164 -82
- package/dist/main.ts +0 -0
- package/package.json +2 -1
- package/.claude/settings.local.json +0 -23
- package/commits.csv +0 -2
- package/csv-to-report-prompt.md +0 -97
- package/eslint.config.mts +0 -45
- package/prompt.md +0 -69
- package/src/1.domain/analysis.ts +0 -93
- package/src/1.domain/analyzed-commit.ts +0 -97
- package/src/1.domain/application-error.ts +0 -32
- package/src/1.domain/category.ts +0 -52
- package/src/1.domain/commit-analysis-service.ts +0 -92
- package/src/1.domain/commit-hash.ts +0 -40
- package/src/1.domain/commit.ts +0 -99
- package/src/1.domain/date-formatting-service.ts +0 -81
- package/src/1.domain/date-range.ts +0 -76
- package/src/1.domain/report-generation-service.ts +0 -443
- package/src/2.application/analyze-commits.usecase.ts +0 -307
- package/src/2.application/generate-report.usecase.ts +0 -209
- package/src/2.application/llm-service.ts +0 -54
- package/src/2.application/resume-analysis.usecase.ts +0 -123
- package/src/3.presentation/analysis-repository.interface.ts +0 -27
- package/src/3.presentation/analyze-command.ts +0 -128
- package/src/3.presentation/cli-application.ts +0 -278
- package/src/3.presentation/command-handler.interface.ts +0 -4
- package/src/3.presentation/commit-analysis-controller.ts +0 -101
- package/src/3.presentation/commit-repository.interface.ts +0 -47
- package/src/3.presentation/console-formatter.ts +0 -129
- package/src/3.presentation/progress-repository.interface.ts +0 -49
- package/src/3.presentation/report-command.ts +0 -50
- package/src/3.presentation/resume-command.ts +0 -59
- package/src/3.presentation/storage-repository.interface.ts +0 -33
- package/src/3.presentation/storage-service.interface.ts +0 -32
- package/src/3.presentation/version-control-service.interface.ts +0 -46
- package/src/4.infrastructure/cache-service.ts +0 -271
- package/src/4.infrastructure/cached-analysis-repository.ts +0 -46
- package/src/4.infrastructure/claude-llm-adapter.ts +0 -124
- package/src/4.infrastructure/csv-service.ts +0 -252
- package/src/4.infrastructure/file-storage-repository.ts +0 -108
- package/src/4.infrastructure/file-system-storage-adapter.ts +0 -87
- package/src/4.infrastructure/gemini-llm-adapter.ts +0 -46
- package/src/4.infrastructure/git-adapter.ts +0 -143
- package/src/4.infrastructure/git-commit-repository.ts +0 -85
- package/src/4.infrastructure/json-progress-tracker.ts +0 -182
- package/src/4.infrastructure/llm-adapter-factory.ts +0 -26
- package/src/4.infrastructure/llm-adapter.ts +0 -485
- package/src/4.infrastructure/llm-analysis-repository.ts +0 -38
- package/src/4.infrastructure/openai-llm-adapter.ts +0 -57
- package/src/di.ts +0 -109
- package/src/main.ts +0 -63
- package/src/utils/app-paths.ts +0 -36
- package/src/utils/concurrency.ts +0 -81
- package/src/utils.ts +0 -77
- package/tsconfig.json +0 -25
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { Analysis } from "@domain/analysis"
|
|
2
|
-
import { Commit } from "@domain/commit"
|
|
3
|
-
|
|
4
|
-
import { IAnalysisRepository } from "@presentation/analysis-repository.interface"
|
|
5
|
-
|
|
6
|
-
import { CacheService } from "./cache-service"
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Cached analysis repository that wraps another analysis repository
|
|
10
|
-
*/
|
|
11
|
-
export class CachedAnalysisRepository implements IAnalysisRepository {
|
|
12
|
-
constructor(
|
|
13
|
-
private readonly baseRepository: IAnalysisRepository,
|
|
14
|
-
private readonly cacheService: CacheService,
|
|
15
|
-
) {}
|
|
16
|
-
|
|
17
|
-
async analyze(commit: Commit): Promise<Analysis> {
|
|
18
|
-
const commitHash = commit.getHash().getValue()
|
|
19
|
-
|
|
20
|
-
// Try to get from cache first
|
|
21
|
-
const cachedAnalysis = await this.cacheService.get(commitHash)
|
|
22
|
-
if (cachedAnalysis) {
|
|
23
|
-
return cachedAnalysis
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Cache miss - analyze with base repository
|
|
27
|
-
const analysis = await this.baseRepository.analyze(commit)
|
|
28
|
-
|
|
29
|
-
// Store in cache for next time
|
|
30
|
-
await this.cacheService.set(commitHash, analysis)
|
|
31
|
-
|
|
32
|
-
return analysis
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async isAvailable(): Promise<boolean> {
|
|
36
|
-
return this.baseRepository.isAvailable()
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
getMaxRetries(): number {
|
|
40
|
-
return this.baseRepository.getMaxRetries()
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
setVerbose(verbose: boolean): void {
|
|
44
|
-
this.baseRepository.setVerbose(verbose)
|
|
45
|
-
}
|
|
46
|
-
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { execSync } from "child_process"
|
|
2
|
-
|
|
3
|
-
import { CategoryType } from "@domain/category"
|
|
4
|
-
|
|
5
|
-
import { LLMAdapter } from "./llm-adapter"
|
|
6
|
-
|
|
7
|
-
export class ClaudeLLMAdapter extends LLMAdapter {
|
|
8
|
-
private static readonly MAX_PROMPT_LENGTH = parseInt(
|
|
9
|
-
process.env.CLAUDE_MAX_PROMPT_LENGTH || "100000",
|
|
10
|
-
10,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
protected getMaxPromptLength(): number {
|
|
14
|
-
return ClaudeLLMAdapter.MAX_PROMPT_LENGTH
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async detectAvailableModels(): Promise<string[]> {
|
|
18
|
-
try {
|
|
19
|
-
execSync("command -v claude", { stdio: "ignore" })
|
|
20
|
-
return ["claude", "claude --model sonnet", "claude --model haiku"]
|
|
21
|
-
} catch {
|
|
22
|
-
return []
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async isAvailable(): Promise<boolean> {
|
|
27
|
-
const available = await this.detectAvailableModels()
|
|
28
|
-
return available.length > 0
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
protected async executeModelCommand(prompt: string): Promise<string> {
|
|
32
|
-
const truncatedPrompt = this.truncatePrompt(prompt)
|
|
33
|
-
|
|
34
|
-
if (this.verbose) {
|
|
35
|
-
console.log(` - Prompt length: ${truncatedPrompt.length} characters`)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const modelCommand = this.model || "claude --model sonnet"
|
|
39
|
-
|
|
40
|
-
return execSync(modelCommand, {
|
|
41
|
-
input: truncatedPrompt,
|
|
42
|
-
encoding: "utf8",
|
|
43
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
44
|
-
timeout: LLMAdapter.DEFAULT_TIMEOUT,
|
|
45
|
-
})
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
protected parseResponse(response: string): {
|
|
49
|
-
category: CategoryType
|
|
50
|
-
summary: string
|
|
51
|
-
description: string
|
|
52
|
-
} {
|
|
53
|
-
try {
|
|
54
|
-
// First try standard JSON parsing
|
|
55
|
-
return super.parseResponse(response)
|
|
56
|
-
} catch {
|
|
57
|
-
// Claude often responds in natural language format, so try to parse that
|
|
58
|
-
if (this.verbose) {
|
|
59
|
-
console.log(` - Standard JSON parsing failed, trying Claude natural language parsing...`)
|
|
60
|
-
}
|
|
61
|
-
return this.parseClaudeNaturalLanguageResponse(response)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
private parseClaudeNaturalLanguageResponse(response: string): {
|
|
66
|
-
category: CategoryType
|
|
67
|
-
summary: string
|
|
68
|
-
description: string
|
|
69
|
-
} {
|
|
70
|
-
// Try to extract category from various Claude response patterns
|
|
71
|
-
const categoryMatch = response.match(/\*\*?Category\*\*?:?\s*(tweak|feature|process)/i) ||
|
|
72
|
-
response.match(/Category:\s*(tweak|feature|process)/i) ||
|
|
73
|
-
response.match(/\*\*(tweak|feature|process)\*\*/i) ||
|
|
74
|
-
response.match(/(tweak|feature|process)\s*commit/i) ||
|
|
75
|
-
response.match(/should be categorized as[:\s]*\*\*?(tweak|feature|process)/i)
|
|
76
|
-
const category = categoryMatch?.[1]?.toLowerCase()
|
|
77
|
-
|
|
78
|
-
if (!category || !this.isValidCategory(category)) {
|
|
79
|
-
if (this.verbose) {
|
|
80
|
-
console.log(` - Failed to extract category from Claude response`)
|
|
81
|
-
console.log(` - Response snippet: ${response.substring(0, 500)}`)
|
|
82
|
-
}
|
|
83
|
-
throw new Error(`Could not extract valid category from Claude response`)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Try to extract summary from patterns
|
|
87
|
-
const summaryMatch = response.match(/\*\*?Summary\*\*?:?\s*([^\n\r]+)/i) ||
|
|
88
|
-
response.match(/Summary:\s*([^\n\r]+)/i)
|
|
89
|
-
let summary = summaryMatch?.[1]?.trim()
|
|
90
|
-
|
|
91
|
-
if (!summary) {
|
|
92
|
-
// Fallback: try to find a descriptive line
|
|
93
|
-
const lines = response.split('\n').filter(line => line.trim())
|
|
94
|
-
summary = lines.find(line =>
|
|
95
|
-
line.includes('refactor') ||
|
|
96
|
-
line.includes('add') ||
|
|
97
|
-
line.includes('fix') ||
|
|
98
|
-
line.includes('update') ||
|
|
99
|
-
line.includes('implement')
|
|
100
|
-
)?.trim() || "Code changes"
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Try to extract description
|
|
104
|
-
const descMatch = response.match(/\*\*?Description\*\*?:?\s*([\s\S]+?)(?=\n\n|\n\*\*|\n---|\n#|$)/i) ||
|
|
105
|
-
response.match(/Description:\s*([\s\S]+?)(?=\n\n|\n\*\*|\n---|\n#|$)/i)
|
|
106
|
-
let description = descMatch?.[1]?.trim()
|
|
107
|
-
|
|
108
|
-
if (!description) {
|
|
109
|
-
// Fallback: extract the longest meaningful sentence
|
|
110
|
-
const sentences = response.split(/[.!?]+/).filter(s => s.trim().length > 20)
|
|
111
|
-
description = sentences[0]?.trim() || "Commit contains code changes"
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Clean up and truncate
|
|
115
|
-
summary = summary.substring(0, 80).replace(/[*"]/g, '').trim()
|
|
116
|
-
description = description.replace(/[*"]/g, '').trim()
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
category: category as CategoryType,
|
|
120
|
-
summary: summary || "Code changes",
|
|
121
|
-
description: description || "This commit contains code changes."
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
@@ -1,252 +0,0 @@
|
|
|
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
|
-
/**
|
|
68
|
-
* Split CSV content into rows, properly handling quoted fields that may contain newlines
|
|
69
|
-
*/
|
|
70
|
-
private splitCSVIntoRows(content: string): string[] {
|
|
71
|
-
const rows: string[] = []
|
|
72
|
-
let currentRow = ""
|
|
73
|
-
let inQuotes = false
|
|
74
|
-
let i = 0
|
|
75
|
-
|
|
76
|
-
while (i < content.length) {
|
|
77
|
-
const char = content[i]
|
|
78
|
-
const nextChar = content[i + 1]
|
|
79
|
-
|
|
80
|
-
if (char === '"') {
|
|
81
|
-
if (inQuotes && nextChar === '"') {
|
|
82
|
-
// Escaped quote inside quoted field
|
|
83
|
-
currentRow += '""'
|
|
84
|
-
i += 2
|
|
85
|
-
} else {
|
|
86
|
-
// Start or end of quoted field
|
|
87
|
-
inQuotes = !inQuotes
|
|
88
|
-
currentRow += '"'
|
|
89
|
-
i++
|
|
90
|
-
}
|
|
91
|
-
} else if (char === "\n" && !inQuotes) {
|
|
92
|
-
// Row separator outside quotes
|
|
93
|
-
if (currentRow.trim().length > 0) {
|
|
94
|
-
rows.push(currentRow)
|
|
95
|
-
}
|
|
96
|
-
currentRow = ""
|
|
97
|
-
i++
|
|
98
|
-
} else {
|
|
99
|
-
// Regular character (including newlines inside quotes)
|
|
100
|
-
currentRow += char
|
|
101
|
-
i++
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Add the last row if it exists
|
|
106
|
-
if (currentRow.trim().length > 0) {
|
|
107
|
-
rows.push(currentRow)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return rows
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
private parseCSV(content: string): AnalyzedCommit[] {
|
|
114
|
-
const rows = this.splitCSVIntoRows(content)
|
|
115
|
-
|
|
116
|
-
if (rows.length < 2) {
|
|
117
|
-
throw new Error("Invalid CSV format: no data rows found")
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Validate header
|
|
121
|
-
const header = rows[0].toLowerCase()
|
|
122
|
-
const expectedHeader = "timestamp,category,summary,description"
|
|
123
|
-
if (header !== expectedHeader) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`Invalid CSV format. Expected header: "${expectedHeader}", got: "${header}"`,
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Skip header row
|
|
130
|
-
const dataRows = rows.slice(1)
|
|
131
|
-
const commits: AnalyzedCommit[] = []
|
|
132
|
-
|
|
133
|
-
for (let i = 0; i < dataRows.length; i++) {
|
|
134
|
-
const row = dataRows[i]
|
|
135
|
-
try {
|
|
136
|
-
const commit = this.parseCSVRow(row)
|
|
137
|
-
commits.push(commit)
|
|
138
|
-
} catch (error) {
|
|
139
|
-
console.warn(`Warning: Failed to parse CSV row ${i + 2}: ${row}`)
|
|
140
|
-
console.warn(`Error: ${error}`)
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return commits
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private parseCSVRow(row: string): AnalyzedCommit {
|
|
148
|
-
const fields = this.parseCSVFields(row)
|
|
149
|
-
|
|
150
|
-
if (fields.length !== 4) {
|
|
151
|
-
throw new Error(
|
|
152
|
-
`Expected 4 fields (timestamp,category,summary,description), got ${fields.length}`,
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const [timestampStr, category, summary, description] = fields
|
|
157
|
-
|
|
158
|
-
// Validate timestamp
|
|
159
|
-
const timestamp = new Date(timestampStr)
|
|
160
|
-
if (isNaN(timestamp.getTime())) {
|
|
161
|
-
throw new Error(`Invalid timestamp: ${timestampStr}`)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Validate category
|
|
165
|
-
if (!this.isValidCategory(category)) {
|
|
166
|
-
throw new Error(
|
|
167
|
-
`Invalid category: ${category}. Must be one of: tweak, feature, process`,
|
|
168
|
-
)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Validate required fields
|
|
172
|
-
if (!summary.trim()) {
|
|
173
|
-
throw new Error("Summary field cannot be empty")
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (!description.trim()) {
|
|
177
|
-
throw new Error("Description field cannot be empty")
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Create a minimal commit object for CSV import
|
|
181
|
-
// We'll use placeholder values for hash and diff since they're not in the CSV
|
|
182
|
-
const placeholderHash = CommitHash.create(
|
|
183
|
-
"0000000000000000000000000000000000000000",
|
|
184
|
-
)
|
|
185
|
-
const placeholderDiff = "# Placeholder diff for CSV import\n+1\n-0" // Minimal valid diff
|
|
186
|
-
const placeholderMessage = summary // Use summary as message
|
|
187
|
-
|
|
188
|
-
const commit = new Commit({
|
|
189
|
-
hash: placeholderHash,
|
|
190
|
-
message: placeholderMessage,
|
|
191
|
-
date: timestamp, // Use actual timestamp from CSV
|
|
192
|
-
diff: placeholderDiff,
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
// Create analysis from CSV data
|
|
196
|
-
const analysisCategory = Category.create(category)
|
|
197
|
-
const analysis = new Analysis({
|
|
198
|
-
category: analysisCategory,
|
|
199
|
-
summary: summary.trim(),
|
|
200
|
-
description: description.trim(),
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
return new AnalyzedCommit(commit, analysis)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Parse CSV fields handling quoted fields with commas and escaped quotes
|
|
208
|
-
*/
|
|
209
|
-
private parseCSVFields(line: string): string[] {
|
|
210
|
-
const fields: string[] = []
|
|
211
|
-
let currentField = ""
|
|
212
|
-
let inQuotes = false
|
|
213
|
-
let i = 0
|
|
214
|
-
|
|
215
|
-
while (i < line.length) {
|
|
216
|
-
const char = line[i]
|
|
217
|
-
const nextChar = line[i + 1]
|
|
218
|
-
|
|
219
|
-
if (char === '"') {
|
|
220
|
-
if (inQuotes && nextChar === '"') {
|
|
221
|
-
// Escaped quote inside quoted field
|
|
222
|
-
currentField += '"'
|
|
223
|
-
i += 2
|
|
224
|
-
} else {
|
|
225
|
-
// Start or end of quoted field
|
|
226
|
-
inQuotes = !inQuotes
|
|
227
|
-
i++
|
|
228
|
-
}
|
|
229
|
-
} else if (char === "," && !inQuotes) {
|
|
230
|
-
// Field separator outside quotes
|
|
231
|
-
fields.push(currentField)
|
|
232
|
-
currentField = ""
|
|
233
|
-
i++
|
|
234
|
-
} else {
|
|
235
|
-
// Regular character
|
|
236
|
-
currentField += char
|
|
237
|
-
i++
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Add the last field
|
|
242
|
-
fields.push(currentField)
|
|
243
|
-
|
|
244
|
-
return fields
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private isValidCategory(
|
|
248
|
-
category: string,
|
|
249
|
-
): category is "tweak" | "feature" | "process" {
|
|
250
|
-
return ["tweak", "feature", "process"].includes(category)
|
|
251
|
-
}
|
|
252
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
|