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
@@ -0,0 +1,81 @@
1
+ import { AnalyzedCommit } from "./analyzed-commit"
2
+
3
+ /**
4
+ * Domain service for formatting date ranges based on time periods
5
+ */
6
+ export class DateFormattingService {
7
+ /**
8
+ * Format date range based on the span of commits
9
+ */
10
+ formatDateRange(commits: AnalyzedCommit[]): string {
11
+ const dates = commits.map(c => c.getDate())
12
+ const minDate = new Date(Math.min(...dates.map(d => d.getTime())))
13
+ const maxDate = new Date(Math.max(...dates.map(d => d.getTime())))
14
+
15
+ const diffInMilliseconds = maxDate.getTime() - minDate.getTime()
16
+ const diffInDays = diffInMilliseconds / (1000 * 60 * 60 * 24)
17
+
18
+
19
+ // For very small differences, use a more conservative approach
20
+ if (diffInDays < 0.05) { // Less than ~1 hour
21
+ return this.formatHourlyRange(minDate, maxDate)
22
+ } else if (diffInDays <= 1) {
23
+ return this.formatDailyRange(minDate, maxDate)
24
+ } else if (diffInDays <= 7) {
25
+ return this.formatWeeklyRange(minDate, maxDate)
26
+ } else if (diffInDays <= 31) {
27
+ return this.formatMonthlyRange(minDate, maxDate)
28
+ } else if (diffInDays <= 365) {
29
+ return this.formatMonthlyRange(minDate, maxDate)
30
+ } else {
31
+ return this.formatYearlyRange(minDate, maxDate)
32
+ }
33
+ }
34
+
35
+
36
+ private formatHourlyRange(minDate: Date, maxDate: Date): string {
37
+ const formatTime = (date: Date) => {
38
+ const hour = date.getHours()
39
+ const ampm = hour < 12 ? 'am' : 'pm'
40
+ const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
41
+ return `${displayHour}${ampm}`
42
+ }
43
+
44
+ // If same time, just show the single time
45
+ if (minDate.getTime() === maxDate.getTime()) {
46
+ return formatTime(minDate)
47
+ }
48
+
49
+ return `${formatTime(minDate)} to ${formatTime(maxDate)}`
50
+ }
51
+
52
+ private formatDailyRange(minDate: Date, maxDate: Date): string {
53
+ const minDay = minDate.toLocaleDateString('en-US', { weekday: 'long' })
54
+ const maxDay = maxDate.toLocaleDateString('en-US', { weekday: 'long' })
55
+ return `${minDay} to ${maxDay}`
56
+ }
57
+
58
+ private formatWeeklyRange(minDate: Date, maxDate: Date): string {
59
+ const formatWeekly = (date: Date) => `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}`
60
+ return `${formatWeekly(minDate)} - ${formatWeekly(maxDate)}`
61
+ }
62
+
63
+ private formatMonthlyRange(minDate: Date, maxDate: Date): string {
64
+ const minMonth = minDate.toLocaleDateString('en-US', { month: 'short' })
65
+ const maxMonth = maxDate.toLocaleDateString('en-US', { month: 'short' })
66
+ const minYear = minDate.getFullYear()
67
+ const maxYear = maxDate.getFullYear()
68
+
69
+ if (minYear === maxYear) {
70
+ return `${minMonth} - ${maxMonth}`
71
+ } else {
72
+ return `${minMonth} ${minYear} - ${maxMonth} ${maxYear}`
73
+ }
74
+ }
75
+
76
+ private formatYearlyRange(minDate: Date, maxDate: Date): string {
77
+ const minYear = minDate.getFullYear()
78
+ const maxYear = maxDate.getFullYear()
79
+ return minYear === maxYear ? minYear.toString() : `${minYear} - ${maxYear}`
80
+ }
81
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Value object for date ranges in commit analysis
3
+ */
4
+ export class DateRange {
5
+ private constructor(
6
+ private readonly startDate: Date,
7
+ private readonly endDate: Date,
8
+ ) {}
9
+
10
+ static create(startDate: Date, endDate: Date): DateRange {
11
+ if (!startDate || !endDate) {
12
+ throw new Error('Start date and end date are required')
13
+ }
14
+
15
+ if (startDate > endDate) {
16
+ throw new Error('Start date cannot be after end date')
17
+ }
18
+
19
+ return new DateRange(new Date(startDate), new Date(endDate))
20
+ }
21
+
22
+ static fromYear(year: number): DateRange {
23
+ if (!Number.isInteger(year) || year < 1970) {
24
+ throw new Error('Invalid year provided')
25
+ }
26
+
27
+ const startDate = new Date(year, 0, 1) // January 1st
28
+ const endDate = new Date(year, 11, 31, 23, 59, 59, 999) // December 31st
29
+
30
+ return new DateRange(startDate, endDate)
31
+ }
32
+
33
+ static fromYearRange(startYear: number, endYear: number): DateRange {
34
+ if (startYear > endYear) {
35
+ throw new Error('Start year cannot be after end year')
36
+ }
37
+
38
+ const startDate = new Date(startYear, 0, 1)
39
+ const endDate = new Date(endYear, 11, 31, 23, 59, 59, 999)
40
+
41
+ return new DateRange(startDate, endDate)
42
+ }
43
+
44
+ getStartDate(): Date {
45
+ return new Date(this.startDate)
46
+ }
47
+
48
+ getEndDate(): Date {
49
+ return new Date(this.endDate)
50
+ }
51
+
52
+ contains(date: Date): boolean {
53
+ return date >= this.startDate && date <= this.endDate
54
+ }
55
+
56
+ getYearSpan(): number {
57
+ return this.endDate.getFullYear() - this.startDate.getFullYear() + 1
58
+ }
59
+
60
+ getYears(): number[] {
61
+ const years: number[] = []
62
+ for (let year = this.startDate.getFullYear(); year <= this.endDate.getFullYear(); year++) {
63
+ years.push(year)
64
+ }
65
+ return years
66
+ }
67
+
68
+ equals(other: DateRange): boolean {
69
+ return this.startDate.getTime() === other.startDate.getTime() &&
70
+ this.endDate.getTime() === other.endDate.getTime()
71
+ }
72
+
73
+ toString(): string {
74
+ return `${this.startDate.getFullYear()}-${this.endDate.getFullYear()}`
75
+ }
76
+ }
@@ -0,0 +1,292 @@
1
+ import { AnalyzedCommit } from "./analyzed-commit"
2
+ import { Category, CategoryType } from "./category"
3
+ import { DateRange } from "./date-range"
4
+
5
+ /**
6
+ * Statistics for analyzed commits
7
+ */
8
+ export interface CommitStatistics {
9
+ totalCommits: number
10
+ yearRange: {
11
+ min: number
12
+ max: number
13
+ }
14
+ categoryBreakdown: Record<CategoryType, number>
15
+ yearlyBreakdown: Record<number, number>
16
+ largeChanges: number
17
+ }
18
+
19
+ /**
20
+ * Domain service for report generation operations
21
+ */
22
+ export class ReportGenerationService {
23
+ /**
24
+ * Generates statistics from analyzed commits
25
+ */
26
+ generateStatistics(commits: AnalyzedCommit[]): CommitStatistics {
27
+ if (commits.length === 0) {
28
+ throw new Error("Cannot generate statistics from empty commit list")
29
+ }
30
+
31
+ const years = commits.map((c) => c.getYear())
32
+
33
+ const categoryBreakdown: Record<CategoryType, number> = {
34
+ tweak: 0,
35
+ feature: 0,
36
+ process: 0,
37
+ }
38
+
39
+ const yearlyBreakdown: Record<number, number> = {}
40
+ let largeChanges = 0
41
+
42
+ for (const commit of commits) {
43
+ // Category breakdown
44
+ const category = commit.getAnalysis().getCategory().getValue()
45
+ categoryBreakdown[category]++
46
+
47
+ // Yearly breakdown
48
+ const year = commit.getYear()
49
+ yearlyBreakdown[year] = (yearlyBreakdown[year] || 0) + 1
50
+
51
+ // Large changes count
52
+ if (commit.isLargeChange()) {
53
+ largeChanges++
54
+ }
55
+ }
56
+
57
+ return {
58
+ totalCommits: commits.length,
59
+ yearRange: {
60
+ min: Math.min(...years),
61
+ max: Math.max(...years),
62
+ },
63
+ categoryBreakdown,
64
+ yearlyBreakdown,
65
+ largeChanges,
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Filters commits by date range
71
+ */
72
+ filterByDateRange(
73
+ commits: AnalyzedCommit[],
74
+ dateRange: DateRange,
75
+ ): AnalyzedCommit[] {
76
+ return commits.filter((commit) => dateRange.contains(commit.getDate()))
77
+ }
78
+
79
+ /**
80
+ * Filters commits by category
81
+ */
82
+ filterByCategory(
83
+ commits: AnalyzedCommit[],
84
+ category: Category,
85
+ ): AnalyzedCommit[] {
86
+ return commits.filter((commit) =>
87
+ commit.getAnalysis().getCategory().equals(category),
88
+ )
89
+ }
90
+
91
+ /**
92
+ * Groups commits by year
93
+ */
94
+ groupByYear(commits: AnalyzedCommit[]): Map<number, AnalyzedCommit[]> {
95
+ const grouped = new Map<number, AnalyzedCommit[]>()
96
+
97
+ for (const commit of commits) {
98
+ const year = commit.getYear()
99
+ if (!grouped.has(year)) {
100
+ grouped.set(year, [])
101
+ }
102
+ grouped.get(year)!.push(commit)
103
+ }
104
+
105
+ return grouped
106
+ }
107
+
108
+ /**
109
+ * Groups commits by category
110
+ */
111
+ groupByCategory(
112
+ commits: AnalyzedCommit[],
113
+ ): Map<CategoryType, AnalyzedCommit[]> {
114
+ const grouped = new Map<CategoryType, AnalyzedCommit[]>()
115
+
116
+ for (const commit of commits) {
117
+ const category = commit.getAnalysis().getCategory().getValue()
118
+ if (!grouped.has(category)) {
119
+ grouped.set(category, [])
120
+ }
121
+ grouped.get(category)!.push(commit)
122
+ }
123
+
124
+ return grouped
125
+ }
126
+
127
+ /**
128
+ * Sorts commits by date (newest first by default)
129
+ */
130
+ sortByDate(
131
+ commits: AnalyzedCommit[],
132
+ ascending: boolean = false,
133
+ ): AnalyzedCommit[] {
134
+ return commits.slice().sort((a, b) => {
135
+ const aDate = a.getDate().getTime()
136
+ const bDate = b.getDate().getTime()
137
+ return ascending ? aDate - bDate : bDate - aDate
138
+ })
139
+ }
140
+
141
+ /**
142
+ * Gets the most significant commits (features and large changes)
143
+ */
144
+ getSignificantCommits(commits: AnalyzedCommit[]): AnalyzedCommit[] {
145
+ return commits.filter(
146
+ (commit) =>
147
+ commit.getAnalysis().isFeatureAnalysis() || commit.isLargeChange(),
148
+ )
149
+ }
150
+
151
+ /**
152
+ * Determines the appropriate time period for summaries based on date range
153
+ */
154
+ determineTimePeriod(commits: AnalyzedCommit[]): 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly' {
155
+ if (commits.length === 0) return 'yearly'
156
+
157
+ const dates = commits.map(c => c.getDate())
158
+ const minDate = new Date(Math.min(...dates.map(d => d.getTime())))
159
+ const maxDate = new Date(Math.max(...dates.map(d => d.getTime())))
160
+
161
+ const diffInMilliseconds = maxDate.getTime() - minDate.getTime()
162
+ const diffInDays = diffInMilliseconds / (1000 * 60 * 60 * 24)
163
+
164
+ if (diffInDays <= 1) return 'daily'
165
+ if (diffInDays <= 7) return 'weekly'
166
+ if (diffInDays <= 31) return 'monthly'
167
+ if (diffInDays <= 93) return 'quarterly' // ~3 months
168
+ return 'yearly'
169
+ }
170
+
171
+ /**
172
+ * Groups commits by the appropriate time period
173
+ */
174
+ groupByTimePeriod(commits: AnalyzedCommit[], period: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'): Map<string, AnalyzedCommit[]> {
175
+ const grouped = new Map<string, AnalyzedCommit[]>()
176
+
177
+ for (const commit of commits) {
178
+ const date = commit.getDate()
179
+ let key: string
180
+
181
+ switch (period) {
182
+ case 'daily':
183
+ key = this.formatDailyKey(date)
184
+ break
185
+ case 'weekly':
186
+ key = this.formatWeeklyKey(date)
187
+ break
188
+ case 'monthly':
189
+ key = this.formatMonthlyKey(date)
190
+ break
191
+ case 'quarterly':
192
+ key = this.formatQuarterlyKey(date)
193
+ break
194
+ case 'yearly':
195
+ default:
196
+ key = date.getFullYear().toString()
197
+ break
198
+ }
199
+
200
+ if (!grouped.has(key)) {
201
+ grouped.set(key, [])
202
+ }
203
+ grouped.get(key)!.push(commit)
204
+ }
205
+
206
+ return grouped
207
+ }
208
+
209
+ private formatDailyKey(date: Date): string {
210
+ const year = date.getFullYear()
211
+ const month = date.getMonth() + 1
212
+ const day = date.getDate()
213
+ const hour = date.getHours()
214
+
215
+ if (hour < 12) return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} Morning`
216
+ if (hour < 17) return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} Afternoon`
217
+ return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} Evening`
218
+ }
219
+
220
+ private formatWeeklyKey(date: Date): string {
221
+ const startOfWeek = new Date(date)
222
+ startOfWeek.setDate(date.getDate() - date.getDay())
223
+ const endOfWeek = new Date(startOfWeek)
224
+ endOfWeek.setDate(startOfWeek.getDate() + 6)
225
+
226
+ const formatDate = (d: Date) =>
227
+ `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
228
+
229
+ return `Week of ${formatDate(startOfWeek)} to ${formatDate(endOfWeek)}`
230
+ }
231
+
232
+ private formatMonthlyKey(date: Date): string {
233
+ const months = ['January', 'February', 'March', 'April', 'May', 'June',
234
+ 'July', 'August', 'September', 'October', 'November', 'December']
235
+ return `${months[date.getMonth()]} ${date.getFullYear()}`
236
+ }
237
+
238
+ private formatQuarterlyKey(date: Date): string {
239
+ const quarter = Math.floor(date.getMonth() / 3) + 1
240
+ return `Q${quarter} ${date.getFullYear()}`
241
+ }
242
+
243
+ /**
244
+ * Converts analyzed commits to CSV string format for LLM consumption
245
+ */
246
+ convertToCSVString(commits: AnalyzedCommit[]): string {
247
+ const header = "year,category,summary,description"
248
+ const rows = commits.map((commit) => {
249
+ const analysis = commit.getAnalysis()
250
+ return [
251
+ commit.getYear().toString(),
252
+ this.escapeCsvField(analysis.getCategory().getValue()),
253
+ this.escapeCsvField(analysis.getSummary()),
254
+ this.escapeCsvField(analysis.getDescription()),
255
+ ].join(",")
256
+ })
257
+
258
+ return [header, ...rows].join("\n")
259
+ }
260
+
261
+ /**
262
+ * Converts grouped commits to CSV with time period information
263
+ */
264
+ convertGroupedToCSV(groupedCommits: Map<string, AnalyzedCommit[]>, period: string): string {
265
+ const header = `${period},category,summary,description`
266
+ const rows: string[] = []
267
+
268
+ for (const [timePeriod, commits] of groupedCommits) {
269
+ for (const commit of commits) {
270
+ const analysis = commit.getAnalysis()
271
+ rows.push([
272
+ this.escapeCsvField(timePeriod),
273
+ this.escapeCsvField(analysis.getCategory().getValue()),
274
+ this.escapeCsvField(analysis.getSummary()),
275
+ this.escapeCsvField(analysis.getDescription()),
276
+ ].join(","))
277
+ }
278
+ }
279
+
280
+ return [header, ...rows].join("\n")
281
+ }
282
+
283
+ /**
284
+ * Escape CSV fields that contain commas, quotes, or newlines
285
+ */
286
+ private escapeCsvField(field: string): string {
287
+ if (field.includes(",") || field.includes('"') || field.includes("\n")) {
288
+ return `"${field.replace(/"/g, '""')}"`
289
+ }
290
+ return field
291
+ }
292
+ }