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 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
+ }
@@ -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
+