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,49 @@
|
|
|
1
|
+
import { AnalyzedCommit } from "@domain/analyzed-commit"
|
|
2
|
+
import { CommitHash } from "@domain/commit-hash"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Progress state for tracking analysis progress
|
|
6
|
+
*/
|
|
7
|
+
export interface ProgressState {
|
|
8
|
+
totalCommits: CommitHash[]
|
|
9
|
+
processedCommits: CommitHash[]
|
|
10
|
+
analyzedCommits: AnalyzedCommit[]
|
|
11
|
+
lastProcessedIndex: number
|
|
12
|
+
startTime: Date
|
|
13
|
+
outputFile: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Repository interface for progress tracking operations
|
|
18
|
+
*/
|
|
19
|
+
export interface IProgressRepository {
|
|
20
|
+
/**
|
|
21
|
+
* Saves the current progress state
|
|
22
|
+
*/
|
|
23
|
+
saveProgress(state: ProgressState): Promise<void>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Loads the saved progress state
|
|
27
|
+
*/
|
|
28
|
+
loadProgress(): Promise<ProgressState | null>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Checks if there is saved progress
|
|
32
|
+
*/
|
|
33
|
+
hasProgress(): Promise<boolean>
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Clears any saved progress
|
|
37
|
+
*/
|
|
38
|
+
clearProgress(): Promise<void>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Gets the remaining commits from a progress state
|
|
42
|
+
*/
|
|
43
|
+
getRemainingCommits(state: ProgressState): CommitHash[]
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Formats a progress summary for display
|
|
47
|
+
*/
|
|
48
|
+
formatProgressSummary(state: ProgressState): string
|
|
49
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { GenerateReportUseCase } from "@app/generate-report.usecase"
|
|
2
|
+
|
|
3
|
+
import { ConsoleFormatter } from "./console-formatter"
|
|
4
|
+
|
|
5
|
+
export interface ReportCommandOptions {
|
|
6
|
+
inputCsv?: string
|
|
7
|
+
output: string
|
|
8
|
+
includeStatistics?: boolean
|
|
9
|
+
sourceInfo?: {
|
|
10
|
+
type: "author" | "commits" | "csv"
|
|
11
|
+
value: string
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ReportCommand {
|
|
16
|
+
constructor(private readonly generateReportUseCase: GenerateReportUseCase) {}
|
|
17
|
+
|
|
18
|
+
async execute(options: ReportCommandOptions): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
console.log("\nGenerating report from existing CSV...")
|
|
21
|
+
|
|
22
|
+
const result = await this.generateReportUseCase.handle({
|
|
23
|
+
inputCsvPath: options.inputCsv,
|
|
24
|
+
outputPath: options.output,
|
|
25
|
+
includeStatistics: options.includeStatistics ?? true,
|
|
26
|
+
sourceInfo:
|
|
27
|
+
options.sourceInfo ||
|
|
28
|
+
(options.inputCsv
|
|
29
|
+
? { type: "csv", value: options.inputCsv }
|
|
30
|
+
: undefined),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
ConsoleFormatter.logReport(`Report generated: ${result.reportPath}`)
|
|
34
|
+
|
|
35
|
+
const stats = result.statistics
|
|
36
|
+
ConsoleFormatter.logSuccess(
|
|
37
|
+
`Processed ${result.commitsProcessed} commits`,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
ConsoleFormatter.logInfo(
|
|
41
|
+
`Categories: ${stats.categoryBreakdown.feature} features, ${stats.categoryBreakdown.process} process, ${stats.categoryBreakdown.tweak} tweaks`,
|
|
42
|
+
)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
ConsoleFormatter.logError(
|
|
45
|
+
error instanceof Error ? error.message : "Unknown error occurred",
|
|
46
|
+
)
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ResumeAnalysisUseCase } from "@app/resume-analysis.usecase"
|
|
2
|
+
|
|
3
|
+
import { ConsoleFormatter } from "./console-formatter"
|
|
4
|
+
|
|
5
|
+
export interface ResumeCommandOptions {
|
|
6
|
+
verbose?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ResumeCommand {
|
|
10
|
+
constructor(private readonly resumeAnalysisUseCase: ResumeAnalysisUseCase) {}
|
|
11
|
+
|
|
12
|
+
async execute(options: ResumeCommandOptions): Promise<boolean> {
|
|
13
|
+
try {
|
|
14
|
+
const result = await this.resumeAnalysisUseCase.handle({
|
|
15
|
+
verbose: options.verbose,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
if (!result) {
|
|
19
|
+
ConsoleFormatter.logInfo(
|
|
20
|
+
"No previous checkpoint found or user chose to start fresh",
|
|
21
|
+
)
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Display results
|
|
26
|
+
ConsoleFormatter.logSuccess(
|
|
27
|
+
"Analysis resumed and completed successfully!",
|
|
28
|
+
)
|
|
29
|
+
ConsoleFormatter.logInfo(
|
|
30
|
+
`Total analyzed: ${result.analyzedCommits.length} commits`,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if (result.failedCommits > 0) {
|
|
34
|
+
ConsoleFormatter.logWarning(
|
|
35
|
+
`Failed commits during resume: ${result.failedCommits}`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Display category summary
|
|
40
|
+
const summary = result.analyzedCommits.reduce(
|
|
41
|
+
(acc, commit) => {
|
|
42
|
+
const category = commit.getAnalysis().getCategory().getValue()
|
|
43
|
+
acc[category] = (acc[category] || 0) + 1
|
|
44
|
+
return acc
|
|
45
|
+
},
|
|
46
|
+
{} as Record<string, number>,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
ConsoleFormatter.displayAnalysisSummary(summary)
|
|
50
|
+
|
|
51
|
+
return true
|
|
52
|
+
} catch (error) {
|
|
53
|
+
ConsoleFormatter.logError(
|
|
54
|
+
error instanceof Error ? error.message : "Unknown error occurred",
|
|
55
|
+
)
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AnalyzedCommit } from "@domain/analyzed-commit"
|
|
2
|
+
|
|
3
|
+
export interface IStorageRepository {
|
|
4
|
+
/**
|
|
5
|
+
* Exports analyzed commits to CSV format
|
|
6
|
+
*/
|
|
7
|
+
exportToCSV(commits: AnalyzedCommit[], filePath: string): Promise<void>
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Imports commits from CSV format
|
|
11
|
+
*/
|
|
12
|
+
importFromCSV(filePath: string): Promise<AnalyzedCommit[]>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generates a markdown report from analyzed commits
|
|
16
|
+
*/
|
|
17
|
+
generateReport(commits: AnalyzedCommit[], outputPath: string): Promise<void>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads commits from a file (one hash per line)
|
|
21
|
+
*/
|
|
22
|
+
readCommitHashesFromFile(filePath: string): Promise<string[]>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensures a directory exists
|
|
26
|
+
*/
|
|
27
|
+
ensureDirectoryExists(directoryPath: string): Promise<void>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Writes content to a file
|
|
31
|
+
*/
|
|
32
|
+
writeFile(filePath: string, content: string): Promise<void>
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface IStorageService {
|
|
2
|
+
/**
|
|
3
|
+
* Writes content to a file
|
|
4
|
+
*/
|
|
5
|
+
writeFile(filePath: string, content: string): Promise<void>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads content from a file
|
|
9
|
+
*/
|
|
10
|
+
readFile(filePath: string): Promise<string>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Checks if a file exists
|
|
14
|
+
*/
|
|
15
|
+
fileExists(filePath: string): Promise<boolean>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a directory if it doesn't exist
|
|
19
|
+
*/
|
|
20
|
+
ensureDirectory(directoryPath: string): Promise<void>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Deletes a file
|
|
24
|
+
*/
|
|
25
|
+
deleteFile(filePath: string): Promise<void>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reads lines from a file
|
|
29
|
+
*/
|
|
30
|
+
readLines(filePath: string): Promise<string[]>
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface IVersionControlService {
|
|
2
|
+
/**
|
|
3
|
+
* Gets detailed commit information by hash
|
|
4
|
+
*/
|
|
5
|
+
getCommitInfo(hash: string): Promise<{
|
|
6
|
+
hash: string
|
|
7
|
+
message: string
|
|
8
|
+
date: Date
|
|
9
|
+
diff: string
|
|
10
|
+
}>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validates if a commit hash exists
|
|
14
|
+
*/
|
|
15
|
+
validateCommitHash(hash: string): Promise<boolean>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Checks if current directory is a valid repository
|
|
19
|
+
*/
|
|
20
|
+
isValidRepository(): Promise<boolean>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gets current user's email from repository config
|
|
24
|
+
*/
|
|
25
|
+
getCurrentUserEmail(): Promise<string>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Gets current user's name from repository config
|
|
29
|
+
*/
|
|
30
|
+
getCurrentUserName(): Promise<string>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gets commits authored by a specific user
|
|
34
|
+
*/
|
|
35
|
+
getUserAuthoredCommits(params: {
|
|
36
|
+
authorEmail: string
|
|
37
|
+
limit?: number
|
|
38
|
+
since?: string
|
|
39
|
+
until?: string
|
|
40
|
+
}): Promise<string[]>
|
|
41
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { promises as fs } from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
|
|
4
|
+
import { Analysis } from "@domain/analysis"
|
|
5
|
+
import { Category, CategoryType } from "@domain/category"
|
|
6
|
+
|
|
7
|
+
import { AppPaths } from "../utils/app-paths"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cache entry for storing analyzed commit results
|
|
11
|
+
*/
|
|
12
|
+
interface CacheEntry {
|
|
13
|
+
hash: string
|
|
14
|
+
timestamp: number
|
|
15
|
+
analysis: {
|
|
16
|
+
category: string
|
|
17
|
+
summary: string
|
|
18
|
+
description: string
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Service for caching analyzed commit results
|
|
24
|
+
*/
|
|
25
|
+
export class CacheService {
|
|
26
|
+
private static readonly DEFAULT_TTL_DAYS = 30
|
|
27
|
+
private static readonly CACHE_FILE_PREFIX = "commit-"
|
|
28
|
+
|
|
29
|
+
private readonly cacheDir: string
|
|
30
|
+
private readonly ttlMs: number
|
|
31
|
+
private cacheEnabled: boolean = true
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
baseDir: string = process.cwd(),
|
|
35
|
+
ttlDays: number = CacheService.DEFAULT_TTL_DAYS
|
|
36
|
+
) {
|
|
37
|
+
this.cacheDir = AppPaths.getCacheDir(baseDir)
|
|
38
|
+
this.ttlMs = ttlDays * 24 * 60 * 60 * 1000
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Enable or disable caching
|
|
43
|
+
*/
|
|
44
|
+
setCacheEnabled(enabled: boolean): void {
|
|
45
|
+
this.cacheEnabled = enabled
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize cache directory
|
|
50
|
+
*/
|
|
51
|
+
async initialize(): Promise<void> {
|
|
52
|
+
if (!this.cacheEnabled) {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await fs.mkdir(this.cacheDir, { recursive: true })
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn("Failed to initialize cache directory:", error)
|
|
60
|
+
this.cacheEnabled = false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get cached analysis result for a commit hash
|
|
66
|
+
*/
|
|
67
|
+
async get(commitHash: string): Promise<Analysis | null> {
|
|
68
|
+
if (!this.cacheEnabled) {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const cacheFilePath = this.getCacheFilePath(commitHash)
|
|
74
|
+
|
|
75
|
+
// Check if cache file exists
|
|
76
|
+
const stat = await fs.stat(cacheFilePath)
|
|
77
|
+
const now = Date.now()
|
|
78
|
+
|
|
79
|
+
// Check if cache entry is expired
|
|
80
|
+
if (now - stat.mtimeMs > this.ttlMs) {
|
|
81
|
+
await this.delete(commitHash)
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Read and parse cache entry
|
|
86
|
+
const cacheData = await fs.readFile(cacheFilePath, "utf-8")
|
|
87
|
+
const entry: CacheEntry = JSON.parse(cacheData)
|
|
88
|
+
|
|
89
|
+
// Verify hash matches
|
|
90
|
+
if (entry.hash !== commitHash) {
|
|
91
|
+
await this.delete(commitHash)
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Reconstruct Analysis object
|
|
96
|
+
const category = Category.create(entry.analysis.category as CategoryType)
|
|
97
|
+
return new Analysis({
|
|
98
|
+
category,
|
|
99
|
+
summary: entry.analysis.summary,
|
|
100
|
+
description: entry.analysis.description,
|
|
101
|
+
})
|
|
102
|
+
} catch {
|
|
103
|
+
// Cache miss or error - return null
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Store analysis result in cache
|
|
110
|
+
*/
|
|
111
|
+
async set(commitHash: string, analysis: Analysis): Promise<void> {
|
|
112
|
+
if (!this.cacheEnabled) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await this.initialize()
|
|
118
|
+
|
|
119
|
+
const entry: CacheEntry = {
|
|
120
|
+
hash: commitHash,
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
analysis: {
|
|
123
|
+
category: analysis.getCategory().getValue(),
|
|
124
|
+
summary: analysis.getSummary(),
|
|
125
|
+
description: analysis.getDescription(),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cacheFilePath = this.getCacheFilePath(commitHash)
|
|
130
|
+
await fs.writeFile(cacheFilePath, JSON.stringify(entry, null, 2))
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Silent fail for cache writes
|
|
133
|
+
console.warn(`Failed to cache analysis for ${commitHash}:`, error)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Delete cached entry for a commit hash
|
|
139
|
+
*/
|
|
140
|
+
async delete(commitHash: string): Promise<void> {
|
|
141
|
+
if (!this.cacheEnabled) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const cacheFilePath = this.getCacheFilePath(commitHash)
|
|
147
|
+
await fs.unlink(cacheFilePath)
|
|
148
|
+
} catch {
|
|
149
|
+
// Silent fail for cache deletes
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Clear all cache entries
|
|
155
|
+
*/
|
|
156
|
+
async clear(): Promise<void> {
|
|
157
|
+
if (!this.cacheEnabled) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const files = await fs.readdir(this.cacheDir)
|
|
163
|
+
const cacheFiles = files.filter(file =>
|
|
164
|
+
file.startsWith(CacheService.CACHE_FILE_PREFIX)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
await Promise.all(
|
|
168
|
+
cacheFiles.map(file =>
|
|
169
|
+
fs.unlink(path.join(this.cacheDir, file)).catch(() => {})
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
} catch {
|
|
173
|
+
// Silent fail for cache clear
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get cache statistics
|
|
179
|
+
*/
|
|
180
|
+
async getStats(): Promise<{
|
|
181
|
+
totalEntries: number
|
|
182
|
+
totalSize: number
|
|
183
|
+
oldestEntry: Date | null
|
|
184
|
+
newestEntry: Date | null
|
|
185
|
+
}> {
|
|
186
|
+
if (!this.cacheEnabled) {
|
|
187
|
+
return {
|
|
188
|
+
totalEntries: 0,
|
|
189
|
+
totalSize: 0,
|
|
190
|
+
oldestEntry: null,
|
|
191
|
+
newestEntry: null,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const files = await fs.readdir(this.cacheDir)
|
|
197
|
+
const cacheFiles = files.filter(file =>
|
|
198
|
+
file.startsWith(CacheService.CACHE_FILE_PREFIX)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
let totalSize = 0
|
|
202
|
+
let oldestTime = Number.MAX_SAFE_INTEGER
|
|
203
|
+
let newestTime = 0
|
|
204
|
+
|
|
205
|
+
for (const file of cacheFiles) {
|
|
206
|
+
const filePath = path.join(this.cacheDir, file)
|
|
207
|
+
const stat = await fs.stat(filePath)
|
|
208
|
+
totalSize += stat.size
|
|
209
|
+
oldestTime = Math.min(oldestTime, stat.mtimeMs)
|
|
210
|
+
newestTime = Math.max(newestTime, stat.mtimeMs)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
totalEntries: cacheFiles.length,
|
|
215
|
+
totalSize,
|
|
216
|
+
oldestEntry: cacheFiles.length > 0 ? new Date(oldestTime) : null,
|
|
217
|
+
newestEntry: cacheFiles.length > 0 ? new Date(newestTime) : null,
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
return {
|
|
221
|
+
totalEntries: 0,
|
|
222
|
+
totalSize: 0,
|
|
223
|
+
oldestEntry: null,
|
|
224
|
+
newestEntry: null,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Clean expired cache entries
|
|
231
|
+
*/
|
|
232
|
+
async cleanExpired(): Promise<number> {
|
|
233
|
+
if (!this.cacheEnabled) {
|
|
234
|
+
return 0
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const files = await fs.readdir(this.cacheDir)
|
|
239
|
+
const cacheFiles = files.filter(file =>
|
|
240
|
+
file.startsWith(CacheService.CACHE_FILE_PREFIX)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const now = Date.now()
|
|
244
|
+
let cleanedCount = 0
|
|
245
|
+
|
|
246
|
+
for (const file of cacheFiles) {
|
|
247
|
+
const filePath = path.join(this.cacheDir, file)
|
|
248
|
+
try {
|
|
249
|
+
const stat = await fs.stat(filePath)
|
|
250
|
+
if (now - stat.mtimeMs > this.ttlMs) {
|
|
251
|
+
await fs.unlink(filePath)
|
|
252
|
+
cleanedCount++
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
// Skip files that can't be processed
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return cleanedCount
|
|
260
|
+
} catch {
|
|
261
|
+
return 0
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private getCacheFilePath(commitHash: string): string {
|
|
266
|
+
return path.join(
|
|
267
|
+
this.cacheDir,
|
|
268
|
+
`${CacheService.CACHE_FILE_PREFIX}${commitHash}.json`
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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
|
+
}
|