commit-analyzer 1.0.1

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.
@@ -0,0 +1,180 @@
1
+ import { readFileSync } from "fs"
2
+
3
+ export interface ParsedCSVRow {
4
+ year: number
5
+ category: "tweak" | "feature" | "process"
6
+ summary: string
7
+ description: string
8
+ }
9
+
10
+ export class CSVReaderService {
11
+ static readCSV(filename: string): ParsedCSVRow[] {
12
+ try {
13
+ const content = readFileSync(filename, "utf8")
14
+ return this.parseCSV(content)
15
+ } catch (error) {
16
+ throw new Error(
17
+ `Failed to read CSV file ${filename}: ${error instanceof Error ? error.message : "Unknown error"}`,
18
+ )
19
+ }
20
+ }
21
+
22
+ private static parseCSV(content: string): ParsedCSVRow[] {
23
+ const lines = content.trim().split("\n")
24
+
25
+ if (lines.length === 0) {
26
+ throw new Error("CSV file is empty")
27
+ }
28
+
29
+ // Validate header
30
+ const header = lines[0].toLowerCase()
31
+ const expectedHeader = "year,category,summary,description"
32
+ if (header !== expectedHeader) {
33
+ throw new Error(
34
+ `Invalid CSV format. Expected header: "${expectedHeader}", got: "${header}"`,
35
+ )
36
+ }
37
+
38
+ const rows: ParsedCSVRow[] = []
39
+
40
+ for (let i = 1; i < lines.length; i++) {
41
+ const line = lines[i].trim()
42
+ if (line === "") continue // Skip empty lines
43
+
44
+ try {
45
+ const row = this.parseCSVLine(line, i + 1)
46
+ rows.push(row)
47
+ } catch (error) {
48
+ throw new Error(
49
+ `Error parsing CSV line ${i + 1}: ${error instanceof Error ? error.message : "Unknown error"}`,
50
+ )
51
+ }
52
+ }
53
+
54
+ return rows
55
+ }
56
+
57
+ private static parseCSVLine(line: string, lineNumber: number): ParsedCSVRow {
58
+ const fields = this.parseCSVFields(line)
59
+
60
+ if (fields.length !== 4) {
61
+ throw new Error(
62
+ `Expected 4 fields (year,category,summary,description), got ${fields.length}`,
63
+ )
64
+ }
65
+
66
+ const [yearStr, category, summary, description] = fields
67
+
68
+ // Validate year
69
+ const year = parseInt(yearStr, 10)
70
+ if (isNaN(year) || year < 1900 || year > 2100) {
71
+ throw new Error(`Invalid year: ${yearStr}`)
72
+ }
73
+
74
+ // Validate category
75
+ if (!this.isValidCategory(category)) {
76
+ throw new Error(
77
+ `Invalid category: ${category}. Must be one of: tweak, feature, process`,
78
+ )
79
+ }
80
+
81
+ // Validate required fields
82
+ if (!summary.trim()) {
83
+ throw new Error("Summary field cannot be empty")
84
+ }
85
+
86
+ if (!description.trim()) {
87
+ throw new Error("Description field cannot be empty")
88
+ }
89
+
90
+ return {
91
+ year,
92
+ category: category as "tweak" | "feature" | "process",
93
+ summary: summary.trim(),
94
+ description: description.trim(),
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Parse CSV fields handling quoted fields with commas and escaped quotes
100
+ */
101
+ private static parseCSVFields(line: string): string[] {
102
+ const fields: string[] = []
103
+ let currentField = ""
104
+ let inQuotes = false
105
+ let i = 0
106
+
107
+ while (i < line.length) {
108
+ const char = line[i]
109
+ const nextChar = line[i + 1]
110
+
111
+ if (char === '"') {
112
+ if (inQuotes && nextChar === '"') {
113
+ // Escaped quote inside quoted field
114
+ currentField += '"'
115
+ i += 2
116
+ } else {
117
+ // Start or end of quoted field
118
+ inQuotes = !inQuotes
119
+ i++
120
+ }
121
+ } else if (char === "," && !inQuotes) {
122
+ // Field separator outside quotes
123
+ fields.push(currentField)
124
+ currentField = ""
125
+ i++
126
+ } else {
127
+ // Regular character
128
+ currentField += char
129
+ i++
130
+ }
131
+ }
132
+
133
+ // Add the last field
134
+ fields.push(currentField)
135
+
136
+ return fields
137
+ }
138
+
139
+ private static isValidCategory(
140
+ category: string,
141
+ ): category is "tweak" | "feature" | "process" {
142
+ return ["tweak", "feature", "process"].includes(category)
143
+ }
144
+
145
+ /**
146
+ * Get summary statistics about the CSV data
147
+ */
148
+ static getStatistics(rows: ParsedCSVRow[]): {
149
+ totalRows: number
150
+ yearRange: { min: number; max: number }
151
+ categoryBreakdown: Record<string, number>
152
+ } {
153
+ if (rows.length === 0) {
154
+ return {
155
+ totalRows: 0,
156
+ yearRange: { min: 0, max: 0 },
157
+ categoryBreakdown: { tweak: 0, feature: 0, process: 0 },
158
+ }
159
+ }
160
+
161
+ const years = rows.map((row) => row.year)
162
+ const categoryBreakdown = rows.reduce(
163
+ (acc, row) => {
164
+ acc[row.category]++
165
+ return acc
166
+ },
167
+ { tweak: 0, feature: 0, process: 0 } as Record<string, number>,
168
+ )
169
+
170
+ return {
171
+ totalRows: rows.length,
172
+ yearRange: {
173
+ min: Math.min(...years),
174
+ max: Math.max(...years),
175
+ },
176
+ categoryBreakdown,
177
+ }
178
+ }
179
+ }
180
+
package/src/csv.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { writeFileSync } from "fs"
2
+ import { AnalyzedCommit, CSVRow } from "./types"
3
+
4
+ export class CSVService {
5
+ static generateCSV(commits: AnalyzedCommit[]): string {
6
+ const headers = "year,category,summary,description"
7
+ const rows = commits.map((commit) => this.formatRow(commit))
8
+
9
+ return [headers, ...rows].join("\n")
10
+ }
11
+
12
+ static exportToFile(commits: AnalyzedCommit[], filename: string): void {
13
+ const csvContent = this.generateCSV(commits)
14
+ writeFileSync(filename, csvContent, "utf8")
15
+ }
16
+
17
+ private static formatRow(commit: AnalyzedCommit): string {
18
+ const row: CSVRow = {
19
+ year: commit.year,
20
+ category: commit.analysis.category,
21
+ summary: commit.analysis.summary,
22
+ description: commit.analysis.description,
23
+ }
24
+
25
+ return [
26
+ row.year,
27
+ this.escapeCsvField(row.category),
28
+ this.escapeCsvField(row.summary),
29
+ this.escapeCsvField(row.description),
30
+ ].join(",")
31
+ }
32
+
33
+ private static escapeCsvField(field: string): string {
34
+ if (field.includes(",") || field.includes('"') || field.includes("\n")) {
35
+ return `"${field.replace(/"/g, '""')}"`
36
+ }
37
+ return field
38
+ }
39
+ }
40
+
package/src/errors.ts ADDED
@@ -0,0 +1,49 @@
1
+ export class CommitAnalyzerError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly code: string,
5
+ ) {
6
+ super(message)
7
+ this.name = "CommitAnalyzerError"
8
+ }
9
+ }
10
+
11
+ export class GitError extends CommitAnalyzerError {
12
+ constructor(message: string) {
13
+ super(message, "GIT_ERROR")
14
+ }
15
+ }
16
+
17
+ export class LLMError extends CommitAnalyzerError {
18
+ constructor(message: string) {
19
+ super(message, "LLM_ERROR")
20
+ }
21
+ }
22
+
23
+ export class ValidationError extends CommitAnalyzerError {
24
+ constructor(message: string) {
25
+ super(message, "VALIDATION_ERROR")
26
+ }
27
+ }
28
+
29
+ export class FileError extends CommitAnalyzerError {
30
+ constructor(message: string) {
31
+ super(message, "FILE_ERROR")
32
+ }
33
+ }
34
+
35
+ export function handleError(error: unknown): never {
36
+ if (error instanceof CommitAnalyzerError) {
37
+ console.error(`Error [${error.code}]: ${error.message}`)
38
+ process.exit(1)
39
+ }
40
+
41
+ if (error instanceof Error) {
42
+ console.error(`Unexpected error: ${error.message}`)
43
+ process.exit(1)
44
+ }
45
+
46
+ console.error("Unknown error occurred")
47
+ process.exit(1)
48
+ }
49
+
package/src/git.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { execSync } from "child_process"
2
+ import { CommitInfo } from "./types"
3
+
4
+ export class GitService {
5
+ static async getCommitInfo(hash: string): Promise<CommitInfo> {
6
+ try {
7
+ const showOutput = execSync(
8
+ `git show --format="%H|%s|%ci" --no-patch "${hash}"`,
9
+ {
10
+ encoding: "utf8",
11
+ stdio: ["pipe", "pipe", "pipe"],
12
+ },
13
+ ).trim()
14
+
15
+ const [fullHash, message, dateStr] = showOutput.split("|")
16
+ const date = new Date(dateStr)
17
+ const year = date.getFullYear()
18
+
19
+ const diff = execSync(`git show "${hash}"`, {
20
+ encoding: "utf8",
21
+ stdio: ["pipe", "pipe", "pipe"],
22
+ maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large diffs
23
+ })
24
+
25
+ return {
26
+ hash: fullHash,
27
+ message,
28
+ date,
29
+ diff,
30
+ year,
31
+ }
32
+ } catch (error) {
33
+ throw new Error(
34
+ `Failed to get commit info for ${hash}: ${error instanceof Error ? error.message : "Unknown error"}`,
35
+ )
36
+ }
37
+ }
38
+
39
+ static validateCommitHash(hash: string): boolean {
40
+ try {
41
+ execSync(`git rev-parse --verify "${hash}"`, {
42
+ stdio: ["pipe", "pipe", "pipe"],
43
+ })
44
+ return true
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ static isGitRepository(): boolean {
51
+ try {
52
+ execSync("git rev-parse --git-dir", {
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ })
55
+ return true
56
+ } catch {
57
+ return false
58
+ }
59
+ }
60
+
61
+ static getCurrentUserEmail(): string {
62
+ try {
63
+ return execSync("git config user.email", {
64
+ encoding: "utf8",
65
+ stdio: ["pipe", "pipe", "pipe"],
66
+ }).trim()
67
+ } catch (error) {
68
+ throw new Error(
69
+ `Failed to get current user email: ${error instanceof Error ? error.message : "Unknown error"}`,
70
+ )
71
+ }
72
+ }
73
+
74
+ static getCurrentUserName(): string {
75
+ try {
76
+ return execSync("git config user.name", {
77
+ encoding: "utf8",
78
+ stdio: ["pipe", "pipe", "pipe"],
79
+ }).trim()
80
+ } catch (error) {
81
+ throw new Error(
82
+ `Failed to get current user name: ${error instanceof Error ? error.message : "Unknown error"}`,
83
+ )
84
+ }
85
+ }
86
+
87
+ static getUserAuthoredCommits(author?: string, limit?: number): string[] {
88
+ try {
89
+ const authorFilter = author || this.getCurrentUserEmail()
90
+ const limitFlag = limit ? `--max-count=${limit}` : ""
91
+
92
+ const output = execSync(
93
+ `git log --author="${authorFilter}" --format="%H" --no-merges ${limitFlag}`,
94
+ {
95
+ encoding: "utf8",
96
+ stdio: ["pipe", "pipe", "pipe"],
97
+ },
98
+ ).trim()
99
+
100
+ if (!output) {
101
+ return []
102
+ }
103
+
104
+ return output.split("\n").filter((hash) => hash.length > 0)
105
+ } catch (error) {
106
+ throw new Error(
107
+ `Failed to get user authored commits: ${error instanceof Error ? error.message : "Unknown error"}`,
108
+ )
109
+ }
110
+ }
111
+ }
112
+
package/src/index.ts ADDED
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { GitService } from "./git"
4
+ import { LLMService } from "./llm"
5
+ import { CSVService } from "./csv"
6
+ import { CLIService } from "./cli"
7
+ import { ProgressTracker } from "./progress"
8
+ import { MarkdownReportGenerator } from "./report-generator"
9
+ import { handleError, GitError, ValidationError } from "./errors"
10
+ import { AnalyzedCommit } from "./types"
11
+ import * as readline from "readline"
12
+
13
+ async function promptResume(): Promise<boolean> {
14
+ const rl = readline.createInterface({
15
+ input: process.stdin,
16
+ output: process.stdout,
17
+ })
18
+
19
+ return new Promise((resolve) => {
20
+ rl.question("\nDo you want to resume from the checkpoint? (y/n): ", (answer) => {
21
+ rl.close()
22
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes")
23
+ })
24
+ })
25
+ }
26
+
27
+ async function main(): Promise<void> {
28
+ try {
29
+ if (!GitService.isGitRepository()) {
30
+ throw new GitError("Current directory is not a git repository")
31
+ }
32
+
33
+ const options = CLIService.parseArguments()
34
+
35
+ // Handle input CSV mode (skip commit analysis, just generate report)
36
+ if (options.inputCsv) {
37
+ console.log("Generating report from existing CSV...")
38
+
39
+ // Ensure --report flag is set when using --input-csv
40
+ if (!options.report) {
41
+ options.report = true
42
+ console.log("Note: --report flag automatically enabled when using --input-csv")
43
+ }
44
+
45
+ // Determine output file name for report
46
+ let reportOutput = options.output || "summary-report.md"
47
+ if (reportOutput === "output.csv") {
48
+ reportOutput = "summary-report.md"
49
+ } else if (!reportOutput.endsWith('.md')) {
50
+ // If user specified output but it's not .md, append .md
51
+ reportOutput = reportOutput.replace(/\.[^.]+$/, '') + '.md'
52
+ }
53
+
54
+ await MarkdownReportGenerator.generateReport(options.inputCsv, reportOutput)
55
+ return
56
+ }
57
+
58
+ // Prompt to select LLM model if not provided
59
+ const availableModels = LLMService.detectAvailableModels()
60
+ if (availableModels.length === 0) {
61
+ throw new Error("No supported LLM models found. Please install claude, gemini, or codex.")
62
+ }
63
+ const defaultModel = LLMService.detectDefaultModel()
64
+ let selectedModel = options.model
65
+ if (!selectedModel) {
66
+ const rlModel = readline.createInterface({ input: process.stdin, output: process.stdout })
67
+ selectedModel = await new Promise<string>((resolve) =>
68
+ rlModel.question(`Select LLM model (${availableModels.join("/")}) [${defaultModel}]: `, (answer) => {
69
+ rlModel.close()
70
+ resolve(answer.trim() || defaultModel)
71
+ }),
72
+ )
73
+ }
74
+ LLMService.setModel(selectedModel)
75
+
76
+
77
+
78
+
79
+
80
+ // Handle clear flag
81
+ if (options.clear) {
82
+ if (ProgressTracker.hasProgress()) {
83
+ ProgressTracker.clearProgress()
84
+ console.log("āœ“ Progress checkpoint cleared")
85
+ } else {
86
+ console.log("No progress checkpoint to clear")
87
+ }
88
+ if (!options.resume) {
89
+ return
90
+ }
91
+ }
92
+
93
+ let commitsToAnalyze: string[] = options.commits
94
+ let analyzedCommits: AnalyzedCommit[] = []
95
+ let processedCommits: string[] = []
96
+
97
+ // Handle resume flag
98
+ if (options.resume && ProgressTracker.hasProgress()) {
99
+ const progressState = ProgressTracker.loadProgress()
100
+ if (progressState) {
101
+ console.log("šŸ“‚ Found previous session checkpoint")
102
+ console.log(ProgressTracker.formatProgressSummary(progressState))
103
+
104
+ const resumeChoice = await promptResume()
105
+ if (resumeChoice) {
106
+ commitsToAnalyze = ProgressTracker.getRemainingCommits(progressState)
107
+ analyzedCommits = progressState.analyzedCommits
108
+ processedCommits = progressState.processedCommits
109
+
110
+ // Use the output file from the previous session
111
+ options.output = progressState.outputFile
112
+
113
+ console.log(`\nā–¶ļø Resuming with ${commitsToAnalyze.length} remaining commits...`)
114
+ } else {
115
+ ProgressTracker.clearProgress()
116
+ console.log("Starting fresh analysis...")
117
+ }
118
+ }
119
+ } else if (options.resume && !ProgressTracker.hasProgress()) {
120
+ console.log("No previous checkpoint found. Starting fresh...")
121
+ }
122
+
123
+ // Only get new commits if not resuming
124
+ if (commitsToAnalyze.length === 0 || (!options.resume && !options.clear)) {
125
+ if (options.useDefaults) {
126
+ console.log("No commits specified, analyzing your authored commits...")
127
+ const userEmail = GitService.getCurrentUserEmail()
128
+ const userName = GitService.getCurrentUserName()
129
+ console.log(`Finding commits by ${userName} (${userEmail})`)
130
+
131
+ commitsToAnalyze = GitService.getUserAuthoredCommits(
132
+ options.author,
133
+ options.limit,
134
+ )
135
+
136
+ if (commitsToAnalyze.length === 0) {
137
+ throw new ValidationError(
138
+ "No commits found for the current user. Make sure you have commits in this repository.",
139
+ )
140
+ }
141
+
142
+ const limitText = options.limit ? ` (limited to ${options.limit})` : ""
143
+ console.log(`Found ${commitsToAnalyze.length} commits${limitText}`)
144
+ }
145
+ }
146
+
147
+ const totalCommitsToProcess = processedCommits.length + commitsToAnalyze.length
148
+ console.log(`\nAnalyzing ${commitsToAnalyze.length} commits (${totalCommitsToProcess} total)...`)
149
+
150
+ let failedCommits = 0
151
+
152
+ // Keep track of all commits for checkpoint
153
+ const allCommitsToAnalyze = [...processedCommits, ...commitsToAnalyze]
154
+
155
+ for (const [index, hash] of commitsToAnalyze.entries()) {
156
+ const overallIndex = processedCommits.length + index + 1
157
+ console.log(
158
+ `\n[${overallIndex}/${totalCommitsToProcess}] Processing commit: ${hash.substring(0, 8)}`,
159
+ )
160
+
161
+ if (!GitService.validateCommitHash(hash)) {
162
+ console.error(` āŒ Invalid commit hash: ${hash}`)
163
+ failedCommits++
164
+ processedCommits.push(hash)
165
+ continue
166
+ }
167
+
168
+ try {
169
+ const commitInfo = await GitService.getCommitInfo(hash)
170
+ console.log(` āœ“ Extracted commit info`)
171
+
172
+ const analysis = await LLMService.analyzeCommit(commitInfo)
173
+ console.log(
174
+ ` āœ“ Analyzed as "${analysis.category}": ${analysis.summary}`,
175
+ )
176
+
177
+ analyzedCommits.push({
178
+ ...commitInfo,
179
+ analysis,
180
+ })
181
+
182
+ processedCommits.push(hash)
183
+
184
+ // Save progress every 10 commits or on failure
185
+ if ((overallIndex % 10 === 0) || index === commitsToAnalyze.length - 1) {
186
+ ProgressTracker.saveProgress(
187
+ allCommitsToAnalyze,
188
+ processedCommits,
189
+ analyzedCommits,
190
+ options.output!,
191
+ )
192
+ console.log(` šŸ’¾ Progress saved (${overallIndex}/${totalCommitsToProcess})`)
193
+ }
194
+ } catch (error) {
195
+ const errorMessage = error instanceof Error ? error.message : "Unknown error"
196
+ console.error(` āŒ Failed: ${errorMessage}`)
197
+ failedCommits++
198
+ processedCommits.push(hash)
199
+
200
+ // Save progress on failure
201
+ ProgressTracker.saveProgress(
202
+ allCommitsToAnalyze,
203
+ processedCommits,
204
+ analyzedCommits,
205
+ options.output!,
206
+ )
207
+ console.log(` šŸ’¾ Progress saved after failure`)
208
+
209
+ // Always stop on failure after max retries
210
+ console.error(`\nā›” Stopping due to failure (after ${LLMService.getMaxRetries()} retry attempts)`)
211
+ console.log(`āœ… Successfully analyzed ${analyzedCommits.length} commits before failure`)
212
+ console.log(`šŸ“ Progress saved. Use --resume to continue from commit ${overallIndex + 1}`)
213
+
214
+ // Export what we have so far
215
+ if (analyzedCommits.length > 0) {
216
+ CSVService.exportToFile(analyzedCommits, options.output!)
217
+ console.log(`šŸ“Š Partial results exported to ${options.output}`)
218
+ }
219
+
220
+ process.exit(1)
221
+ }
222
+ }
223
+
224
+ if (analyzedCommits.length === 0) {
225
+ throw new ValidationError("No commits were successfully analyzed")
226
+ }
227
+
228
+ CSVService.exportToFile(analyzedCommits, options.output!)
229
+ console.log(`\nāœ… Analysis complete! Results exported to ${options.output}`)
230
+ console.log(
231
+ `Successfully analyzed ${analyzedCommits.length}/${totalCommitsToProcess} commits`,
232
+ )
233
+
234
+ if (failedCommits > 0) {
235
+ console.log(`āš ļø Failed to analyze ${failedCommits} commits (see errors above)`)
236
+ }
237
+
238
+ // Generate report if --report flag is provided
239
+ if (options.report) {
240
+ console.log("\nGenerating condensed markdown report...")
241
+
242
+ // Determine report output filename
243
+ let reportOutput: string
244
+ if (options.output!.endsWith('.csv')) {
245
+ reportOutput = options.output!.replace('.csv', '-report.md')
246
+ } else {
247
+ reportOutput = options.output! + '-report.md'
248
+ }
249
+
250
+ try {
251
+ await MarkdownReportGenerator.generateReport(options.output!, reportOutput)
252
+ console.log(`šŸ“Š Report generated: ${reportOutput}`)
253
+ } catch (error) {
254
+ console.error(`āš ļø Failed to generate report: ${error instanceof Error ? error.message : "Unknown error"}`)
255
+ console.log("CSV analysis was successful, but report generation failed.")
256
+ }
257
+ }
258
+
259
+ // Clear checkpoint on successful completion
260
+ ProgressTracker.clearProgress()
261
+ console.log("āœ“ Progress checkpoint cleared (analysis complete)")
262
+
263
+ const summary = analyzedCommits.reduce(
264
+ (acc, commit) => {
265
+ acc[commit.analysis.category] = (acc[commit.analysis.category] || 0) + 1
266
+ return acc
267
+ },
268
+ {} as Record<string, number>,
269
+ )
270
+
271
+ console.log("\nSummary by category:")
272
+ Object.entries(summary).forEach(([category, count]) => {
273
+ console.log(` ${category}: ${count} commits`)
274
+ })
275
+ } catch (error) {
276
+ handleError(error)
277
+ }
278
+ }
279
+
280
+ if (require.main === module) {
281
+ main()
282
+ }
283
+