gitbrain 0.2.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.
- package/README.md +164 -0
- package/bin/index.js +107 -0
- package/package.json +36 -0
- package/src/commands/config.js +14 -0
- package/src/commands/content.js +28 -0
- package/src/commands/risk.js +23 -0
- package/src/commands/today.js +25 -0
- package/src/core/analyzer.js +85 -0
- package/src/core/formatter.js +146 -0
- package/src/core/git.js +57 -0
- package/src/index.js +76 -0
- package/src/services/ai.js +262 -0
- package/src/services/config.js +142 -0
- package/src/utils/logger.js +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# GitBrain
|
|
2
|
+
|
|
3
|
+
GitBrain is a modular Node.js CLI for generating developer-facing content from Git commits and analyzing pull request risk.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
### `gitbrain content`
|
|
8
|
+
Fetches the latest 5 commits and generates a human-friendly dev update thread with:
|
|
9
|
+
- **Smart categorization**: commits are classified as features, fixes, refactoring, docs, or other
|
|
10
|
+
- **Narrative summary**: auto-generated summary like "shipped 2 features, fixed 1 issue"
|
|
11
|
+
- **Highlights**: key features and fixes called out at the top
|
|
12
|
+
- **Clean formatting**: organized sections with author attribution
|
|
13
|
+
|
|
14
|
+
### `gitbrain risk`
|
|
15
|
+
Computes a risk score based on diff summary metrics with:
|
|
16
|
+
- **Risk level**: LOW / MEDIUM / HIGH
|
|
17
|
+
- **Critical file detection**: identifies changes to auth, payment, security, config, and core files
|
|
18
|
+
- **AI-powered analysis**: explains risk in plain language and highlights likely failure points
|
|
19
|
+
- **Detailed metrics**: files changed, lines added/deleted
|
|
20
|
+
- **Smart scoring**: weighted calculation based on scope and criticality of changes
|
|
21
|
+
|
|
22
|
+
## Critical file categories
|
|
23
|
+
|
|
24
|
+
The risk analyzer automatically detects changes to critical files and flags them:
|
|
25
|
+
|
|
26
|
+
- **🔐 Auth**: Files containing authentication logic (auth, oauth, jwt, password, session, token, login)
|
|
27
|
+
- **💳 Payment**: Files related to payments (payment, stripe, paypal, checkout, billing, credit, invoice)
|
|
28
|
+
- **🔒 Security**: Files related to encryption and security (crypto, secret, encryption, ssl, certificate, tls)
|
|
29
|
+
- **⚙️ Config**: Configuration and environment files (config, env, .env, settings, database, connection, api)
|
|
30
|
+
- **🔧 Core**: Core application files (core, kernel, engine, main.js, index.js, package.json)
|
|
31
|
+
|
|
32
|
+
When critical files are detected, the risk score receives a significant boost to flag them for review.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install
|
|
38
|
+
npm link
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Environment
|
|
42
|
+
|
|
43
|
+
GitBrain stores your provider and API key persistently in `~/.gitbrain/config.json`.
|
|
44
|
+
|
|
45
|
+
- `OPENAI_API_KEY` for OpenAI
|
|
46
|
+
- `GEMINI_API_KEY` for Gemini-style endpoints
|
|
47
|
+
- Optionally `OPENAI_API_BASE` to override the API host
|
|
48
|
+
- Optionally `OPENAI_MODEL` or `GEMINI_MODEL` to set the LLM model
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export OPENAI_API_KEY=your_key_here
|
|
54
|
+
export OPENAI_MODEL=gpt-3.5-turbo
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Persistent configuration
|
|
58
|
+
|
|
59
|
+
If no config exists, GitBrain will prompt for provider and API key on first use and save it locally.
|
|
60
|
+
|
|
61
|
+
To update or reset the stored provider settings, use:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
gitbrain config
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
You can still override the provider on each run:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
gitbrain content --provider openai
|
|
71
|
+
gitbrain risk --provider gemini
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If no key is provided and no local config exists, GitBrain falls back to basic local analysis and shows a warning.
|
|
75
|
+
|
|
76
|
+
## Error handling
|
|
77
|
+
|
|
78
|
+
GitBrain includes robust error handling for common issues:
|
|
79
|
+
|
|
80
|
+
**Not a Git repository**: When you run any command outside a Git repository, you'll see a friendly error message with a suggestion to initialize a repository:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
error Not a Git repository
|
|
84
|
+
This command must be run inside a Git repository.
|
|
85
|
+
Try: git init
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This applies to all commands: `content`, `today`, and `risk`.
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
gitbrain content
|
|
94
|
+
|
|
95
|
+
gitbrain today
|
|
96
|
+
|
|
97
|
+
gitbrain risk
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Example output
|
|
101
|
+
|
|
102
|
+
### Content command
|
|
103
|
+
```
|
|
104
|
+
📝 Dev Update
|
|
105
|
+
|
|
106
|
+
This sprint I shipped 2 features, fixed 1 issue.
|
|
107
|
+
|
|
108
|
+
Highlights
|
|
109
|
+
✨ Key feature: Add dark mode support
|
|
110
|
+
🐛 Fixed: Navigation menu rendering
|
|
111
|
+
|
|
112
|
+
What changed
|
|
113
|
+
Features:
|
|
114
|
+
• Add dark mode support — alice
|
|
115
|
+
• Improve search performance — bob
|
|
116
|
+
|
|
117
|
+
Fixes:
|
|
118
|
+
• Fix menu click handler — alice
|
|
119
|
+
|
|
120
|
+
📊 3 commits from current repository
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Risk command
|
|
124
|
+
```
|
|
125
|
+
⚠️ Risk Assessment
|
|
126
|
+
|
|
127
|
+
Risk Level: MEDIUM (Score: 68 / 100)
|
|
128
|
+
|
|
129
|
+
🚨 Critical files detected:
|
|
130
|
+
🔐 Auth: src/auth/login.js
|
|
131
|
+
💳 Payment: src/payment/checkout.js
|
|
132
|
+
⚙️ Config: .env, config/database.js
|
|
133
|
+
|
|
134
|
+
Metrics:
|
|
135
|
+
Files changed: 12
|
|
136
|
+
Lines added: 250
|
|
137
|
+
Lines deleted: 120
|
|
138
|
+
Total changes: 370
|
|
139
|
+
|
|
140
|
+
⚠️ Critical files changed. Require careful review and testing.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Project structure
|
|
144
|
+
|
|
145
|
+
- `bin/index.js`: CLI entrypoint
|
|
146
|
+
- `src/commands`: command handlers
|
|
147
|
+
- `src/core`: Git integration, analysis, and formatting
|
|
148
|
+
- `src/services`: AI helpers and commit analysis
|
|
149
|
+
- `src/utils`: reusable CLI utilities
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
Run the CLI locally:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npm run start -- content
|
|
157
|
+
npm run start -- risk
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Notes
|
|
161
|
+
|
|
162
|
+
- Requires Node.js 18 or later
|
|
163
|
+
- Designed to be extended with additional commands like `today`, AI summarization, and CI integration
|
|
164
|
+
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import { runContent } from "../src/commands/content.js";
|
|
5
|
+
import { runRisk } from "../src/commands/risk.js";
|
|
6
|
+
import { runToday } from "../src/commands/today.js";
|
|
7
|
+
import { runConfig } from "../src/commands/config.js";
|
|
8
|
+
import { resolveRuntimeConfig } from "../src/index.js";
|
|
9
|
+
import { info, error, success } from "../src/utils/logger.js";
|
|
10
|
+
import { NotGitRepoError } from "../src/core/git.js";
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name("gitbrain")
|
|
14
|
+
.description("GitBrain CLI for AI-powered Git repository analysis")
|
|
15
|
+
.version("0.1.0")
|
|
16
|
+
.option("-p, --provider <provider>", "Select LLM provider (openai|gemini)");
|
|
17
|
+
|
|
18
|
+
async function loadRuntimeConfig() {
|
|
19
|
+
const opts = program.opts();
|
|
20
|
+
return await resolveRuntimeConfig({ provider: opts.provider });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function showProviderHint(runtime) {
|
|
24
|
+
const provider = runtime.config?.provider || "openai";
|
|
25
|
+
const hasKey = Boolean(runtime.config?.apiKey || process.env.OPENAI_API_KEY || process.env.GEMINI_API_KEY);
|
|
26
|
+
if (!hasKey) {
|
|
27
|
+
console.log(chalk.yellow("LLM provider not configured. Use gitbrain config, OPENAI_API_KEY, or GEMINI_API_KEY."));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const source = runtime.isStored ? "stored config" : runtime.isEnv ? "environment" : "default";
|
|
32
|
+
console.log(chalk.gray(`LLM provider: ${provider} (${source}). Use --provider openai|gemini to override.`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.command("content")
|
|
37
|
+
.description("Generate developer-facing content from recent commits")
|
|
38
|
+
.action(async () => {
|
|
39
|
+
const runtime = await loadRuntimeConfig();
|
|
40
|
+
showProviderHint(runtime);
|
|
41
|
+
info("Analyzing recent commits...");
|
|
42
|
+
try {
|
|
43
|
+
const { result, aiUsed } = await runContent(runtime.config);
|
|
44
|
+
console.log(result);
|
|
45
|
+
success("Content generation complete.");
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (!(err instanceof NotGitRepoError)) {
|
|
48
|
+
error(err);
|
|
49
|
+
}
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
program
|
|
55
|
+
.command("today")
|
|
56
|
+
.description("Summarize commits made today")
|
|
57
|
+
.action(async () => {
|
|
58
|
+
const runtime = await loadRuntimeConfig();
|
|
59
|
+
showProviderHint(runtime);
|
|
60
|
+
info("Analyzing today's work...");
|
|
61
|
+
try {
|
|
62
|
+
const { result, aiUsed } = await runToday(runtime.config);
|
|
63
|
+
console.log(result);
|
|
64
|
+
success("Today's summary complete.");
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!(err instanceof NotGitRepoError)) {
|
|
67
|
+
error(err);
|
|
68
|
+
}
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
program
|
|
74
|
+
.command("risk")
|
|
75
|
+
.description("Analyze repository change risk")
|
|
76
|
+
.action(async () => {
|
|
77
|
+
const runtime = await loadRuntimeConfig();
|
|
78
|
+
showProviderHint(runtime);
|
|
79
|
+
info("Evaluating repository diff...");
|
|
80
|
+
try {
|
|
81
|
+
const { result, aiUsed } = await runRisk(runtime.config);
|
|
82
|
+
console.log(result);
|
|
83
|
+
success("Risk analysis complete.");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (!(err instanceof NotGitRepoError)) {
|
|
86
|
+
error(err);
|
|
87
|
+
}
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
program
|
|
93
|
+
.command("config")
|
|
94
|
+
.description("Update or reset persistent AI provider configuration")
|
|
95
|
+
.action(async () => {
|
|
96
|
+
info("Opening GitBrain configuration...");
|
|
97
|
+
try {
|
|
98
|
+
const result = await runConfig();
|
|
99
|
+
console.log(result);
|
|
100
|
+
success("Configuration saved.");
|
|
101
|
+
} catch (err) {
|
|
102
|
+
error(err);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gitbrain",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "AI-powered Git CLI for commit analysis and PR risk assessment",
|
|
5
|
+
"bin": {
|
|
6
|
+
"gitbrain": "./bin/index.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node ./bin/index.js",
|
|
18
|
+
"dev": "node ./bin/index.js",
|
|
19
|
+
"check": "node --check ./bin/index.js"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@google/generative-ai": "^0.24.1",
|
|
23
|
+
"chalk": "^5.3.0",
|
|
24
|
+
"commander": "^11.0.0",
|
|
25
|
+
"openai": "^4.104.0",
|
|
26
|
+
"simple-git": "^3.19.1"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"cli",
|
|
31
|
+
"git",
|
|
32
|
+
"ai",
|
|
33
|
+
"developer-tools"
|
|
34
|
+
],
|
|
35
|
+
"author": "Bright"
|
|
36
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { promptForConfig, getConfig } from "../services/config.js";
|
|
2
|
+
import { info } from "../utils/logger.js";
|
|
3
|
+
|
|
4
|
+
export async function runConfig() {
|
|
5
|
+
info("Opening GitBrain AI configuration...");
|
|
6
|
+
const { config: existing } = await getConfig();
|
|
7
|
+
|
|
8
|
+
if (existing && existing.provider) {
|
|
9
|
+
console.log(`Current provider: ${existing.provider}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const config = await promptForConfig(existing?.provider || "openai");
|
|
13
|
+
return `Saved ${config.provider} configuration successfully.`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getRecentCommits, NotGitRepoError } from "../core/git.js";
|
|
2
|
+
import { formatThread } from "../core/formatter.js";
|
|
3
|
+
import { generateDevSummary, analyzeCodebasePatterns } from "../services/ai.js";
|
|
4
|
+
import { info, formatGitError } from "../utils/logger.js";
|
|
5
|
+
|
|
6
|
+
export async function runContent(config) {
|
|
7
|
+
try {
|
|
8
|
+
info("Loading the latest commit history...");
|
|
9
|
+
const commits = await getRecentCommits({ maxCount: 5 });
|
|
10
|
+
|
|
11
|
+
if (!commits.length) {
|
|
12
|
+
return { result: "No commits were found in the current repository. Please run this inside a Git repo with commit history.", aiUsed: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const summaryResult = await generateDevSummary(commits, config);
|
|
16
|
+
const patternResult = await analyzeCodebasePatterns(commits, config);
|
|
17
|
+
|
|
18
|
+
const aiUsed = summaryResult.aiUsed || patternResult.aiUsed;
|
|
19
|
+
const result = formatThread(commits, summaryResult.text, "content", patternResult.text);
|
|
20
|
+
return { result, aiUsed };
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err instanceof NotGitRepoError) {
|
|
23
|
+
formatGitError();
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getDiffSummary, NotGitRepoError } from "../core/git.js";
|
|
2
|
+
import { calculateRisk } from "../core/analyzer.js";
|
|
3
|
+
import { analyzeRiskWithAI } from "../services/ai.js";
|
|
4
|
+
import { formatRisk } from "../core/formatter.js";
|
|
5
|
+
import { info, formatGitError } from "../utils/logger.js";
|
|
6
|
+
|
|
7
|
+
export async function runRisk(config) {
|
|
8
|
+
try {
|
|
9
|
+
info("Collecting diff summary from repository...");
|
|
10
|
+
const diff = await getDiffSummary();
|
|
11
|
+
const scoreSummary = calculateRisk(diff);
|
|
12
|
+
const aiResult = await analyzeRiskWithAI(diff, config);
|
|
13
|
+
const riskAnalysis = { ...scoreSummary, aiAnalysis: aiResult.text };
|
|
14
|
+
const result = formatRisk(diff, riskAnalysis);
|
|
15
|
+
return { result, aiUsed: aiResult.aiUsed };
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err instanceof NotGitRepoError) {
|
|
18
|
+
formatGitError();
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getTodayCommits, NotGitRepoError } from "../core/git.js";
|
|
2
|
+
import { formatThread } from "../core/formatter.js";
|
|
3
|
+
import { generateDevSummary } from "../services/ai.js";
|
|
4
|
+
import { info, formatGitError } from "../utils/logger.js";
|
|
5
|
+
|
|
6
|
+
export async function runToday(config) {
|
|
7
|
+
try {
|
|
8
|
+
info("Loading commits from today...");
|
|
9
|
+
const commits = await getTodayCommits();
|
|
10
|
+
|
|
11
|
+
if (!commits.length) {
|
|
12
|
+
return { result: "No commits found for today. You've earned a relaxing break!", aiUsed: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const summaryResult = await generateDevSummary(commits, config);
|
|
16
|
+
const result = formatThread(commits, summaryResult.text, "today");
|
|
17
|
+
return { result, aiUsed: summaryResult.aiUsed };
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err instanceof NotGitRepoError) {
|
|
20
|
+
formatGitError();
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
function normalizeNumber(value) {
|
|
2
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function clamp(value, min, max) {
|
|
6
|
+
return Math.max(min, Math.min(max, value));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const CRITICAL_PATTERNS = {
|
|
10
|
+
auth: /auth|oauth|jwt|password|credential|session|token|login/i,
|
|
11
|
+
payment: /payment|stripe|paypal|checkout|billing|credit|invoice|transaction|card/i,
|
|
12
|
+
security: /crypto|secret|encryption|ssl|certificate|tls|https/i,
|
|
13
|
+
config: /config|env|\.env|settings|database|connection|api/i,
|
|
14
|
+
core: /core|kernel|engine|main\.js|index\.js|package\.json/i,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function detectCriticalFiles(diff) {
|
|
18
|
+
const criticalFiles = {
|
|
19
|
+
auth: [],
|
|
20
|
+
payment: [],
|
|
21
|
+
security: [],
|
|
22
|
+
config: [],
|
|
23
|
+
core: [],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (!Array.isArray(diff.files)) {
|
|
27
|
+
return criticalFiles;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
diff.files.forEach((file) => {
|
|
31
|
+
const filePath = typeof file === "string" ? file : file.file || file.name || "";
|
|
32
|
+
const lowerPath = filePath.toLowerCase();
|
|
33
|
+
|
|
34
|
+
Object.entries(CRITICAL_PATTERNS).forEach(([category, pattern]) => {
|
|
35
|
+
if (pattern.test(lowerPath)) {
|
|
36
|
+
criticalFiles[category].push(filePath);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return criticalFiles;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function calculateCriticalRiskBonus(criticalFiles) {
|
|
45
|
+
let bonus = 0;
|
|
46
|
+
|
|
47
|
+
Object.entries(criticalFiles).forEach(([category, files]) => {
|
|
48
|
+
if (files.length > 0) {
|
|
49
|
+
const multiplier = category === "payment" ? 40 : category === "auth" ? 35 : category === "security" ? 35 : 20;
|
|
50
|
+
bonus += multiplier;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return bonus;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function calculateRisk(diff) {
|
|
58
|
+
const files = Array.isArray(diff.files) ? diff.files.length : normalizeNumber(diff.files);
|
|
59
|
+
const insertions = normalizeNumber(diff.insertions);
|
|
60
|
+
const deletions = normalizeNumber(diff.deletions);
|
|
61
|
+
|
|
62
|
+
let score = 0;
|
|
63
|
+
|
|
64
|
+
if (files > 10) score += 35;
|
|
65
|
+
else if (files > 5) score += 20;
|
|
66
|
+
else if (files > 0) score += 10;
|
|
67
|
+
|
|
68
|
+
if (insertions > 500) score += 35;
|
|
69
|
+
else if (insertions > 200) score += 20;
|
|
70
|
+
else if (insertions > 0) score += 10;
|
|
71
|
+
|
|
72
|
+
if (deletions > 500) score += 30;
|
|
73
|
+
else if (deletions > 200) score += 15;
|
|
74
|
+
else if (deletions > 0) score += 8;
|
|
75
|
+
|
|
76
|
+
const criticalFiles = detectCriticalFiles(diff);
|
|
77
|
+
const criticalBonus = calculateCriticalRiskBonus(criticalFiles);
|
|
78
|
+
score += criticalBonus;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
score: clamp(score, 0, 100),
|
|
82
|
+
criticalFiles,
|
|
83
|
+
hasCritical: Object.values(criticalFiles).some((files) => files.length > 0),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { categorizeCommits, extractHighlights } from "../services/ai.js";
|
|
3
|
+
|
|
4
|
+
export function formatThread(commits, narrativeSummary, context = "content", analysisNotes = "") {
|
|
5
|
+
const categories = categorizeCommits(commits);
|
|
6
|
+
const highlights = extractHighlights(commits);
|
|
7
|
+
|
|
8
|
+
const sections = [];
|
|
9
|
+
|
|
10
|
+
sections.push(
|
|
11
|
+
context === "today"
|
|
12
|
+
? chalk.bold.cyan("📅 Today's Work")
|
|
13
|
+
: chalk.bold.cyan("📝 Dev Update")
|
|
14
|
+
);
|
|
15
|
+
sections.push("");
|
|
16
|
+
|
|
17
|
+
if (narrativeSummary) {
|
|
18
|
+
const summaryText = narrativeSummary.trim();
|
|
19
|
+
const formatted = context === "today" ? summaryText : summaryText;
|
|
20
|
+
sections.push(chalk.gray(formatted));
|
|
21
|
+
sections.push("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (analysisNotes) {
|
|
25
|
+
sections.push(chalk.bold("AI Insight"));
|
|
26
|
+
sections.push(chalk.white(analysisNotes));
|
|
27
|
+
sections.push("");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (highlights.length > 0) {
|
|
31
|
+
sections.push(chalk.bold("Highlights"));
|
|
32
|
+
highlights.forEach((h) => sections.push(chalk.cyan(h)));
|
|
33
|
+
sections.push("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const categoryItems = [];
|
|
37
|
+
|
|
38
|
+
if (categories.features.length > 0) {
|
|
39
|
+
categoryItems.push(formatCategory("Features", categories.features));
|
|
40
|
+
}
|
|
41
|
+
if (categories.fixes.length > 0) {
|
|
42
|
+
categoryItems.push(formatCategory("Fixes", categories.fixes));
|
|
43
|
+
}
|
|
44
|
+
if (categories.refactoring.length > 0) {
|
|
45
|
+
categoryItems.push(formatCategory("Refactoring", categories.refactoring));
|
|
46
|
+
}
|
|
47
|
+
if (categories.docs.length > 0) {
|
|
48
|
+
categoryItems.push(formatCategory("Documentation", categories.docs));
|
|
49
|
+
}
|
|
50
|
+
if (categories.other.length > 0) {
|
|
51
|
+
categoryItems.push(formatCategory("Other", categories.other));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (categoryItems.length > 0) {
|
|
55
|
+
sections.push(chalk.bold("What changed"));
|
|
56
|
+
sections.push(...categoryItems.flat());
|
|
57
|
+
sections.push("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
sections.push(
|
|
61
|
+
chalk.dim(
|
|
62
|
+
`📊 ${commits.length} commit${commits.length > 1 ? "s" : ""} from ${context === "today" ? "today" : "current repository"}`
|
|
63
|
+
)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return sections.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatCategory(name, commits) {
|
|
70
|
+
const lines = [chalk.yellow(`${name}:`)];
|
|
71
|
+
commits.forEach((commit) => {
|
|
72
|
+
const cleanMsg = commit.message
|
|
73
|
+
.trim()
|
|
74
|
+
.replace(/^(feat|fix|refactor|docs)\s*:?\s*/i, "")
|
|
75
|
+
.replace(/^#\d+\s+/g, "")
|
|
76
|
+
.slice(0, 80);
|
|
77
|
+
lines.push(` ${chalk.gray("•")} ${cleanMsg} ${chalk.dim(`— ${commit.author}`)}`);
|
|
78
|
+
});
|
|
79
|
+
lines.push("");
|
|
80
|
+
return lines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatRisk(diff, riskAnalysis) {
|
|
84
|
+
const score = typeof riskAnalysis === "number" ? riskAnalysis : riskAnalysis.score;
|
|
85
|
+
const criticalFiles = riskAnalysis.criticalFiles || {};
|
|
86
|
+
const hasCritical = riskAnalysis.hasCritical || false;
|
|
87
|
+
|
|
88
|
+
const level = score >= 70 ? "HIGH" : score >= 35 ? "MEDIUM" : "LOW";
|
|
89
|
+
const styledLevel =
|
|
90
|
+
level === "HIGH"
|
|
91
|
+
? chalk.red.bold(level)
|
|
92
|
+
: level === "MEDIUM"
|
|
93
|
+
? chalk.yellow.bold(level)
|
|
94
|
+
: chalk.green.bold(level);
|
|
95
|
+
const changes = normalizeChanges(diff);
|
|
96
|
+
|
|
97
|
+
const sections = [chalk.bold.red("⚠️ Risk Assessment"), "", `Risk Level: ${styledLevel} (Score: ${score} / 100)`, ""];
|
|
98
|
+
|
|
99
|
+
if (hasCritical) {
|
|
100
|
+
sections.push(chalk.red.bold("🚨 Critical files detected:"));
|
|
101
|
+
if (criticalFiles.auth && criticalFiles.auth.length > 0) {
|
|
102
|
+
sections.push(chalk.red(` 🔐 Auth: ${criticalFiles.auth.join(", ")}`));
|
|
103
|
+
}
|
|
104
|
+
if (criticalFiles.payment && criticalFiles.payment.length > 0) {
|
|
105
|
+
sections.push(chalk.red(` 💳 Payment: ${criticalFiles.payment.join(", ")}`));
|
|
106
|
+
}
|
|
107
|
+
if (criticalFiles.security && criticalFiles.security.length > 0) {
|
|
108
|
+
sections.push(chalk.red(` 🔒 Security: ${criticalFiles.security.join(", ")}`));
|
|
109
|
+
}
|
|
110
|
+
if (criticalFiles.config && criticalFiles.config.length > 0) {
|
|
111
|
+
sections.push(chalk.yellow(` ⚙️ Config: ${criticalFiles.config.join(", ")}`));
|
|
112
|
+
}
|
|
113
|
+
if (criticalFiles.core && criticalFiles.core.length > 0) {
|
|
114
|
+
sections.push(chalk.yellow(` 🔧 Core: ${criticalFiles.core.join(", ")}`));
|
|
115
|
+
}
|
|
116
|
+
sections.push("");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (riskAnalysis.aiAnalysis) {
|
|
120
|
+
sections.push(chalk.bold("AI Insight"));
|
|
121
|
+
sections.push(chalk.white(riskAnalysis.aiAnalysis));
|
|
122
|
+
sections.push("");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sections.push(chalk.dim("Metrics:"));
|
|
126
|
+
sections.push(` Files changed: ${Array.isArray(diff.files) ? diff.files.length : diff.files}`);
|
|
127
|
+
sections.push(` Lines added: ${diff.insertions ?? 0}`);
|
|
128
|
+
sections.push(` Lines deleted: ${diff.deletions ?? 0}`);
|
|
129
|
+
sections.push(` Total changes: ${changes}`);
|
|
130
|
+
sections.push("");
|
|
131
|
+
|
|
132
|
+
if (hasCritical) {
|
|
133
|
+
sections.push(chalk.red.italic("⚠️ Critical files changed. Require careful review and testing."));
|
|
134
|
+
} else {
|
|
135
|
+
sections.push(chalk.italic.dim("Tip: Large diffs should be reviewed carefully and tested thoroughly."));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return sections.join("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeChanges(diff) {
|
|
142
|
+
if (typeof diff.changes === "number") {
|
|
143
|
+
return diff.changes;
|
|
144
|
+
}
|
|
145
|
+
return (diff.insertions ?? 0) + (diff.deletions ?? 0);
|
|
146
|
+
}
|
package/src/core/git.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import simpleGit from "simple-git";
|
|
2
|
+
|
|
3
|
+
const git = simpleGit({ baseDir: process.cwd() });
|
|
4
|
+
|
|
5
|
+
class NotGitRepoError extends Error {
|
|
6
|
+
constructor(message = "Not a Git repository") {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "NotGitRepoError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function ensureRepository() {
|
|
13
|
+
const isRepo = await git.checkIsRepo();
|
|
14
|
+
if (!isRepo) {
|
|
15
|
+
throw new NotGitRepoError();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getRecentCommits(options = { maxCount: 5 }) {
|
|
20
|
+
await ensureRepository();
|
|
21
|
+
const { maxCount } = options;
|
|
22
|
+
const log = await git.log({ maxCount });
|
|
23
|
+
return log.all.map((commit) => ({
|
|
24
|
+
hash: commit.hash,
|
|
25
|
+
author: commit.author_name,
|
|
26
|
+
date: commit.date,
|
|
27
|
+
message: commit.message,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function getTodayCommits() {
|
|
32
|
+
await ensureRepository();
|
|
33
|
+
const startOfDay = new Date();
|
|
34
|
+
startOfDay.setHours(0, 0, 0, 0);
|
|
35
|
+
|
|
36
|
+
const log = await git.log();
|
|
37
|
+
const todayCommits = log.all
|
|
38
|
+
.filter((commit) => {
|
|
39
|
+
const commitDate = new Date(commit.date);
|
|
40
|
+
return commitDate >= startOfDay;
|
|
41
|
+
})
|
|
42
|
+
.map((commit) => ({
|
|
43
|
+
hash: commit.hash,
|
|
44
|
+
author: commit.author_name,
|
|
45
|
+
date: commit.date,
|
|
46
|
+
message: commit.message,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
return todayCommits;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getDiffSummary() {
|
|
53
|
+
await ensureRepository();
|
|
54
|
+
return git.diffSummary();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { NotGitRepoError };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getConfig, getEnvConfig } from "./services/config.js";
|
|
2
|
+
|
|
3
|
+
function normalizeProvider(provider) {
|
|
4
|
+
const value = provider?.toString().trim().toLowerCase();
|
|
5
|
+
if (value === "gemini") {
|
|
6
|
+
return "gemini";
|
|
7
|
+
}
|
|
8
|
+
if (value === "openai") {
|
|
9
|
+
return "openai";
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function resolveRuntimeConfig(options = {}) {
|
|
15
|
+
const { provider, apiKey } = options;
|
|
16
|
+
const requestedProvider = normalizeProvider(provider);
|
|
17
|
+
const { config: storedConfig, isStored } = await getConfig({ promptIfMissing: false });
|
|
18
|
+
|
|
19
|
+
let finalProvider = requestedProvider;
|
|
20
|
+
let finalApiKey = apiKey ?? null;
|
|
21
|
+
let configSource = null;
|
|
22
|
+
|
|
23
|
+
if (finalProvider) {
|
|
24
|
+
// Provider explicitly requested
|
|
25
|
+
configSource = "requested";
|
|
26
|
+
if (!finalApiKey && storedConfig?.provider === finalProvider) {
|
|
27
|
+
finalApiKey = storedConfig.apiKey;
|
|
28
|
+
configSource = "stored";
|
|
29
|
+
}
|
|
30
|
+
if (!finalApiKey) {
|
|
31
|
+
const envConfig = getEnvConfig(finalProvider);
|
|
32
|
+
if (envConfig) {
|
|
33
|
+
finalApiKey = envConfig.apiKey;
|
|
34
|
+
configSource = "env";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
// No provider requested - use stored if available
|
|
39
|
+
if (storedConfig?.provider && storedConfig?.apiKey) {
|
|
40
|
+
finalProvider = storedConfig.provider;
|
|
41
|
+
finalApiKey = storedConfig.apiKey;
|
|
42
|
+
configSource = "stored";
|
|
43
|
+
} else {
|
|
44
|
+
// Fall back to env, preferring the one with an active key
|
|
45
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
46
|
+
const geminiKey = process.env.GEMINI_API_KEY;
|
|
47
|
+
|
|
48
|
+
if (geminiKey && !openaiKey) {
|
|
49
|
+
finalProvider = "gemini";
|
|
50
|
+
finalApiKey = geminiKey;
|
|
51
|
+
configSource = "env";
|
|
52
|
+
} else {
|
|
53
|
+
finalProvider = "openai";
|
|
54
|
+
finalApiKey = openaiKey || null;
|
|
55
|
+
configSource = openaiKey ? "env" : "default";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isEnv = configSource === "env";
|
|
61
|
+
const resultIsStored = configSource === "stored";
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
config: {
|
|
65
|
+
provider: finalProvider,
|
|
66
|
+
apiKey: finalApiKey,
|
|
67
|
+
},
|
|
68
|
+
isStored: resultIsStored,
|
|
69
|
+
isEnv,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { runContent } from "./commands/content.js";
|
|
74
|
+
export { runRisk } from "./commands/risk.js";
|
|
75
|
+
export { runToday } from "./commands/today.js";
|
|
76
|
+
export { runConfig } from "./commands/config.js";
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { GoogleGenerativeAI } from "@google/generative-ai";
|
|
3
|
+
import { warn } from "../utils/logger.js";
|
|
4
|
+
|
|
5
|
+
function normalizeProvider(provider) {
|
|
6
|
+
const value = provider?.toString().trim().toLowerCase();
|
|
7
|
+
if (value === "gemini") {
|
|
8
|
+
return "gemini";
|
|
9
|
+
}
|
|
10
|
+
if (value === "openai") {
|
|
11
|
+
return "openai";
|
|
12
|
+
}
|
|
13
|
+
return "openai";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getEnvKey(provider) {
|
|
17
|
+
const normalized = normalizeProvider(provider);
|
|
18
|
+
if (normalized === "gemini") {
|
|
19
|
+
return process.env.GEMINI_API_KEY || null;
|
|
20
|
+
}
|
|
21
|
+
return process.env.OPENAI_API_KEY || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { getEnvKey };
|
|
25
|
+
|
|
26
|
+
function getEnvModel(provider) {
|
|
27
|
+
if (normalizeProvider(provider) === "gemini") {
|
|
28
|
+
return process.env.GEMINI_MODEL || "gemini-2.5-flash";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return process.env.OPENAI_MODEL || "gpt-3.5-turbo";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseGeminiText(result) {
|
|
35
|
+
const candidate = result?.candidates?.[0];
|
|
36
|
+
if (!candidate) {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof candidate.output === "string") {
|
|
41
|
+
return candidate.output;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(candidate.output)) {
|
|
45
|
+
for (const item of candidate.output) {
|
|
46
|
+
if (typeof item === "string") {
|
|
47
|
+
return item;
|
|
48
|
+
}
|
|
49
|
+
if (item?.content) {
|
|
50
|
+
const contentArray = Array.isArray(item.content) ? item.content : [item.content];
|
|
51
|
+
for (const part of contentArray) {
|
|
52
|
+
if (typeof part?.text === "string") {
|
|
53
|
+
return part.text;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return result?.candidates?.[0]?.content?.[0]?.text || "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function callLLM(systemPrompt, userPrompt, config) {
|
|
64
|
+
const provider = normalizeProvider(config?.provider);
|
|
65
|
+
const apiKey = config?.apiKey || getEnvKey(provider);
|
|
66
|
+
|
|
67
|
+
if (!apiKey) {
|
|
68
|
+
throw new Error("Missing API key");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const model = getEnvModel(provider);
|
|
72
|
+
|
|
73
|
+
if (provider === "gemini") {
|
|
74
|
+
const client = new GoogleGenerativeAI(apiKey);
|
|
75
|
+
const modelClient = client.getGenerativeModel({
|
|
76
|
+
model,
|
|
77
|
+
generationConfig: {
|
|
78
|
+
temperature: 0.6,
|
|
79
|
+
maxOutputTokens: 500,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const response = await modelClient.generateContent({
|
|
84
|
+
contents: [
|
|
85
|
+
{
|
|
86
|
+
parts: [
|
|
87
|
+
{
|
|
88
|
+
text: `${systemPrompt}\n\n${userPrompt}`,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return parseGeminiText(response).trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const openai = new OpenAI({ apiKey });
|
|
99
|
+
const response = await openai.chat.completions.create({
|
|
100
|
+
model,
|
|
101
|
+
messages: [
|
|
102
|
+
{ role: "system", content: systemPrompt },
|
|
103
|
+
{ role: "user", content: userPrompt },
|
|
104
|
+
],
|
|
105
|
+
temperature: 0.6,
|
|
106
|
+
max_tokens: 500,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return response.choices?.[0]?.message?.content?.trim() ?? "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function collectCommitMessages(commits) {
|
|
113
|
+
return commits
|
|
114
|
+
.map((commit) => (typeof commit === "string" ? commit : commit.message || ""))
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fallbackSummary(commits) {
|
|
119
|
+
const messages = collectCommitMessages(commits);
|
|
120
|
+
if (!messages.length) {
|
|
121
|
+
return "No commit messages available to summarize.";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const topMessages = messages.slice(0, 5).map((message) => `• ${message}`);
|
|
125
|
+
return `Recent work includes ${messages.length} update${messages.length > 1 ? "s" : ""}. ${topMessages.join(" ")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function fallbackRiskAnalysis(diffSummary) {
|
|
129
|
+
const files = Array.isArray(diffSummary.files) ? diffSummary.files.length : diffSummary.files;
|
|
130
|
+
const insertions = diffSummary.insertions ?? 0;
|
|
131
|
+
const deletions = diffSummary.deletions ?? 0;
|
|
132
|
+
|
|
133
|
+
const points = [`This diff touches ${files} file${files === 1 ? "" : "s"}.`, `Insertions: ${insertions}, deletions: ${deletions}.`, `Larger insertions or deletions increase the chance of regression.`];
|
|
134
|
+
return points.join(" ");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fallbackCodebasePatterns(commits) {
|
|
138
|
+
const messages = collectCommitMessages(commits);
|
|
139
|
+
if (!messages.length) {
|
|
140
|
+
return "No commit history available to identify codebase patterns.";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const lower = messages.map((message) => message.toLowerCase());
|
|
144
|
+
const aiSignals = lower.filter((message) => /\b(ai|gpt|copilot|generated|auto-generated|openai)\b/.test(message));
|
|
145
|
+
const duplicates = messages.filter((message, index) => messages.indexOf(message) !== index);
|
|
146
|
+
const styles = new Set(messages.map((message) => message.trim().match(/^[^:\-\s]+/)?.[0]?.toLowerCase() || "").filter(Boolean));
|
|
147
|
+
|
|
148
|
+
const observations = [];
|
|
149
|
+
if (aiSignals.length) {
|
|
150
|
+
observations.push("Commit history includes language that may indicate AI-assisted or generated work.");
|
|
151
|
+
}
|
|
152
|
+
if (duplicates.length) {
|
|
153
|
+
observations.push("Some messages repeat, which can indicate duplicate work or inconsistent commit granularity.");
|
|
154
|
+
}
|
|
155
|
+
if (styles.size > 2) {
|
|
156
|
+
observations.push("Commit message style is inconsistent, which may reflect varying structure in the codebase.");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return observations.length ? observations.join(" ") : "No strong patterns identified in the commit history.";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function generateDevSummary(commits, config) {
|
|
163
|
+
const messages = collectCommitMessages(commits);
|
|
164
|
+
if (!messages.length) {
|
|
165
|
+
return { text: "No commit messages available to summarize.", aiUsed: false };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const systemPrompt = "You are an intelligent developer assistant that writes concise developer summaries in clear, human language without markdown.";
|
|
169
|
+
const userPrompt = `Summarize the following commit messages as a developer update. Explain the work and its impact in a natural devlog tone. Do not use markdown. Commit messages:\n${messages.join("\n")}`;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const text = await callLLM(systemPrompt, userPrompt, config);
|
|
173
|
+
return { text: text.trim() || fallbackSummary(commits), aiUsed: true };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
warn(`AI summary failed: ${err.message}. Using fallback summary.`);
|
|
176
|
+
return { text: fallbackSummary(commits), aiUsed: false };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function analyzeRiskWithAI(diffSummary, config) {
|
|
181
|
+
const combined = `Files changed: ${Array.isArray(diffSummary.files) ? diffSummary.files.length : diffSummary.files}. Insertions: ${diffSummary.insertions ?? 0}. Deletions: ${diffSummary.deletions ?? 0}.`;
|
|
182
|
+
const systemPrompt = "You are an engineering risk analyst. Provide a clear explanation of risk and likely failure points based on diff metrics.";
|
|
183
|
+
const userPrompt = `Analyze the following diff summary. Explain why the changes may be risky, identify possible failure points, and describe what the team should watch for. Do not use markdown. Summary: ${combined}`;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const text = await callLLM(systemPrompt, userPrompt, config);
|
|
187
|
+
return { text: text.trim() || fallbackRiskAnalysis(diffSummary), aiUsed: true };
|
|
188
|
+
} catch (err) {
|
|
189
|
+
warn(`AI risk analysis failed: ${err.message}. Using fallback risk explanation.`);
|
|
190
|
+
return { text: fallbackRiskAnalysis(diffSummary), aiUsed: false };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function analyzeCodebasePatterns(commits, config) {
|
|
195
|
+
const messages = collectCommitMessages(commits);
|
|
196
|
+
if (!messages.length) {
|
|
197
|
+
return { text: "No commit history available to identify codebase patterns.", aiUsed: false };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const systemPrompt = "You are an engineering assistant that detects codebase quality patterns from commit history.";
|
|
201
|
+
const userPrompt = `Review the following commit messages and describe any signs of AI-generated code patterns, duplication, or inconsistent structure. Keep the answer brief and plain text.\n${messages.join("\n")}`;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const text = await callLLM(systemPrompt, userPrompt, config);
|
|
205
|
+
return { text: text.trim() || fallbackCodebasePatterns(commits), aiUsed: true };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
warn(`AI codebase pattern analysis failed: ${err.message}. Using fallback pattern analysis.`);
|
|
208
|
+
return { text: fallbackCodebasePatterns(commits), aiUsed: false };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function categorizeCommits(commits) {
|
|
213
|
+
const categories = {
|
|
214
|
+
features: [],
|
|
215
|
+
fixes: [],
|
|
216
|
+
refactoring: [],
|
|
217
|
+
docs: [],
|
|
218
|
+
other: [],
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
commits.forEach((commit) => {
|
|
222
|
+
const message = (typeof commit === "string" ? commit : commit.message || "").toLowerCase().trim();
|
|
223
|
+
if (message.startsWith("feat") || message.includes("add") || message.includes("new")) {
|
|
224
|
+
categories.features.push(commit);
|
|
225
|
+
} else if (message.startsWith("fix") || message.includes("fix")) {
|
|
226
|
+
categories.fixes.push(commit);
|
|
227
|
+
} else if (message.startsWith("refactor") || message.includes("refactor") || message.includes("cleanup")) {
|
|
228
|
+
categories.refactoring.push(commit);
|
|
229
|
+
} else if (message.startsWith("docs") || message.includes("doc")) {
|
|
230
|
+
categories.docs.push(commit);
|
|
231
|
+
} else {
|
|
232
|
+
categories.other.push(commit);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return categories;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function extractHighlights(commits) {
|
|
240
|
+
const categories = categorizeCommits(commits);
|
|
241
|
+
const highlights = [];
|
|
242
|
+
|
|
243
|
+
if (categories.features.length > 0) {
|
|
244
|
+
const topFeature = cleanMessage(categories.features[0].message);
|
|
245
|
+
highlights.push(`✨ Key feature: ${topFeature}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (categories.fixes.length > 0) {
|
|
249
|
+
const topFix = cleanMessage(categories.fixes[0].message);
|
|
250
|
+
highlights.push(`🐛 Fixed: ${topFix}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return highlights;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function cleanMessage(msg) {
|
|
257
|
+
return msg
|
|
258
|
+
.trim()
|
|
259
|
+
.replace(/^(feat|fix|refactor|docs)\s*:?\s*/i, "")
|
|
260
|
+
.replace(/^#\d+\s+/g, "")
|
|
261
|
+
.replace(/\s+$/, "");
|
|
262
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import readline from "readline";
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), ".gitbrain");
|
|
7
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
8
|
+
|
|
9
|
+
function question(prompt) {
|
|
10
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
rl.question(prompt, (answer) => {
|
|
13
|
+
rl.close();
|
|
14
|
+
resolve(answer);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function ensureConfigExists() {
|
|
20
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getEnvConfig(requestedProvider) {
|
|
24
|
+
const hasOpenAI = Boolean(process.env.OPENAI_API_KEY);
|
|
25
|
+
const hasGemini = Boolean(process.env.GEMINI_API_KEY);
|
|
26
|
+
const requested = requestedProvider?.toString().trim().toLowerCase();
|
|
27
|
+
|
|
28
|
+
if (requested === "openai") {
|
|
29
|
+
return hasOpenAI ? { provider: "openai", apiKey: process.env.OPENAI_API_KEY } : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (requested === "gemini") {
|
|
33
|
+
return hasGemini ? { provider: "gemini", apiKey: process.env.GEMINI_API_KEY } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (hasOpenAI) {
|
|
37
|
+
return { provider: "openai", apiKey: process.env.OPENAI_API_KEY };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (hasGemini) {
|
|
41
|
+
return { provider: "gemini", apiKey: process.env.GEMINI_API_KEY };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function loadConfig() {
|
|
48
|
+
await ensureConfigExists();
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(CONFIG_FILE, "utf8");
|
|
52
|
+
if (!raw.trim()) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const config = JSON.parse(raw);
|
|
57
|
+
if (!config || typeof config !== "object") {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof config.provider !== "string" || typeof config.apiKey !== "string") {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
provider: config.provider.toLowerCase(),
|
|
67
|
+
apiKey: config.apiKey,
|
|
68
|
+
};
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err.code === "ENOENT") {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (err.name === "SyntaxError") {
|
|
75
|
+
await fs.rm(CONFIG_FILE, { force: true });
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function getConfig(options = {}) {
|
|
84
|
+
const { promptIfMissing = true } = options;
|
|
85
|
+
const stored = await loadConfig();
|
|
86
|
+
if (stored) {
|
|
87
|
+
return { config: stored, isStored: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const envConfig = getEnvConfig();
|
|
91
|
+
if (envConfig) {
|
|
92
|
+
return { config: envConfig, isStored: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!promptIfMissing) {
|
|
96
|
+
return { config: null, isStored: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const config = await promptForConfig();
|
|
100
|
+
return { config, isStored: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function saveConfig(config) {
|
|
104
|
+
await ensureConfigExists();
|
|
105
|
+
const safeConfig = {
|
|
106
|
+
provider: config.provider.toLowerCase(),
|
|
107
|
+
apiKey: config.apiKey,
|
|
108
|
+
};
|
|
109
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(safeConfig, null, 2), "utf8");
|
|
110
|
+
return safeConfig;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function deleteConfig() {
|
|
114
|
+
try {
|
|
115
|
+
await fs.rm(CONFIG_FILE, { force: true });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function promptConfig(defaultProvider = "openai") {
|
|
122
|
+
const providerPrompt = `Choose LLM provider (openai/gemini) [${defaultProvider}]: `;
|
|
123
|
+
let rawProvider = (await question(providerPrompt)).trim().toLowerCase();
|
|
124
|
+
if (!rawProvider) {
|
|
125
|
+
rawProvider = defaultProvider;
|
|
126
|
+
}
|
|
127
|
+
if (rawProvider !== "openai" && rawProvider !== "gemini") {
|
|
128
|
+
rawProvider = "openai";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let apiKey = (await question(`Enter ${rawProvider} API key: `)).trim();
|
|
132
|
+
while (!apiKey) {
|
|
133
|
+
apiKey = (await question("API key is required. Enter API key: ")).trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { provider: rawProvider, apiKey };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function promptForConfig(defaultProvider = "openai") {
|
|
140
|
+
const config = await promptConfig(defaultProvider);
|
|
141
|
+
return await saveConfig(config);
|
|
142
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export function info(message) {
|
|
4
|
+
console.log(chalk.blue("info"), message);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function success(message) {
|
|
8
|
+
console.log(chalk.green("success"), message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function warn(message) {
|
|
12
|
+
console.warn(chalk.yellow("warn"), message);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function error(err) {
|
|
16
|
+
const message = typeof err === "string" ? err : err?.message ?? "An unexpected error occurred.";
|
|
17
|
+
console.error(chalk.red("error"), message);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatGitError() {
|
|
21
|
+
console.error(chalk.red("error"), "Not a Git repository");
|
|
22
|
+
console.error(chalk.dim(" This command must be run inside a Git repository."));
|
|
23
|
+
console.error(chalk.dim(" Try: git init"));
|
|
24
|
+
}
|
|
25
|
+
|