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.
Files changed (62) hide show
  1. package/.claude/settings.local.json +11 -1
  2. package/README.md +33 -2
  3. package/commits.csv +2 -0
  4. package/eslint.config.mts +45 -0
  5. package/package.json +17 -9
  6. package/src/1.domain/analysis.ts +93 -0
  7. package/src/1.domain/analyzed-commit.ts +97 -0
  8. package/src/1.domain/application-error.ts +32 -0
  9. package/src/1.domain/category.ts +52 -0
  10. package/src/1.domain/commit-analysis-service.ts +92 -0
  11. package/src/1.domain/commit-hash.ts +40 -0
  12. package/src/1.domain/commit.ts +99 -0
  13. package/src/1.domain/date-formatting-service.ts +81 -0
  14. package/src/1.domain/date-range.ts +76 -0
  15. package/src/1.domain/report-generation-service.ts +292 -0
  16. package/src/2.application/analyze-commits.usecase.ts +307 -0
  17. package/src/2.application/generate-report.usecase.ts +204 -0
  18. package/src/2.application/llm-service.ts +54 -0
  19. package/src/2.application/resume-analysis.usecase.ts +123 -0
  20. package/src/3.presentation/analysis-repository.interface.ts +27 -0
  21. package/src/3.presentation/analyze-command.ts +128 -0
  22. package/src/3.presentation/cli-application.ts +255 -0
  23. package/src/3.presentation/command-handler.interface.ts +4 -0
  24. package/src/3.presentation/commit-analysis-controller.ts +101 -0
  25. package/src/3.presentation/commit-repository.interface.ts +47 -0
  26. package/src/3.presentation/console-formatter.ts +129 -0
  27. package/src/3.presentation/progress-repository.interface.ts +49 -0
  28. package/src/3.presentation/report-command.ts +50 -0
  29. package/src/3.presentation/resume-command.ts +59 -0
  30. package/src/3.presentation/storage-repository.interface.ts +33 -0
  31. package/src/3.presentation/storage-service.interface.ts +32 -0
  32. package/src/3.presentation/version-control-service.interface.ts +41 -0
  33. package/src/4.infrastructure/cache-service.ts +271 -0
  34. package/src/4.infrastructure/cached-analysis-repository.ts +46 -0
  35. package/src/4.infrastructure/claude-llm-adapter.ts +124 -0
  36. package/src/4.infrastructure/csv-service.ts +206 -0
  37. package/src/4.infrastructure/file-storage-repository.ts +108 -0
  38. package/src/4.infrastructure/file-system-storage-adapter.ts +87 -0
  39. package/src/4.infrastructure/gemini-llm-adapter.ts +46 -0
  40. package/src/4.infrastructure/git-adapter.ts +116 -0
  41. package/src/4.infrastructure/git-commit-repository.ts +85 -0
  42. package/src/4.infrastructure/json-progress-tracker.ts +182 -0
  43. package/src/4.infrastructure/llm-adapter-factory.ts +26 -0
  44. package/src/4.infrastructure/llm-adapter.ts +455 -0
  45. package/src/4.infrastructure/llm-analysis-repository.ts +38 -0
  46. package/src/4.infrastructure/openai-llm-adapter.ts +57 -0
  47. package/src/di.ts +108 -0
  48. package/src/main.ts +63 -0
  49. package/src/utils/app-paths.ts +36 -0
  50. package/src/utils/concurrency.ts +81 -0
  51. package/src/utils.ts +77 -0
  52. package/tsconfig.json +7 -1
  53. package/src/cli.ts +0 -170
  54. package/src/csv-reader.ts +0 -180
  55. package/src/csv.ts +0 -40
  56. package/src/errors.ts +0 -49
  57. package/src/git.ts +0 -112
  58. package/src/index.ts +0 -395
  59. package/src/llm.ts +0 -396
  60. package/src/progress.ts +0 -84
  61. package/src/report-generator.ts +0 -286
  62. package/src/types.ts +0 -24
