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.
- package/.claude/settings.local.json +12 -0
- package/README.md +243 -0
- package/csv-to-report-prompt.md +97 -0
- package/package.json +39 -0
- package/prompt.md +69 -0
- package/src/cli.ts +143 -0
- package/src/csv-reader.ts +180 -0
- package/src/csv.ts +40 -0
- package/src/errors.ts +49 -0
- package/src/git.ts +112 -0
- package/src/index.ts +283 -0
- package/src/llm.ts +283 -0
- package/src/progress.ts +77 -0
- package/src/report-generator.ts +286 -0
- package/src/types.ts +24 -0
- package/tsconfig.json +19 -0
package/src/llm.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { execSync } from "child_process"
|
|
2
|
+
import { CommitInfo, LLMAnalysis } from "./types"
|
|
3
|
+
|
|
4
|
+
export class LLMService {
|
|
5
|
+
private static model: string
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Detect available LLM models by checking CLI commands.
|
|
9
|
+
*/
|
|
10
|
+
static detectAvailableModels(): string[] {
|
|
11
|
+
const models = ["claude", "gemini", "codex"]
|
|
12
|
+
return models.filter((model) => {
|
|
13
|
+
try {
|
|
14
|
+
execSync(`command -v ${model}`, { stdio: "ignore" })
|
|
15
|
+
return true
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Determine default LLM model based on availability.
|
|
24
|
+
*/
|
|
25
|
+
static detectDefaultModel(): string {
|
|
26
|
+
const available = this.detectAvailableModels()
|
|
27
|
+
if (available.length === 0) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"No supported LLM models found. Please install claude, gemini, or codex.",
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
// Default to sonnet if claude is available
|
|
33
|
+
if (available.includes('claude')) {
|
|
34
|
+
return 'claude --model sonnet'
|
|
35
|
+
}
|
|
36
|
+
return available[0]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Set the LLM model command to use.
|
|
41
|
+
*/
|
|
42
|
+
static setModel(model: string): void {
|
|
43
|
+
this.model = model
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the configured LLM model or detect default.
|
|
48
|
+
*/
|
|
49
|
+
static getModel(): string {
|
|
50
|
+
if (!this.model) {
|
|
51
|
+
this.model = this.detectDefaultModel()
|
|
52
|
+
}
|
|
53
|
+
return this.model
|
|
54
|
+
}
|
|
55
|
+
private static readonly MAX_RETRIES = parseInt(
|
|
56
|
+
process.env.LLM_MAX_RETRIES || "3",
|
|
57
|
+
10,
|
|
58
|
+
)
|
|
59
|
+
private static readonly INITIAL_RETRY_DELAY = parseInt(
|
|
60
|
+
process.env.LLM_INITIAL_RETRY_DELAY || "5000",
|
|
61
|
+
10,
|
|
62
|
+
)
|
|
63
|
+
private static readonly MAX_RETRY_DELAY = parseInt(
|
|
64
|
+
process.env.LLM_MAX_RETRY_DELAY || "30000",
|
|
65
|
+
10,
|
|
66
|
+
)
|
|
67
|
+
private static readonly RETRY_MULTIPLIER = parseFloat(
|
|
68
|
+
process.env.LLM_RETRY_MULTIPLIER || "2",
|
|
69
|
+
)
|
|
70
|
+
// Claude-specific configuration with backward compatibility
|
|
71
|
+
private static readonly CLAUDE_MAX_PROMPT_LENGTH = parseInt(
|
|
72
|
+
process.env.CLAUDE_MAX_PROMPT_LENGTH || process.env.LLM_MAX_PROMPT_LENGTH || "100000",
|
|
73
|
+
10,
|
|
74
|
+
)
|
|
75
|
+
private static readonly CLAUDE_MAX_DIFF_LENGTH = parseInt(
|
|
76
|
+
process.env.CLAUDE_MAX_DIFF_LENGTH || process.env.LLM_MAX_DIFF_LENGTH || "80000",
|
|
77
|
+
10,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
static async analyzeCommit(commit: CommitInfo): Promise<LLMAnalysis> {
|
|
81
|
+
const currentModel = this.getModel()
|
|
82
|
+
const prompt = this.buildPrompt(commit.message, commit.diff, currentModel)
|
|
83
|
+
|
|
84
|
+
// Log prompt length for debugging - only for Claude models
|
|
85
|
+
if (this.isClaudeModel(currentModel)) {
|
|
86
|
+
console.log(` - Prompt length: ${prompt.length} characters`)
|
|
87
|
+
if (prompt.length > this.CLAUDE_MAX_PROMPT_LENGTH) {
|
|
88
|
+
console.log(` - Warning: Prompt exceeds Claude max length (${this.CLAUDE_MAX_PROMPT_LENGTH})`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let lastError: Error | null = null
|
|
93
|
+
|
|
94
|
+
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
|
95
|
+
try {
|
|
96
|
+
const output = execSync(currentModel, {
|
|
97
|
+
input: prompt,
|
|
98
|
+
encoding: "utf8",
|
|
99
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
100
|
+
timeout: 60000,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return this.parseResponse(output)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
lastError = error instanceof Error ? error : new Error("Unknown error")
|
|
106
|
+
|
|
107
|
+
// Log detailed error information for debugging
|
|
108
|
+
console.log(` - Error details for commit ${commit.hash.substring(0, 8)}:`)
|
|
109
|
+
console.log(` Command: ${currentModel}`)
|
|
110
|
+
console.log(` Error message: ${lastError.message}`)
|
|
111
|
+
if (this.isClaudeModel(currentModel)) {
|
|
112
|
+
console.log(` Prompt length: ${prompt.length} characters`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// If it's an exec error, log additional details
|
|
116
|
+
if (error && typeof error === 'object' && 'stderr' in error) {
|
|
117
|
+
const execError = error as any
|
|
118
|
+
console.log(` Exit code: ${execError.status || 'unknown'}`)
|
|
119
|
+
console.log(` Signal: ${execError.signal || 'none'}`)
|
|
120
|
+
console.log(` Stderr: ${execError.stderr || 'none'}`)
|
|
121
|
+
console.log(` Stdout: ${execError.stdout || 'none'}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (attempt < this.MAX_RETRIES) {
|
|
125
|
+
const delay = Math.min(
|
|
126
|
+
this.INITIAL_RETRY_DELAY *
|
|
127
|
+
Math.pow(this.RETRY_MULTIPLIER, attempt - 1),
|
|
128
|
+
this.MAX_RETRY_DELAY,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
console.log(
|
|
132
|
+
` - Attempt ${attempt}/${this.MAX_RETRIES} failed for commit ${commit.hash.substring(0, 8)}. Retrying in ${delay / 1000}s...`,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
await this.sleep(delay)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Failed to analyze commit ${commit.hash} after ${this.MAX_RETRIES} attempts: ${lastError?.message || "Unknown error"}`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private static sleep(ms: number): Promise<void> {
|
|
146
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
static getMaxRetries(): number {
|
|
151
|
+
return this.MAX_RETRIES
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if the current model is Claude-based
|
|
156
|
+
*/
|
|
157
|
+
private static isClaudeModel(model?: string): boolean {
|
|
158
|
+
const currentModel = model || this.getModel()
|
|
159
|
+
return currentModel.toLowerCase().includes('claude')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private static buildPrompt(commitMessage: string, diff: string, model: string): string {
|
|
163
|
+
// Only truncate for Claude models
|
|
164
|
+
let truncatedDiff = diff
|
|
165
|
+
let diffTruncated = false
|
|
166
|
+
|
|
167
|
+
if (this.isClaudeModel(model) && diff.length > this.CLAUDE_MAX_DIFF_LENGTH) {
|
|
168
|
+
truncatedDiff = diff.substring(0, this.CLAUDE_MAX_DIFF_LENGTH) + "\n\n[DIFF TRUNCATED - Original length: " + diff.length + " characters]"
|
|
169
|
+
diffTruncated = true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const basePrompt = `Analyze this git commit and provide a categorization:
|
|
173
|
+
|
|
174
|
+
COMMIT MESSAGE:
|
|
175
|
+
${commitMessage}
|
|
176
|
+
|
|
177
|
+
COMMIT DIFF:
|
|
178
|
+
${truncatedDiff}
|
|
179
|
+
|
|
180
|
+
Based on the commit message and code changes, categorize this commit as one of:
|
|
181
|
+
- "tweak": Minor adjustments, bug fixes, small improvements
|
|
182
|
+
- "feature": New functionality, major additions
|
|
183
|
+
- "process": Build system, CI/CD, tooling, configuration changes
|
|
184
|
+
|
|
185
|
+
Provide:
|
|
186
|
+
1. Category: [tweak|feature|process]
|
|
187
|
+
2. Summary: One-line description (max 80 chars)
|
|
188
|
+
3. Description: Detailed explanation (2-3 sentences)
|
|
189
|
+
|
|
190
|
+
Format as JSON:
|
|
191
|
+
\`\`\`json
|
|
192
|
+
{
|
|
193
|
+
"category": "...",
|
|
194
|
+
"summary": "...",
|
|
195
|
+
"description": "..."
|
|
196
|
+
}
|
|
197
|
+
\`\`\``
|
|
198
|
+
|
|
199
|
+
// Final length check - only for Claude models
|
|
200
|
+
if (this.isClaudeModel(model) && basePrompt.length > this.CLAUDE_MAX_PROMPT_LENGTH) {
|
|
201
|
+
// Further truncate the diff if needed
|
|
202
|
+
const overhead = basePrompt.length - this.CLAUDE_MAX_PROMPT_LENGTH
|
|
203
|
+
const newDiffLength = Math.max(1000, this.CLAUDE_MAX_DIFF_LENGTH - overhead - 200) // Keep at least 1000 chars, subtract extra for safety
|
|
204
|
+
truncatedDiff = diff.substring(0, newDiffLength) + "\n\n[DIFF HEAVILY TRUNCATED - Original length: " + diff.length + " characters]"
|
|
205
|
+
|
|
206
|
+
return `Analyze this git commit and provide a categorization:
|
|
207
|
+
|
|
208
|
+
COMMIT MESSAGE:
|
|
209
|
+
${commitMessage}
|
|
210
|
+
|
|
211
|
+
COMMIT DIFF:
|
|
212
|
+
${truncatedDiff}
|
|
213
|
+
|
|
214
|
+
Based on the commit message and code changes, categorize this commit as one of:
|
|
215
|
+
- "tweak": Minor adjustments, bug fixes, small improvements
|
|
216
|
+
- "feature": New functionality, major additions
|
|
217
|
+
- "process": Build system, CI/CD, tooling, configuration changes
|
|
218
|
+
|
|
219
|
+
Provide:
|
|
220
|
+
1. Category: [tweak|feature|process]
|
|
221
|
+
2. Summary: One-line description (max 80 chars)
|
|
222
|
+
3. Description: Detailed explanation (2-3 sentences)
|
|
223
|
+
|
|
224
|
+
Format as JSON:
|
|
225
|
+
\`\`\`json
|
|
226
|
+
{
|
|
227
|
+
"category": "...",
|
|
228
|
+
"summary": "...",
|
|
229
|
+
"description": "..."
|
|
230
|
+
}
|
|
231
|
+
\`\`\``
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return basePrompt
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private static parseResponse(response: string): LLMAnalysis {
|
|
238
|
+
try {
|
|
239
|
+
const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/)
|
|
240
|
+
if (!jsonMatch) {
|
|
241
|
+
throw new Error("No JSON block found in response")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const parsed = JSON.parse(jsonMatch[1])
|
|
245
|
+
|
|
246
|
+
if (!this.isValidCategory(parsed.category)) {
|
|
247
|
+
throw new Error(`Invalid category: ${parsed.category}`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!parsed.summary || !parsed.description) {
|
|
251
|
+
throw new Error("Missing required fields in response")
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
category: parsed.category,
|
|
256
|
+
summary: parsed.summary.substring(0, 80),
|
|
257
|
+
description: parsed.description,
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// Log the raw response for debugging
|
|
261
|
+
console.log(` - Raw LLM response (first 1000 chars): ${response.substring(0, 1000)}`)
|
|
262
|
+
if (response.length > 1000) {
|
|
263
|
+
console.log(` - Response truncated (total length: ${response.length} chars)`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Try to extract and log the JSON block if it exists but is malformed
|
|
267
|
+
const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/)
|
|
268
|
+
if (jsonMatch) {
|
|
269
|
+
console.log(` - Extracted JSON block: ${jsonMatch[1]}`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Failed to parse LLM response: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private static isValidCategory(
|
|
279
|
+
category: string,
|
|
280
|
+
): category is "tweak" | "feature" | "process" {
|
|
281
|
+
return ["tweak", "feature", "process"].includes(category)
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
const state: ProgressState = {
|
|
23
|
+
totalCommits,
|
|
24
|
+
processedCommits,
|
|
25
|
+
analyzedCommits,
|
|
26
|
+
lastProcessedIndex: processedCommits.length - 1,
|
|
27
|
+
startTime: new Date().toISOString(),
|
|
28
|
+
outputFile,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
writeFileSync(this.CHECKPOINT_FILE, JSON.stringify(state, null, 2))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static loadProgress(): ProgressState | null {
|
|
35
|
+
if (!existsSync(this.CHECKPOINT_FILE)) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(this.CHECKPOINT_FILE, "utf8")
|
|
41
|
+
return JSON.parse(content)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error("Failed to load progress file:", error)
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static hasProgress(): boolean {
|
|
49
|
+
return existsSync(this.CHECKPOINT_FILE)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static clearProgress(): void {
|
|
53
|
+
if (existsSync(this.CHECKPOINT_FILE)) {
|
|
54
|
+
unlinkSync(this.CHECKPOINT_FILE)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static getRemainingCommits(state: ProgressState): string[] {
|
|
59
|
+
const processedSet = new Set(state.processedCommits)
|
|
60
|
+
return state.totalCommits.filter(hash => !processedSet.has(hash))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static formatProgressSummary(state: ProgressState): string {
|
|
64
|
+
const processed = state.processedCommits.length
|
|
65
|
+
const total = state.totalCommits.length
|
|
66
|
+
const remaining = total - processed
|
|
67
|
+
const percentComplete = Math.round((processed / total) * 100)
|
|
68
|
+
|
|
69
|
+
return `
|
|
70
|
+
Previous session:
|
|
71
|
+
- Started: ${new Date(state.startTime).toLocaleString()}
|
|
72
|
+
- Progress: ${processed}/${total} commits (${percentComplete}%)
|
|
73
|
+
- Remaining: ${remaining} commits
|
|
74
|
+
- Output file: ${state.outputFile}
|
|
75
|
+
`.trim()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { writeFileSync } from "fs"
|
|
2
|
+
import { CSVReaderService, ParsedCSVRow } from "./csv-reader"
|
|
3
|
+
import { LLMService } from "./llm"
|
|
4
|
+
|
|
5
|
+
export class MarkdownReportGenerator {
|
|
6
|
+
/**
|
|
7
|
+
* Generate a markdown report from CSV data
|
|
8
|
+
*/
|
|
9
|
+
static async generateReport(
|
|
10
|
+
csvFilePath: string,
|
|
11
|
+
outputPath: string,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
console.log(`Reading CSV data from ${csvFilePath}...`)
|
|
14
|
+
|
|
15
|
+
// Read and parse CSV data
|
|
16
|
+
const csvData = CSVReaderService.readCSV(csvFilePath)
|
|
17
|
+
|
|
18
|
+
if (csvData.length === 0) {
|
|
19
|
+
throw new Error("No data found in CSV file")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get statistics for logging
|
|
23
|
+
const stats = CSVReaderService.getStatistics(csvData)
|
|
24
|
+
console.log(
|
|
25
|
+
`Found ${stats.totalRows} commits spanning ${stats.yearRange.min}-${stats.yearRange.max}`,
|
|
26
|
+
)
|
|
27
|
+
console.log(
|
|
28
|
+
`Categories: ${stats.categoryBreakdown.feature} features, ${stats.categoryBreakdown.process} process, ${stats.categoryBreakdown.tweak} tweaks`,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
console.log("Generating condensed report...")
|
|
32
|
+
|
|
33
|
+
// Generate commit analysis section programmatically
|
|
34
|
+
const analysisSection = this.generateAnalysisSection(csvData)
|
|
35
|
+
|
|
36
|
+
// Convert CSV data to string format for LLM (for content summarization only)
|
|
37
|
+
const csvContent = this.convertToCSVString(csvData)
|
|
38
|
+
|
|
39
|
+
// Generate detailed yearly summaries using LLM
|
|
40
|
+
const yearlyContent = await this.generateYearlySummariesWithLLM(csvContent)
|
|
41
|
+
|
|
42
|
+
// Combine analysis section with LLM-generated content
|
|
43
|
+
const reportContent = `# Development Summary Report\n\n${analysisSection}\n\n${yearlyContent}`
|
|
44
|
+
|
|
45
|
+
// Write to output file
|
|
46
|
+
writeFileSync(outputPath, reportContent, "utf8")
|
|
47
|
+
|
|
48
|
+
console.log(`Report generated: ${outputPath}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert parsed CSV data back to CSV string format for LLM consumption
|
|
53
|
+
*/
|
|
54
|
+
private static convertToCSVString(data: ParsedCSVRow[]): string {
|
|
55
|
+
const header = "year,category,summary,description"
|
|
56
|
+
const rows = data.map((row) =>
|
|
57
|
+
[
|
|
58
|
+
row.year,
|
|
59
|
+
this.escapeCsvField(row.category),
|
|
60
|
+
this.escapeCsvField(row.summary),
|
|
61
|
+
this.escapeCsvField(row.description),
|
|
62
|
+
].join(","),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return [header, ...rows].join("\n")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Escape CSV fields that contain commas, quotes, or newlines
|
|
70
|
+
*/
|
|
71
|
+
private static escapeCsvField(field: string): string {
|
|
72
|
+
if (field.includes(",") || field.includes('"') || field.includes("\n")) {
|
|
73
|
+
return `"${field.replace(/"/g, '""')}"`
|
|
74
|
+
}
|
|
75
|
+
return field
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Generate commit analysis section with accurate counts
|
|
80
|
+
*/
|
|
81
|
+
private static generateAnalysisSection(csvData: ParsedCSVRow[]): string {
|
|
82
|
+
const stats = CSVReaderService.getStatistics(csvData)
|
|
83
|
+
|
|
84
|
+
// Group data by year for detailed breakdown
|
|
85
|
+
const yearlyStats = csvData.reduce(
|
|
86
|
+
(acc, row) => {
|
|
87
|
+
if (!acc[row.year]) {
|
|
88
|
+
acc[row.year] = { tweak: 0, feature: 0, process: 0, total: 0 }
|
|
89
|
+
}
|
|
90
|
+
acc[row.year][row.category]++
|
|
91
|
+
acc[row.year].total++
|
|
92
|
+
return acc
|
|
93
|
+
},
|
|
94
|
+
{} as Record<
|
|
95
|
+
number,
|
|
96
|
+
{ tweak: number; feature: number; process: number; total: number }
|
|
97
|
+
>,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Sort years in descending order
|
|
101
|
+
const sortedYears = Object.keys(yearlyStats)
|
|
102
|
+
.map(Number)
|
|
103
|
+
.sort((a, b) => b - a)
|
|
104
|
+
|
|
105
|
+
let analysisContent = `## Commit Analysis\n`
|
|
106
|
+
analysisContent += `- **Total Commits**: ${stats.totalRows} commits across ${stats.yearRange.min}-${stats.yearRange.max}\n`
|
|
107
|
+
|
|
108
|
+
// Add year-by-year breakdown
|
|
109
|
+
for (const year of sortedYears) {
|
|
110
|
+
const yearData = yearlyStats[year]
|
|
111
|
+
analysisContent += `- **${year}**: ${yearData.total} commits (${yearData.feature} features, ${yearData.process} process, ${yearData.tweak} tweaks)\n`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return analysisContent
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Generate yearly summaries using LLM service
|
|
119
|
+
*/
|
|
120
|
+
private static async generateYearlySummariesWithLLM(
|
|
121
|
+
csvContent: string,
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
const prompt = this.buildReportPrompt(csvContent)
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// Use the same retry logic as commit analysis
|
|
127
|
+
const response = await this.callLLMWithRetry(prompt)
|
|
128
|
+
return this.parseReportResponse(response)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Failed to generate report: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the prompt for report generation based on the template
|
|
138
|
+
*/
|
|
139
|
+
private static buildReportPrompt(csvContent: string): string {
|
|
140
|
+
return `Analyze the following CSV data containing git commit analysis results and generate a condensed markdown development summary report.
|
|
141
|
+
|
|
142
|
+
CSV DATA:
|
|
143
|
+
${csvContent}
|
|
144
|
+
|
|
145
|
+
INSTRUCTIONS:
|
|
146
|
+
1. Group the data by year (descending order, most recent first)
|
|
147
|
+
2. Within each year, group by category: Features, Process Improvements, and Tweaks & Bug Fixes
|
|
148
|
+
3. Consolidate similar items within each category to create readable summaries
|
|
149
|
+
4. Focus on what was accomplished rather than individual commit details
|
|
150
|
+
5. Use clear, professional language appropriate for stakeholders
|
|
151
|
+
|
|
152
|
+
CATEGORY MAPPING:
|
|
153
|
+
- "feature" → "Features" section
|
|
154
|
+
- "process" → "Processes" section
|
|
155
|
+
- "tweak" → "Tweaks & Bug Fixes" section
|
|
156
|
+
|
|
157
|
+
CONSOLIDATION GUIDELINES:
|
|
158
|
+
- Group similar features together (e.g., "authentication system improvements")
|
|
159
|
+
- Combine related bug fixes (e.g., "resolved 8 authentication issues")
|
|
160
|
+
- Summarize process changes by theme (e.g., "CI/CD pipeline enhancements")
|
|
161
|
+
- Use bullet points for individual items within categories
|
|
162
|
+
- Aim for 3-7 bullet points per category per year
|
|
163
|
+
- Include specific numbers when relevant (e.g., "15 bug fixes", "3 new features")
|
|
164
|
+
|
|
165
|
+
OUTPUT FORMAT:
|
|
166
|
+
Generate yearly summary sections with this exact structure (DO NOT include the main title or commit analysis section):
|
|
167
|
+
|
|
168
|
+
\`\`\`markdown
|
|
169
|
+
## [YEAR]
|
|
170
|
+
### Features
|
|
171
|
+
- [Consolidated feature summary 1]
|
|
172
|
+
- [Consolidated feature summary 2]
|
|
173
|
+
- [Additional features as needed]
|
|
174
|
+
|
|
175
|
+
### Processes
|
|
176
|
+
- [Consolidated process improvement 1]
|
|
177
|
+
- [Consolidated process improvement 2]
|
|
178
|
+
- [Additional process items as needed]
|
|
179
|
+
|
|
180
|
+
### Tweaks & Bug Fixes
|
|
181
|
+
- [Consolidated tweak/fix summary 1]
|
|
182
|
+
- [Consolidated tweak/fix summary 2]
|
|
183
|
+
- [Additional tweaks/fixes as needed]
|
|
184
|
+
|
|
185
|
+
## [PREVIOUS YEAR]
|
|
186
|
+
[Repeat structure for each year in the data]
|
|
187
|
+
\`\`\`
|
|
188
|
+
|
|
189
|
+
QUALITY REQUIREMENTS:
|
|
190
|
+
- Keep summaries concise but informative
|
|
191
|
+
- Use active voice and clear language
|
|
192
|
+
- Avoid technical jargon where possible
|
|
193
|
+
- Ensure each bullet point represents meaningful work
|
|
194
|
+
- Make the report valuable for both technical and non-technical readers
|
|
195
|
+
|
|
196
|
+
Generate the markdown report now:`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Call LLM with retry logic similar to commit analysis
|
|
201
|
+
*/
|
|
202
|
+
private static async callLLMWithRetry(prompt: string): Promise<string> {
|
|
203
|
+
const maxRetries = LLMService.getMaxRetries()
|
|
204
|
+
let lastError: Error | null = null
|
|
205
|
+
|
|
206
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
207
|
+
try {
|
|
208
|
+
// Create a mock commit object for the LLM service
|
|
209
|
+
const mockCommit = {
|
|
210
|
+
hash: "report-generation",
|
|
211
|
+
message: "Generate report from CSV data",
|
|
212
|
+
date: new Date(),
|
|
213
|
+
diff: prompt,
|
|
214
|
+
year: new Date().getFullYear(),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Use the existing LLM service but intercept the response
|
|
218
|
+
const { execSync } = require("child_process")
|
|
219
|
+
const currentModel = LLMService.getModel()
|
|
220
|
+
|
|
221
|
+
console.log(` - Using model: ${currentModel}`)
|
|
222
|
+
console.log(
|
|
223
|
+
` - Processing ${prompt.split("\n").length} lines of CSV data`,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
const output = execSync(currentModel, {
|
|
227
|
+
input: prompt,
|
|
228
|
+
encoding: "utf8",
|
|
229
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
230
|
+
timeout: 120000, // Longer timeout for report generation
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
return output.trim()
|
|
234
|
+
} catch (error) {
|
|
235
|
+
lastError = error instanceof Error ? error : new Error("Unknown error")
|
|
236
|
+
|
|
237
|
+
console.log(` - Error generating report:`)
|
|
238
|
+
console.log(` Attempt: ${attempt}/${maxRetries}`)
|
|
239
|
+
console.log(` Error: ${lastError.message}`)
|
|
240
|
+
|
|
241
|
+
if (attempt < maxRetries) {
|
|
242
|
+
const delay = Math.min(5000 * Math.pow(2, attempt - 1), 30000)
|
|
243
|
+
console.log(` Retrying in ${delay / 1000}s...`)
|
|
244
|
+
await this.sleep(delay)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
throw new Error(
|
|
250
|
+
`Failed to generate report after ${maxRetries} attempts: ${lastError?.message || "Unknown error"}`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Parse the LLM response to extract the yearly summary content
|
|
256
|
+
*/
|
|
257
|
+
private static parseReportResponse(response: string): string {
|
|
258
|
+
// Look for markdown block first
|
|
259
|
+
const markdownMatch = response.match(/```markdown\s*([\s\S]*?)\s*```/)
|
|
260
|
+
if (markdownMatch) {
|
|
261
|
+
return markdownMatch[1].trim()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// If no markdown block, look for content starting with "##" (yearly sections)
|
|
265
|
+
const yearSectionMatch = response.match(/^(##\s+\d{4}[\s\S]*)/m)
|
|
266
|
+
if (yearSectionMatch) {
|
|
267
|
+
return yearSectionMatch[1].trim()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// If no clear structure found, return the entire response but log a warning
|
|
271
|
+
console.log(
|
|
272
|
+
" - Warning: Could not find structured yearly sections in LLM response",
|
|
273
|
+
)
|
|
274
|
+
console.log(` - Response preview: ${response.substring(0, 200)}...`)
|
|
275
|
+
|
|
276
|
+
return response.trim()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Sleep utility for retry delays
|
|
281
|
+
*/
|
|
282
|
+
private static sleep(ms: number): Promise<void> {
|
|
283
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface CommitInfo {
|
|
2
|
+
hash: string
|
|
3
|
+
message: string
|
|
4
|
+
date: Date
|
|
5
|
+
diff: string
|
|
6
|
+
year: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LLMAnalysis {
|
|
10
|
+
category: "tweak" | "feature" | "process"
|
|
11
|
+
summary: string
|
|
12
|
+
description: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AnalyzedCommit extends CommitInfo {
|
|
16
|
+
analysis: LLMAnalysis
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CSVRow {
|
|
20
|
+
year: number
|
|
21
|
+
category: string
|
|
22
|
+
summary: string
|
|
23
|
+
description: string
|
|
24
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": ["ES2020"],
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|