commit-analyzer 1.0.3 → 1.1.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.
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 +18 -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 +209 -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 +46 -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 +252 -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 +143 -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 +109 -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 -411
  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,143 @@
1
+ import { execSync } from "child_process"
2
+
3
+ import { IVersionControlService } from "@presentation/version-control-service.interface"
4
+
5
+ import { getErrorMessage } from "../utils"
6
+
7
+ export class GitAdapter implements IVersionControlService {
8
+ private static readonly LARGE_DIFF_BUFFER = 50 * 1024 * 1024 // 50MB buffer for large diffs
9
+ private static readonly EXEC_OPTIONS = {
10
+ encoding: "utf8" as const,
11
+ stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"],
12
+ }
13
+
14
+ async getCommitInfo(hash: string): Promise<{
15
+ hash: string
16
+ message: string
17
+ date: Date
18
+ diff: string
19
+ }> {
20
+ try {
21
+ const showOutput = execSync(
22
+ `git show --format="%H|%s|%ci" --no-patch "${hash}"`,
23
+ GitAdapter.EXEC_OPTIONS,
24
+ ).trim()
25
+
26
+ const [fullHash, message, dateStr] = showOutput.split("|")
27
+ const date = new Date(dateStr)
28
+
29
+ const diff = execSync(`git show "${hash}"`, {
30
+ ...GitAdapter.EXEC_OPTIONS,
31
+ maxBuffer: GitAdapter.LARGE_DIFF_BUFFER,
32
+ })
33
+
34
+ return {
35
+ hash: fullHash,
36
+ message,
37
+ date,
38
+ diff,
39
+ }
40
+ } catch (error) {
41
+ throw new Error(
42
+ `Failed to get commit info for ${hash}: ${getErrorMessage(error)}`,
43
+ )
44
+ }
45
+ }
46
+
47
+ async validateCommitHash(hash: string): Promise<boolean> {
48
+ try {
49
+ execSync(`git rev-parse --verify "${hash}"`, GitAdapter.EXEC_OPTIONS)
50
+ return true
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
+
56
+ async isValidRepository(): Promise<boolean> {
57
+ try {
58
+ execSync("git rev-parse --git-dir", GitAdapter.EXEC_OPTIONS)
59
+ return true
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ async getCurrentUserEmail(): Promise<string> {
66
+ try {
67
+ return execSync("git config user.email", GitAdapter.EXEC_OPTIONS).trim()
68
+ } catch (error) {
69
+ throw new Error(
70
+ `Failed to get current user email: ${getErrorMessage(error)}`,
71
+ )
72
+ }
73
+ }
74
+
75
+ async getCurrentUserName(): Promise<string> {
76
+ try {
77
+ return execSync("git config user.name", GitAdapter.EXEC_OPTIONS).trim()
78
+ } catch (error) {
79
+ throw new Error(
80
+ `Failed to get current user name: ${getErrorMessage(error)}`,
81
+ )
82
+ }
83
+ }
84
+
85
+ async getRepositoryName(): Promise<string> {
86
+ try {
87
+ // Try to get the repository name from remote origin URL
88
+ const remoteUrl = execSync("git config --get remote.origin.url", GitAdapter.EXEC_OPTIONS).trim()
89
+
90
+ // Extract repository name from various URL formats
91
+ // git@github.com:user/repo.git -> repo
92
+ // https://github.com/user/repo.git -> repo
93
+ // https://github.com/user/repo -> repo
94
+ const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/)
95
+ if (match && match[1]) {
96
+ return match[1]
97
+ }
98
+
99
+ // Fallback: get the directory name
100
+ const dirName = execSync("basename $(git rev-parse --show-toplevel)", GitAdapter.EXEC_OPTIONS).trim()
101
+ return dirName
102
+ } catch (error) {
103
+ // Final fallback: use current directory name
104
+ try {
105
+ return execSync("basename $(pwd)", GitAdapter.EXEC_OPTIONS).trim()
106
+ } catch {
107
+ return "Unknown Project"
108
+ }
109
+ }
110
+ }
111
+
112
+ async getUserAuthoredCommits(params: {
113
+ authorEmail: string
114
+ limit?: number
115
+ since?: string
116
+ until?: string
117
+ }): Promise<string[]> {
118
+ const { authorEmail, limit, since, until } = params
119
+ try {
120
+ const limitFlag = limit ? `--max-count=${limit}` : ""
121
+ const sinceFlag = since ? `--since="${since}"` : ""
122
+ const untilFlag = until ? `--until="${until}"` : ""
123
+
124
+ const output = execSync(
125
+ `git log --author="${authorEmail}" --format="%H" --no-merges ${limitFlag} ${sinceFlag} ${untilFlag}`,
126
+ GitAdapter.EXEC_OPTIONS,
127
+ ).trim()
128
+
129
+ return this.parseCommitHashes(output)
130
+ } catch (error) {
131
+ throw new Error(
132
+ `Failed to get user authored commits: ${getErrorMessage(error)}`,
133
+ )
134
+ }
135
+ }
136
+
137
+ private parseCommitHashes(output: string): string[] {
138
+ if (!output) {
139
+ return []
140
+ }
141
+ return output.split("\n").filter((hash) => hash.length > 0)
142
+ }
143
+ }
@@ -0,0 +1,85 @@
1
+ import { Commit } from "@domain/commit"
2
+ import { CommitHash } from "@domain/commit-hash"
3
+
4
+ import { ICommitRepository } from "@presentation/commit-repository.interface"
5
+ import { IVersionControlService } from "@presentation/version-control-service.interface"
6
+
7
+ export class GitCommitRepository implements ICommitRepository {
8
+ constructor(private readonly versionControlService: IVersionControlService) {}
9
+
10
+ async getByHash(hash: CommitHash): Promise<Commit> {
11
+ const commitInfo = await this.versionControlService.getCommitInfo(
12
+ hash.getValue(),
13
+ )
14
+
15
+ return new Commit({
16
+ hash,
17
+ message: commitInfo.message,
18
+ date: commitInfo.date,
19
+ diff: commitInfo.diff,
20
+ })
21
+ }
22
+
23
+ async getByAuthor(params: {
24
+ authorEmail: string
25
+ limit?: number
26
+ since?: string
27
+ until?: string
28
+ }): Promise<Commit[]> {
29
+ const { authorEmail, limit, since, until } = params
30
+ const commitHashes =
31
+ await this.versionControlService.getUserAuthoredCommits({
32
+ authorEmail,
33
+ limit,
34
+ since,
35
+ until,
36
+ })
37
+ const commits: Commit[] = []
38
+
39
+ for (const hashString of commitHashes) {
40
+ try {
41
+ const hash = CommitHash.create(hashString)
42
+ const commit = await this.getByHash(hash)
43
+ commits.push(commit)
44
+ } catch (error) {
45
+ console.warn(`Warning: Failed to load commit ${hashString}:`, error)
46
+ }
47
+ }
48
+
49
+ return commits
50
+ }
51
+
52
+ async getByHashes(hashes: CommitHash[]): Promise<Commit[]> {
53
+ const commits: Commit[] = []
54
+
55
+ for (const hash of hashes) {
56
+ try {
57
+ const commit = await this.getByHash(hash)
58
+ commits.push(commit)
59
+ } catch (error) {
60
+ console.warn(
61
+ `Warning: Failed to load commit ${hash.getShortHash()}:`,
62
+ error,
63
+ )
64
+ }
65
+ }
66
+
67
+ return commits
68
+ }
69
+
70
+ async exists(hash: CommitHash): Promise<boolean> {
71
+ return this.versionControlService.validateCommitHash(hash.getValue())
72
+ }
73
+
74
+ async getCurrentUserEmail(): Promise<string> {
75
+ return this.versionControlService.getCurrentUserEmail()
76
+ }
77
+
78
+ async getCurrentUserName(): Promise<string> {
79
+ return this.versionControlService.getCurrentUserName()
80
+ }
81
+
82
+ async isValidRepository(): Promise<boolean> {
83
+ return this.versionControlService.isValidRepository()
84
+ }
85
+ }
@@ -0,0 +1,182 @@
1
+ import { Analysis } from "@domain/analysis"
2
+ import { AnalyzedCommit } from "@domain/analyzed-commit"
3
+ import { Category } from "@domain/category"
4
+ import { Commit } from "@domain/commit"
5
+ import { CommitHash } from "@domain/commit-hash"
6
+
7
+ import { ConsoleFormatter } from "@presentation/console-formatter"
8
+ import {
9
+ IProgressRepository,
10
+ ProgressState,
11
+ } from "@presentation/progress-repository.interface"
12
+ import { IStorageService } from "@presentation/storage-service.interface"
13
+
14
+ import { calculatePercentage } from "../utils"
15
+ import { AppPaths } from "../utils/app-paths"
16
+
17
+ export class JSONProgressTracker implements IProgressRepository {
18
+ private static readonly JSON_INDENT = 2
19
+
20
+ constructor(private readonly storageService: IStorageService) {}
21
+
22
+ private getProgressFilePath(): string {
23
+ return AppPaths.getProgressFilePath()
24
+ }
25
+
26
+ async saveProgress(state: ProgressState): Promise<void> {
27
+ const serializedState = this.serializeProgressState(state)
28
+ const content = JSON.stringify(
29
+ serializedState,
30
+ null,
31
+ JSONProgressTracker.JSON_INDENT,
32
+ )
33
+ await this.storageService.writeFile(
34
+ this.getProgressFilePath(),
35
+ content,
36
+ )
37
+ }
38
+
39
+ async loadProgress(): Promise<ProgressState | null> {
40
+ if (!(await this.hasProgress())) {
41
+ return null
42
+ }
43
+
44
+ try {
45
+ const content = await this.storageService.readFile(
46
+ this.getProgressFilePath(),
47
+ )
48
+ const serializedState = JSON.parse(content)
49
+ return this.deserializeProgressState(serializedState)
50
+ } catch (error) {
51
+ ConsoleFormatter.logError(`Failed to load progress file: ${error}`)
52
+ return null
53
+ }
54
+ }
55
+
56
+ async hasProgress(): Promise<boolean> {
57
+ return this.storageService.fileExists(this.getProgressFilePath())
58
+ }
59
+
60
+ async clearProgress(): Promise<void> {
61
+ await this.storageService.deleteFile(this.getProgressFilePath())
62
+ }
63
+
64
+ getRemainingCommits(state: ProgressState): CommitHash[] {
65
+ const processedHashes = new Set(
66
+ state.processedCommits.map((hash) => hash.getValue()),
67
+ )
68
+ return state.totalCommits.filter(
69
+ (hash) => !processedHashes.has(hash.getValue()),
70
+ )
71
+ }
72
+
73
+ formatProgressSummary(state: ProgressState): string {
74
+ const processed = state.processedCommits.length
75
+ const total = state.totalCommits.length
76
+ const remaining = total - processed
77
+ const percentComplete = calculatePercentage(processed, total)
78
+
79
+ return `
80
+ Previous session:
81
+ - Started: ${state.startTime.toLocaleString()}
82
+ - Progress: ${processed}/${total} commits (${percentComplete}%)
83
+ - Remaining: ${remaining} commits
84
+ - Output file: ${state.outputFile}
85
+ `.trim()
86
+ }
87
+
88
+ private serializeProgressState(
89
+ state: ProgressState,
90
+ ): Record<string, unknown> {
91
+ return {
92
+ totalCommits: state.totalCommits.map((hash) => hash.getValue()),
93
+ processedCommits: state.processedCommits.map((hash) => hash.getValue()),
94
+ analyzedCommits: state.analyzedCommits.map((commit) => ({
95
+ hash: commit.getHash().getValue(),
96
+ message: commit.getMessage(),
97
+ date: commit.getDate().toISOString(),
98
+ year: commit.getYear(),
99
+ category: commit.getAnalysis().getCategory().getValue(),
100
+ summary: commit.getAnalysis().getSummary(),
101
+ description: commit.getAnalysis().getDescription(),
102
+ })),
103
+ lastProcessedIndex: state.lastProcessedIndex,
104
+ startTime: state.startTime.toISOString(),
105
+ outputFile: state.outputFile,
106
+ }
107
+ }
108
+
109
+ private deserializeProgressState(
110
+ data: Record<string, unknown>,
111
+ ): ProgressState {
112
+ this.validateProgressData(data)
113
+
114
+ return {
115
+ totalCommits: (data.totalCommits as string[]).map((hash: string) =>
116
+ CommitHash.create(hash),
117
+ ),
118
+ processedCommits: (data.processedCommits as string[]).map(
119
+ (hash: string) => CommitHash.create(hash),
120
+ ),
121
+ analyzedCommits: this.deserializeAnalyzedCommits(
122
+ data.analyzedCommits as Record<string, unknown>[],
123
+ ),
124
+ lastProcessedIndex: data.lastProcessedIndex as number,
125
+ startTime: new Date(data.startTime as string),
126
+ outputFile: data.outputFile as string,
127
+ }
128
+ }
129
+
130
+ private validateProgressData(data: Record<string, unknown>): void {
131
+ if (!Array.isArray(data.totalCommits)) {
132
+ throw new Error("Invalid progress data: totalCommits must be an array")
133
+ }
134
+
135
+ if (!Array.isArray(data.processedCommits)) {
136
+ throw new Error(
137
+ "Invalid progress data: processedCommits must be an array",
138
+ )
139
+ }
140
+
141
+ if (typeof data.lastProcessedIndex !== "number") {
142
+ throw new Error(
143
+ "Invalid progress data: lastProcessedIndex must be a number",
144
+ )
145
+ }
146
+
147
+ if (typeof data.startTime !== "string") {
148
+ throw new Error("Invalid progress data: startTime must be a string")
149
+ }
150
+
151
+ if (typeof data.outputFile !== "string") {
152
+ throw new Error("Invalid progress data: outputFile must be a string")
153
+ }
154
+ }
155
+
156
+ private deserializeAnalyzedCommits(
157
+ data: Record<string, unknown>[],
158
+ ): AnalyzedCommit[] {
159
+ if (!Array.isArray(data)) {
160
+ return []
161
+ }
162
+
163
+ return data.map((item: Record<string, unknown>) => {
164
+ const hash = CommitHash.create(item.hash as string)
165
+ const commit = new Commit({
166
+ hash,
167
+ message: item.message as string,
168
+ date: new Date(item.date as string),
169
+ diff: "", // We don't store diff in progress, so use empty string
170
+ })
171
+
172
+ const category = Category.create(item.category as string)
173
+ const analysis = new Analysis({
174
+ category,
175
+ summary: item.summary as string,
176
+ description: item.description as string,
177
+ })
178
+
179
+ return new AnalyzedCommit(commit, analysis)
180
+ })
181
+ }
182
+ }
@@ -0,0 +1,26 @@
1
+ import { ClaudeLLMAdapter } from "./claude-llm-adapter"
2
+ import { GeminiLLMAdapter } from "./gemini-llm-adapter"
3
+ import { LLMAdapter } from "./llm-adapter"
4
+ import { OpenAILLMAdapter } from "./openai-llm-adapter"
5
+
6
+ export class LLMAdapterFactory {
7
+ static create(llm?: string): LLMAdapter {
8
+ const normalizedLLM = llm?.toLowerCase()
9
+
10
+ switch (normalizedLLM) {
11
+ case "gemini":
12
+ return new GeminiLLMAdapter()
13
+ case "openai":
14
+ case "gpt":
15
+ return new OpenAILLMAdapter()
16
+ case "claude":
17
+ default:
18
+ return new ClaudeLLMAdapter()
19
+ }
20
+ }
21
+
22
+ static getSupportedLLMs(): string[] {
23
+ return ["claude", "gemini", "openai"]
24
+ }
25
+ }
26
+