package/src/llm.ts DELETED
@@ -1,396 +0,0 @@
1
- import { execSync } from "child_process"
2
- import { CommitInfo, LLMAnalysis } from "./types"
3
-
4
- export class LLMService {
5
- private static model: string
6
- private static verbose: boolean = false
7
-
8
- /**
9
- * Detect available LLM models by checking CLI commands.
10
- */
11
- static detectAvailableModels(): string[] {
12
- const models = ["claude", "gemini", "codex"]
13
- return models.filter((model) => {
14
- try {
15
- execSync(`command -v ${model}`, { stdio: "ignore" })
16
- return true
17
- } catch {
18
- return false
19
- }
20
- })
21
- }
22
-
23
- /**
24
- * Determine default LLM model based on availability.
25
- */
26
- static detectDefaultModel(): string {
27
- const available = this.detectAvailableModels()
28
- if (available.length === 0) {
29
- throw new Error(
30
- "No supported LLM models found. Please install claude, gemini, or codex.",
31
- )
32
- }
33
- // Default to sonnet if claude is available
34
- if (available.includes('claude')) {
35
- return 'claude --model sonnet'
36
- }
37
- return available[0]
38
- }
39
-
40
- /**
41
- * Set the LLM model command to use.
42
- */
43
- static setModel(model: string): void {
44
- this.model = model
45
- }
46
-
47
- /**
48
- * Set verbose mode for detailed error logging.
49
- */
50
- static setVerbose(verbose: boolean): void {
51
- this.verbose = verbose
52
- }
53
-
54
- /**
55
- * Get the configured LLM model or detect default.
56
- */
57
- static getModel(): string {
58
- if (!this.model) {
59
- this.model = this.detectDefaultModel()
60
- }
61
- return this.model
62
- }
63
- private static readonly MAX_RETRIES = parseInt(
64
- process.env.LLM_MAX_RETRIES || "3",
65
- 10,
66
- )
67
- private static readonly INITIAL_RETRY_DELAY = parseInt(
68
- process.env.LLM_INITIAL_RETRY_DELAY || "5000",
69
- 10,
70
- )
71
- private static readonly MAX_RETRY_DELAY = parseInt(
72
- process.env.LLM_MAX_RETRY_DELAY || "30000",
73
- 10,
74
- )
75
- private static readonly RETRY_MULTIPLIER = parseFloat(
76
- process.env.LLM_RETRY_MULTIPLIER || "2",
77
- )
78
- // Claude-specific configuration with backward compatibility
79
- private static readonly CLAUDE_MAX_PROMPT_LENGTH = parseInt(
80
- process.env.CLAUDE_MAX_PROMPT_LENGTH || process.env.LLM_MAX_PROMPT_LENGTH || "100000",
81
- 10,
82
- )
83
- private static readonly CLAUDE_MAX_DIFF_LENGTH = parseInt(
84
- process.env.CLAUDE_MAX_DIFF_LENGTH || process.env.LLM_MAX_DIFF_LENGTH || "80000",
85
- 10,
86
- )
87
-
88
- static async analyzeCommit(commit: CommitInfo): Promise<LLMAnalysis> {
89
- const currentModel = this.getModel()
90
- const prompt = this.buildPrompt(commit.message, commit.diff, currentModel)
91
-
92
- // Log prompt length for debugging - only for Claude models
93
- if (this.isClaudeModel(currentModel)) {
94
- console.log(` - Prompt length: ${prompt.length} characters`)
95
- if (prompt.length > this.CLAUDE_MAX_PROMPT_LENGTH) {
96
- console.log(` - Warning: Prompt exceeds Claude max length (${this.CLAUDE_MAX_PROMPT_LENGTH})`)
97
- }
98
- }
99
-
100
- let lastError: Error | null = null
101
-
102
- for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
103
- try {
104
- const output = execSync(currentModel, {
105
- input: prompt,
106
- encoding: "utf8",
107
- stdio: ["pipe", "pipe", "pipe"],
108
- timeout: 60000,
109
- })
110
-
111
- return this.parseResponse(output)
112
- } catch (error) {
113
- lastError = error instanceof Error ? error : new Error("Unknown error")
114
-
115
- // Check if this is a rate limit error
116
- const rateLimitInfo = this.isRateLimitError(error)
117
-
118
- if (rateLimitInfo.isRateLimit) {
119
- // For rate limits, show user-friendly message immediately
120
- const friendlyMessage = this.getRateLimitMessage(rateLimitInfo.service, rateLimitInfo.limitType)
121
- console.log(` - ${friendlyMessage}`)
122
-
123
- // Show detailed error info only in verbose mode
124
- if (this.verbose) {
125
- console.log(` - Verbose error details for commit ${commit.hash.substring(0, 8)}:`)
126
- console.log(` Command: ${currentModel}`)
127
- console.log(` Error message: ${lastError.message}`)
128
- if (this.isClaudeModel(currentModel)) {
129
- console.log(` Prompt length: ${prompt.length} characters`)
130
- }
131
-
132
- // If it's an exec error, log additional details
133
- if (error && typeof error === 'object' && 'stderr' in error) {
134
- const execError = error as any
135
- console.log(` Exit code: ${execError.status || 'unknown'}`)
136
- console.log(` Signal: ${execError.signal || 'none'}`)
137
- if (execError.stderr) {
138
- console.log(` Stderr: ${execError.stderr.substring(0, 1000)}${execError.stderr.length > 1000 ? '...' : ''}`)
139
- }
140
- }
141
- }
142
-
143
- // If it's a daily quota error, don't retry - fail immediately
144
- if (rateLimitInfo.limitType === "daily quota") {
145
- throw new Error(
146
- `Daily quota exceeded for ${rateLimitInfo.service || 'LLM service'}. Retrying will not help until quota resets.`,
147
- )
148
- }
149
- } else {
150
- // For non-rate-limit errors, show detailed info based on verbose mode
151
- if (this.verbose) {
152
- console.log(` - Error details for commit ${commit.hash.substring(0, 8)}:`)
153
- console.log(` Command: ${currentModel}`)
154
- console.log(` Error message: ${lastError.message}`)
155
- if (this.isClaudeModel(currentModel)) {
156
- console.log(` Prompt length: ${prompt.length} characters`)
157
- }
158
-
159
- // If it's an exec error, log additional details
160
- if (error && typeof error === 'object' && 'stderr' in error) {
161
- const execError = error as any
162
- console.log(` Exit code: ${execError.status || 'unknown'}`)
163
- console.log(` Signal: ${execError.signal || 'none'}`)
164
- console.log(` Stderr: ${execError.stderr || 'none'}`)
165
- console.log(` Stdout: ${execError.stdout || 'none'}`)
166
- }
167
- } else {
168
- // In non-verbose mode, show concise error message
169
- console.log(` - Analysis failed: ${lastError.message}`)
170
- }
171
- }
172
-
173
- if (attempt < this.MAX_RETRIES) {
174
- const delay = Math.min(
175
- this.INITIAL_RETRY_DELAY *
176
- Math.pow(this.RETRY_MULTIPLIER, attempt - 1),
177
- this.MAX_RETRY_DELAY,
178
- )
179
-
180
- console.log(
181
- ` - Attempt ${attempt}/${this.MAX_RETRIES} failed for commit ${commit.hash.substring(0, 8)}. Retrying in ${delay / 1000}s...`,
182
- )
183
-
184
- await this.sleep(delay)
185
- }
186
- }
187
- }
188
-
189
- throw new Error(
190
- `Failed to analyze commit ${commit.hash} after ${this.MAX_RETRIES} attempts: ${lastError?.message || "Unknown error"}`,
191
- )
192
- }
193
-
194
- private static sleep(ms: number): Promise<void> {
195
- return new Promise((resolve) => setTimeout(resolve, ms))
196
- }
197
-
198
-
199
- static getMaxRetries(): number {
200
- return this.MAX_RETRIES
201
- }
202
-
203
- /**
204
- * Check if the current model is Claude-based
205
- */
206
- private static isClaudeModel(model?: string): boolean {
207
- const currentModel = model || this.getModel()
208
- return currentModel.toLowerCase().includes('claude')
209
- }
210
-
211
- private static buildPrompt(commitMessage: string, diff: string, model: string): string {
212
- // Only truncate for Claude models
213
- let truncatedDiff = diff
214
- let diffTruncated = false
215
-
216
- if (this.isClaudeModel(model) && diff.length > this.CLAUDE_MAX_DIFF_LENGTH) {
217
- truncatedDiff = diff.substring(0, this.CLAUDE_MAX_DIFF_LENGTH) + "\n\n[DIFF TRUNCATED - Original length: " + diff.length + " characters]"
218
- diffTruncated = true
219
- }
220
-
221
- const basePrompt = `Analyze this git commit and provide a categorization:
222
-
223
- COMMIT MESSAGE:
224
- ${commitMessage}
225
-
226
- COMMIT DIFF:
227
- ${truncatedDiff}
228
-
229
- Based on the commit message and code changes, categorize this commit as one of:
230
- - "tweak": Minor adjustments, bug fixes, small improvements
231
- - "feature": New functionality, major additions
232
- - "process": Build system, CI/CD, tooling, configuration changes
233
-
234
- Provide:
235
- 1. Category: [tweak|feature|process]
236
- 2. Summary: One-line description (max 80 chars)
237
- 3. Description: Detailed explanation (2-3 sentences)
238
-
239
- Format as JSON:
240
- \`\`\`json
241
- {
242
- "category": "...",
243
- "summary": "...",
244
- "description": "..."
245
- }
246
- \`\`\``
247
-
248
- // Final length check - only for Claude models
249
- if (this.isClaudeModel(model) && basePrompt.length > this.CLAUDE_MAX_PROMPT_LENGTH) {
250
- // Further truncate the diff if needed
251
- const overhead = basePrompt.length - this.CLAUDE_MAX_PROMPT_LENGTH
252
- const newDiffLength = Math.max(1000, this.CLAUDE_MAX_DIFF_LENGTH - overhead - 200) // Keep at least 1000 chars, subtract extra for safety
253
- truncatedDiff = diff.substring(0, newDiffLength) + "\n\n[DIFF HEAVILY TRUNCATED - Original length: " + diff.length + " characters]"
254
-
255
- return `Analyze this git commit and provide a categorization:
256
-
257
- COMMIT MESSAGE:
258
- ${commitMessage}
259
-
260
- COMMIT DIFF:
261
- ${truncatedDiff}
262
-
263
- Based on the commit message and code changes, categorize this commit as one of:
264
- - "tweak": Minor adjustments, bug fixes, small improvements
265
- - "feature": New functionality, major additions
266
- - "process": Build system, CI/CD, tooling, configuration changes
267
-
268
- Provide:
269
- 1. Category: [tweak|feature|process]
270
- 2. Summary: One-line description (max 80 chars)
271
- 3. Description: Detailed explanation (2-3 sentences)
272
-
273
- Format as JSON:
274
- \`\`\`json
275
- {
276
- "category": "...",
277
- "summary": "...",
278
- "description": "..."
279
- }
280
- \`\`\``
281
- }
282
-
283
- return basePrompt
284
- }
285
-
286
- private static parseResponse(response: string): LLMAnalysis {
287
- try {
288
- const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/)
289
- if (!jsonMatch) {
290
- throw new Error("No JSON block found in response")
291
- }
292
-
293
- const parsed = JSON.parse(jsonMatch[1])
294
-
295
- if (!this.isValidCategory(parsed.category)) {
296
- throw new Error(`Invalid category: ${parsed.category}`)
297
- }
298
-
299
- if (!parsed.summary || !parsed.description) {
300
- throw new Error("Missing required fields in response")
301
- }
302
-
303
- return {
304
- category: parsed.category,
305
- summary: parsed.summary.substring(0, 80),
306
- description: parsed.description,
307
- }
308
- } catch (error) {
309
- // Log the raw response for debugging
310
- console.log(` - Raw LLM response (first 1000 chars): ${response.substring(0, 1000)}`)
311
- if (response.length > 1000) {
312
- console.log(` - Response truncated (total length: ${response.length} chars)`)
313
- }
314
-
315
- // Try to extract and log the JSON block if it exists but is malformed
316
- const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/)
317
- if (jsonMatch) {
318
- console.log(` - Extracted JSON block: ${jsonMatch[1]}`)
319
- }
320
-
321
- throw new Error(
322
- `Failed to parse LLM response: ${error instanceof Error ? error.message : "Unknown error"}`,
323
- )
324
- }
325
- }
326
-
327
- private static isValidCategory(
328
- category: string,
329
- ): category is "tweak" | "feature" | "process" {
330
- return ["tweak", "feature", "process"].includes(category)
331
- }
332
-
333
- /**
334
- * Check if an error is related to rate limiting or quota exceeded.
335
- */
336
- private static isRateLimitError(error: any): { isRateLimit: boolean; service?: string; limitType?: string } {
337
- const errorMessage = error?.message?.toLowerCase() || ""
338
- const stderr = error?.stderr?.toLowerCase() || ""
339
- const stdout = error?.stdout?.toLowerCase() || ""
340
- const combinedOutput = `${errorMessage} ${stderr} ${stdout}`
341
-
342
- // Check for Gemini rate limit patterns
343
- if (combinedOutput.includes("quota exceeded") && combinedOutput.includes("gemini")) {
344
- if (combinedOutput.includes("requests per day")) {
345
- return { isRateLimit: true, service: "Gemini", limitType: "daily quota" }
346
- }
347
- if (combinedOutput.includes("requests per minute")) {
348
- return { isRateLimit: true, service: "Gemini", limitType: "per-minute rate limit" }
349
- }
350
- return { isRateLimit: true, service: "Gemini", limitType: "quota limit" }
351
- }
352
-
353
- // Check for Claude rate limit patterns
354
- if (combinedOutput.includes("rate limit") && combinedOutput.includes("claude")) {
355
- return { isRateLimit: true, service: "Claude", limitType: "rate limit" }
356
- }
357
-
358
- // Check for generic rate limit indicators
359
- if (combinedOutput.includes("429") ||
360
- combinedOutput.includes("too many requests") ||
361
- combinedOutput.includes("rate limit") ||
362
- combinedOutput.includes("quota exceeded")) {
363
- // Try to determine service from model name
364
- const currentModel = this.getModel().toLowerCase()
365
- if (currentModel.includes("gemini")) {
366
- return { isRateLimit: true, service: "Gemini", limitType: "rate/quota limit" }
367
- }
368
- if (currentModel.includes("claude")) {
369
- return { isRateLimit: true, service: "Claude", limitType: "rate limit" }
370
- }
371
- return { isRateLimit: true, limitType: "rate/quota limit" }
372
- }
373
-
374
- return { isRateLimit: false }
375
- }
376
-
377
- /**
378
- * Get user-friendly error message for rate limit errors.
379
- */
380
- private static getRateLimitMessage(service?: string, limitType?: string): string {
381
- if (service === "Gemini" && limitType === "daily quota") {
382
- return "⚠️ Gemini daily quota exceeded. The limit resets at midnight Pacific Time. Consider switching to a different model or resuming tomorrow."
383
- }
384
-
385
- if (service === "Gemini" && limitType === "per-minute rate limit") {
386
- return "⚠️ Gemini rate limit exceeded. Wait a minute before retrying, or consider switching to a different model."
387
- }
388
-
389
- if (service === "Claude") {
390
- return "⚠️ Claude rate limit exceeded. Wait a moment before retrying, or consider switching to a different model."
391
- }
392
-
393
- const serviceMsg = service ? `${service} ` : ""
394
- return `⚠️ ${serviceMsg}rate limit exceeded. Consider switching models or waiting before retrying.`
395
- }
396
- }
package/src/progress.ts DELETED
@@ -1,84 +0,0 @@
1
- import { writeFileSync, readFileSync, existsSync, unlinkSync } from "fs"
2
- import { AnalyzedCommit } from "./types"
3
-
4
- interface ProgressState {
5
- totalCommits: string[]
6
- processedCommits: string[]
7
- analyzedCommits: AnalyzedCommit[]
8
- lastProcessedIndex: number
9
- startTime: string
10
- outputFile: string
11
- }
12
-
13
- export class ProgressTracker {
14
- private static readonly CHECKPOINT_FILE = ".commit-analyzer-progress.json"
15
-
16
- static saveProgress(
17
- totalCommits: string[],
18
- processedCommits: string[],
19
- analyzedCommits: AnalyzedCommit[],
20
- outputFile: string,
21
- ): void {
22
- // Preserve the original start time if this is an update to existing progress
23
- let startTime = new Date().toISOString()
24
- const existingState = this.loadProgress()
25
- if (existingState) {
26
- startTime = existingState.startTime
27
- }
28
-
29
- const state: ProgressState = {
30
- totalCommits,
31
- processedCommits,
32
- analyzedCommits,
33
- lastProcessedIndex: processedCommits.length - 1,
34
- startTime,
35
- outputFile,
36
- }
37
-
38
- writeFileSync(this.CHECKPOINT_FILE, JSON.stringify(state, null, 2))
39
- }
40
-
41
- static loadProgress(): ProgressState | null {
42
- if (!existsSync(this.CHECKPOINT_FILE)) {
43
- return null
44
- }
45
-
46
- try {
47
- const content = readFileSync(this.CHECKPOINT_FILE, "utf8")
48
- return JSON.parse(content)
49
- } catch (error) {
50
- console.error("Failed to load progress file:", error)
51
- return null
52
- }
53
- }
54
-
55
- static hasProgress(): boolean {
56
- return existsSync(this.CHECKPOINT_FILE)
57
- }
58
-
59
- static clearProgress(): void {
60
- if (existsSync(this.CHECKPOINT_FILE)) {
61
- unlinkSync(this.CHECKPOINT_FILE)
62
- }
63
- }
64
-
65
- static getRemainingCommits(state: ProgressState): string[] {
66
- const processedSet = new Set(state.processedCommits)
67
- return state.totalCommits.filter(hash => !processedSet.has(hash))
68
- }
69
-
70
- static formatProgressSummary(state: ProgressState): string {
71
- const processed = state.processedCommits.length
72
- const total = state.totalCommits.length
73
- const remaining = total - processed
74
- const percentComplete = Math.round((processed / total) * 100)
75
-
76
- return `
77
- Previous session:
78
- - Started: ${new Date(state.startTime).toLocaleString()}
79
- - Progress: ${processed}/${total} commits (${percentComplete}%)
80
- - Remaining: ${remaining} commits
81
- - Output file: ${state.outputFile}
82
- `.trim()
83
- }
84
- }