piqnote 0.1.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/.github/workflows/ci.yml +73 -0
- package/.piqnoterc +9 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +623 -0
- package/README.md +106 -0
- package/dist/ai/factory.js +24 -0
- package/dist/ai/localProvider.js +23 -0
- package/dist/ai/mockProvider.js +28 -0
- package/dist/ai/openAiProvider.js +85 -0
- package/dist/ai/provider.js +2 -0
- package/dist/analyzer/diffAnalyzer.js +83 -0
- package/dist/analyzer/scorer.js +53 -0
- package/dist/cli.js +236 -0
- package/dist/config/loader.js +39 -0
- package/dist/config/types.js +2 -0
- package/dist/formatter/commitFormatter.js +26 -0
- package/dist/git/gitClient.js +110 -0
- package/package.json +51 -0
- package/src/ai/factory.ts +32 -0
- package/src/ai/localProvider.ts +22 -0
- package/src/ai/mockProvider.ts +30 -0
- package/src/ai/openAiProvider.ts +109 -0
- package/src/ai/provider.ts +19 -0
- package/src/analyzer/diffAnalyzer.ts +96 -0
- package/src/analyzer/scorer.ts +68 -0
- package/src/cli.ts +314 -0
- package/src/config/loader.ts +36 -0
- package/src/config/types.ts +11 -0
- package/src/formatter/commitFormatter.ts +37 -0
- package/src/git/gitClient.ts +96 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatCommit = formatCommit;
|
|
4
|
+
function truncate(text, max) {
|
|
5
|
+
if (text.length <= max)
|
|
6
|
+
return text;
|
|
7
|
+
return text.slice(0, max - 3).trimEnd() + "...";
|
|
8
|
+
}
|
|
9
|
+
function ensureConventional(subject, scope) {
|
|
10
|
+
const conventionalRegex = /^(feat|fix|chore|docs|refactor|test|perf|build|ci|style|revert)(\(.+\))?:/;
|
|
11
|
+
if (conventionalRegex.test(subject)) {
|
|
12
|
+
return subject;
|
|
13
|
+
}
|
|
14
|
+
const scoped = scope ? `chore(${scope}): ${subject}` : `chore: ${subject}`;
|
|
15
|
+
return scoped;
|
|
16
|
+
}
|
|
17
|
+
function formatCommit(payload, config) {
|
|
18
|
+
const baseSubject = truncate(payload.subject, config.maxSubjectLength || 72);
|
|
19
|
+
const scoped = config.style === "conventional"
|
|
20
|
+
? ensureConventional(baseSubject, config.scope || payload.insightsScope)
|
|
21
|
+
: baseSubject;
|
|
22
|
+
const bullets = payload.bullets?.length
|
|
23
|
+
? payload.bullets.map((b) => `${config.bulletPrefix} ${b.trim()}`)
|
|
24
|
+
: [];
|
|
25
|
+
return [scoped, ...bullets].join("\n");
|
|
26
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isGitRepo = isGitRepo;
|
|
7
|
+
exports.hasStagedChanges = hasStagedChanges;
|
|
8
|
+
exports.getStagedDiff = getStagedDiff;
|
|
9
|
+
exports.getStagedFiles = getStagedFiles;
|
|
10
|
+
exports.stageAll = stageAll;
|
|
11
|
+
exports.commitMessage = commitMessage;
|
|
12
|
+
exports.getBranches = getBranches;
|
|
13
|
+
exports.getCurrentBranch = getCurrentBranch;
|
|
14
|
+
exports.checkoutBranch = checkoutBranch;
|
|
15
|
+
exports.createBranch = createBranch;
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
function runGit(command, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return (0, child_process_1.execSync)(`git ${command}`, {
|
|
22
|
+
cwd,
|
|
23
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
}).trim();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
throw new Error(`Git command failed: ${message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function isGitRepo(cwd) {
|
|
33
|
+
try {
|
|
34
|
+
runGit("rev-parse --is-inside-work-tree", cwd);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function hasStagedChanges(cwd) {
|
|
42
|
+
try {
|
|
43
|
+
const output = runGit("diff --cached --name-only", cwd);
|
|
44
|
+
return output.length > 0;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function getStagedDiff(cwd) {
|
|
51
|
+
return runGit("diff --cached", cwd);
|
|
52
|
+
}
|
|
53
|
+
function getStagedFiles(cwd) {
|
|
54
|
+
try {
|
|
55
|
+
const output = runGit("diff --cached --name-only", cwd);
|
|
56
|
+
if (!output)
|
|
57
|
+
return [];
|
|
58
|
+
return output.split("\n").filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function stageAll(cwd) {
|
|
65
|
+
runGit("add -A", cwd);
|
|
66
|
+
}
|
|
67
|
+
function commitMessage(cwd, message) {
|
|
68
|
+
const tempDir = fs_1.default.mkdtempSync(path_1.default.join(cwd, ".piqnote-"));
|
|
69
|
+
const filePath = path_1.default.join(tempDir, "message.txt");
|
|
70
|
+
fs_1.default.writeFileSync(filePath, message, "utf-8");
|
|
71
|
+
try {
|
|
72
|
+
runGit(`commit -F "${filePath}"`, cwd);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
try {
|
|
76
|
+
fs_1.default.rmSync(tempDir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function getBranches(cwd) {
|
|
83
|
+
try {
|
|
84
|
+
const output = runGit("branch --list --format='%(refname:short)'", cwd);
|
|
85
|
+
return output
|
|
86
|
+
.split("\n")
|
|
87
|
+
.map((b) => b.replace(/'/g, "").trim())
|
|
88
|
+
.filter(Boolean);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function getCurrentBranch(cwd) {
|
|
95
|
+
try {
|
|
96
|
+
const name = runGit("rev-parse --abbrev-ref HEAD", cwd);
|
|
97
|
+
if (name && name !== "HEAD")
|
|
98
|
+
return name;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* fallthrough */
|
|
102
|
+
}
|
|
103
|
+
return "main";
|
|
104
|
+
}
|
|
105
|
+
function checkoutBranch(cwd, branch) {
|
|
106
|
+
runGit(`checkout ${branch}`, cwd);
|
|
107
|
+
}
|
|
108
|
+
function createBranch(cwd, branch) {
|
|
109
|
+
runGit(`checkout -b ${branch}`, cwd);
|
|
110
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "piqnote",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Piqnote CLI by PromethIQ - generate high-quality Git commit messages.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"bin": {
|
|
7
|
+
"piqnote": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "commonjs",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p .",
|
|
12
|
+
"start": "npm run build && node dist/cli.js",
|
|
13
|
+
"dev": "ts-node src/cli.ts",
|
|
14
|
+
"lint": "tsc --noEmit -p .",
|
|
15
|
+
"test": "npm run build",
|
|
16
|
+
"release:changelog": "npx release-please release-pr",
|
|
17
|
+
"release:tag": "npx release-please github-release"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"git",
|
|
21
|
+
"commit",
|
|
22
|
+
"cli",
|
|
23
|
+
"conventional commits",
|
|
24
|
+
"promethiq",
|
|
25
|
+
"piqnote"
|
|
26
|
+
],
|
|
27
|
+
"author": "Adam Kudlík",
|
|
28
|
+
"homepage": "https://github.com/promethiq/piqnote",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/promethiq/piqnote/issues"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/promethiq/piqnote.git"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"chalk": "^4.1.2",
|
|
38
|
+
"commander": "^11.1.0",
|
|
39
|
+
"inquirer": "^9.2.12"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/inquirer": "^9.0.9",
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"release-please": "^16.3.0",
|
|
45
|
+
"ts-node": "^10.9.2",
|
|
46
|
+
"typescript": "^5.4.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PiqnoteConfig } from "../config/types";
|
|
2
|
+
import { AiProvider, AiRequest } from "./provider";
|
|
3
|
+
import { MockAiProvider } from "./mockProvider";
|
|
4
|
+
import { LocalAiProvider } from "./localProvider";
|
|
5
|
+
import { OpenAiProvider } from "./openAiProvider";
|
|
6
|
+
|
|
7
|
+
export interface ProviderOptions {
|
|
8
|
+
offline?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getProvider(config: PiqnoteConfig, options: ProviderOptions = {}): AiProvider {
|
|
12
|
+
if (options.offline) {
|
|
13
|
+
return new MockAiProvider();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
switch (config.provider) {
|
|
17
|
+
case "openai":
|
|
18
|
+
return new OpenAiProvider();
|
|
19
|
+
case "local":
|
|
20
|
+
return new LocalAiProvider();
|
|
21
|
+
case "mock":
|
|
22
|
+
default:
|
|
23
|
+
return new MockAiProvider();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function generateWithProvider(
|
|
28
|
+
provider: AiProvider,
|
|
29
|
+
request: AiRequest
|
|
30
|
+
) {
|
|
31
|
+
return provider.generate(request);
|
|
32
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AiProvider, AiRequest, AiResponse } from "./provider";
|
|
2
|
+
|
|
3
|
+
export class LocalAiProvider implements AiProvider {
|
|
4
|
+
public name = "local";
|
|
5
|
+
|
|
6
|
+
async generate(request: AiRequest): Promise<AiResponse> {
|
|
7
|
+
const { insights } = request;
|
|
8
|
+
const prefix = insights.scope ? `${insights.scope}: ` : "";
|
|
9
|
+
const subject = `${prefix}${insights.summary}`.slice(0, 72).trim();
|
|
10
|
+
|
|
11
|
+
const bullets = insights.bulletPoints.slice(0, 5).map((line) => line.replace(/^[+-]/, "").trim());
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
subject,
|
|
15
|
+
bullets,
|
|
16
|
+
rationale: [
|
|
17
|
+
"Generated locally using diff insights and heuristics.",
|
|
18
|
+
`Scope: ${insights.scope || "n/a"}.`,
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AiProvider, AiRequest, AiResponse } from "./provider";
|
|
2
|
+
|
|
3
|
+
const verbs = ["refine", "fix", "add", "improve", "update", "tune", "adjust", "harden"];
|
|
4
|
+
|
|
5
|
+
function pickVerb(): string {
|
|
6
|
+
return verbs[Math.floor(Math.random() * verbs.length)];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class MockAiProvider implements AiProvider {
|
|
10
|
+
public name = "mock";
|
|
11
|
+
|
|
12
|
+
async generate(request: AiRequest): Promise<AiResponse> {
|
|
13
|
+
const { insights } = request;
|
|
14
|
+
const primaryTopic = insights.topics[0] || "changes";
|
|
15
|
+
const verb = pickVerb();
|
|
16
|
+
const scopeHint = insights.scope ? `${insights.scope}: ` : "";
|
|
17
|
+
const subject = `${verb} ${scopeHint}${primaryTopic}`.trim();
|
|
18
|
+
|
|
19
|
+
const bullets = insights.bulletPoints.slice(0, 5).map((point) => {
|
|
20
|
+
return point.replace(/^[+\-]/, "").trim();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const rationale = [
|
|
24
|
+
`Used mock provider with heuristic verb '${verb}'.`,
|
|
25
|
+
`Scope hint: ${insights.scope || "none"}.`,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
return { subject, bullets, rationale };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AiProvider, AiRequest, AiResponse } from "./provider";
|
|
2
|
+
import { LocalAiProvider } from "./localProvider";
|
|
3
|
+
|
|
4
|
+
interface OpenAiChatMessage {
|
|
5
|
+
role: "system" | "user" | "assistant";
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface OpenAiChoice {
|
|
10
|
+
message: OpenAiChatMessage;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface OpenAiResponse {
|
|
14
|
+
choices: OpenAiChoice[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class OpenAiProvider implements AiProvider {
|
|
18
|
+
public name = "openai";
|
|
19
|
+
private endpoint: string;
|
|
20
|
+
private apiKey: string;
|
|
21
|
+
private model: string;
|
|
22
|
+
private fallback = new LocalAiProvider();
|
|
23
|
+
|
|
24
|
+
constructor() {
|
|
25
|
+
this.endpoint = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1/chat/completions";
|
|
26
|
+
this.apiKey = process.env.OPENAI_API_KEY || "";
|
|
27
|
+
this.model = process.env.OPENAI_MODEL || "gpt-4o-mini";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async generate(request: AiRequest): Promise<AiResponse> {
|
|
31
|
+
if (!this.apiKey) {
|
|
32
|
+
return this.fallback.generate(request);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const prompt = this.buildPrompt(request);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetch(this.endpoint, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
model: this.model,
|
|
46
|
+
temperature: 0.4,
|
|
47
|
+
messages: prompt,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
return this.fallback.generate(request);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const data = (await response.json()) as OpenAiResponse;
|
|
56
|
+
const content = data.choices?.[0]?.message?.content || "";
|
|
57
|
+
if (!content) {
|
|
58
|
+
return this.fallback.generate(request);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const parsed = this.parseContent(content);
|
|
62
|
+
return parsed || this.fallback.generate(request);
|
|
63
|
+
} catch {
|
|
64
|
+
return this.fallback.generate(request);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private buildPrompt(request: AiRequest): OpenAiChatMessage[] {
|
|
69
|
+
const { diff, insights, language, style } = request;
|
|
70
|
+
const scopeText = insights.scope ? `Scope: ${insights.scope}` : "";
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
role: "system",
|
|
74
|
+
content:
|
|
75
|
+
"You are a commit message assistant. Generate a concise Git commit message with subject <=72 characters. Include optional bullet points. Use Conventional Commits if style=conventional. Be frontend-aware for React/CSS/UI changes.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
role: "user",
|
|
79
|
+
content: [
|
|
80
|
+
`Language: ${language}`,
|
|
81
|
+
`Style: ${style}`,
|
|
82
|
+
scopeText,
|
|
83
|
+
"Diff:",
|
|
84
|
+
diff.slice(0, 6000),
|
|
85
|
+
]
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join("\n"),
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private parseContent(content: string): AiResponse | null {
|
|
93
|
+
const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
94
|
+
if (!lines.length) return null;
|
|
95
|
+
|
|
96
|
+
const subject = lines[0].replace(/^subject[:\-]\s*/i, "").slice(0, 72).trim();
|
|
97
|
+
const bullets = lines
|
|
98
|
+
.slice(1)
|
|
99
|
+
.filter((line) => /^[-*•]/.test(line))
|
|
100
|
+
.map((line) => line.replace(/^[-*•]\s*/, "").trim())
|
|
101
|
+
.slice(0, 5);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
subject: subject || lines[0].slice(0, 72),
|
|
105
|
+
bullets,
|
|
106
|
+
rationale: ["Generated via OpenAI"],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DiffInsights } from "../analyzer/diffAnalyzer";
|
|
2
|
+
|
|
3
|
+
export interface AiRequest {
|
|
4
|
+
diff: string;
|
|
5
|
+
insights: DiffInsights;
|
|
6
|
+
language: string;
|
|
7
|
+
style: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AiResponse {
|
|
11
|
+
subject: string;
|
|
12
|
+
bullets: string[];
|
|
13
|
+
rationale?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AiProvider {
|
|
17
|
+
name: string;
|
|
18
|
+
generate(request: AiRequest): Promise<AiResponse>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface DiffInsights {
|
|
2
|
+
scope?: string;
|
|
3
|
+
topics: string[];
|
|
4
|
+
bulletPoints: string[];
|
|
5
|
+
summary: string;
|
|
6
|
+
isFrontend: boolean;
|
|
7
|
+
filesTouched: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractFilePaths(diff: string): string[] {
|
|
11
|
+
return diff
|
|
12
|
+
.split("\n")
|
|
13
|
+
.filter((line) => line.startsWith("+++ b/") || line.startsWith("--- a/"))
|
|
14
|
+
.map((line) => line.replace("+++ b/", "").replace("--- a/", ""))
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function detectScopeFromPaths(paths: string[]): string | undefined {
|
|
19
|
+
const frontendExtensions = [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".sass", ".vue"];
|
|
20
|
+
const apiIndicators = ["api", "server", "backend", "routes", "controllers"];
|
|
21
|
+
const configIndicators = ["config", "settings", "env", "build", "webpack", "vite"];
|
|
22
|
+
|
|
23
|
+
for (const file of paths) {
|
|
24
|
+
const lower = file.toLowerCase();
|
|
25
|
+
if (frontendExtensions.some((ext) => lower.endsWith(ext))) {
|
|
26
|
+
if (lower.includes("component") || lower.includes("ui")) {
|
|
27
|
+
return "ui";
|
|
28
|
+
}
|
|
29
|
+
return "front";
|
|
30
|
+
}
|
|
31
|
+
if (apiIndicators.some((keyword) => lower.includes(keyword))) {
|
|
32
|
+
return "api";
|
|
33
|
+
}
|
|
34
|
+
if (configIndicators.some((keyword) => lower.includes(keyword))) {
|
|
35
|
+
return "build";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractAddedRemovedLines(diff: string): string[] {
|
|
42
|
+
return diff
|
|
43
|
+
.split("\n")
|
|
44
|
+
.filter((line) => line.startsWith("+") || line.startsWith("-"))
|
|
45
|
+
.filter((line) => !line.startsWith("+++"))
|
|
46
|
+
.filter((line) => !line.startsWith("---"))
|
|
47
|
+
.map((line) => line.substring(1).trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pickTopTopics(lines: string[], limit: number = 3): string[] {
|
|
52
|
+
const keywordCount = new Map<string, number>();
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const words = line
|
|
55
|
+
.replace(/[^a-zA-Z0-9_ ]/g, " ")
|
|
56
|
+
.split(/\s+/)
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((w) => w.toLowerCase())
|
|
59
|
+
.filter((w) => w.length > 3 && w.length < 30);
|
|
60
|
+
for (const word of words) {
|
|
61
|
+
keywordCount.set(word, (keywordCount.get(word) || 0) + 1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return Array.from(keywordCount.entries())
|
|
65
|
+
.sort((a, b) => b[1] - a[1])
|
|
66
|
+
.slice(0, limit)
|
|
67
|
+
.map(([word]) => word);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildSummary(topics: string[], scope?: string): string {
|
|
71
|
+
const topicText = topics.length ? topics.join(", ") : "changes";
|
|
72
|
+
if (scope) {
|
|
73
|
+
return `${scope} updates: ${topicText}`;
|
|
74
|
+
}
|
|
75
|
+
return `Updates around ${topicText}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function analyzeDiff(diff: string): DiffInsights {
|
|
79
|
+
const files = extractFilePaths(diff);
|
|
80
|
+
const scope = detectScopeFromPaths(files);
|
|
81
|
+
const lines = extractAddedRemovedLines(diff);
|
|
82
|
+
const topics = pickTopTopics(lines, 4);
|
|
83
|
+
const bulletPoints = lines.slice(0, 5).map((line) => line.slice(0, 80));
|
|
84
|
+
const summary = buildSummary(topics, scope);
|
|
85
|
+
const frontendExtensions = [".tsx", ".jsx", ".css", ".scss", ".sass", ".vue"];
|
|
86
|
+
const isFrontend = files.some((file) => frontendExtensions.some((ext) => file.toLowerCase().endsWith(ext)));
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
scope,
|
|
90
|
+
topics,
|
|
91
|
+
bulletPoints,
|
|
92
|
+
summary,
|
|
93
|
+
isFrontend,
|
|
94
|
+
filesTouched: files.length,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DiffInsights } from "./diffAnalyzer";
|
|
2
|
+
|
|
3
|
+
export interface ScoreDetail {
|
|
4
|
+
label: string;
|
|
5
|
+
points: number;
|
|
6
|
+
reason: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CommitScore {
|
|
10
|
+
total: number;
|
|
11
|
+
details: ScoreDetail[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isImperative(subject: string): boolean {
|
|
15
|
+
const firstWord = subject.trim().split(/\s+/)[0]?.toLowerCase();
|
|
16
|
+
if (!firstWord) return false;
|
|
17
|
+
const blacklist = ["fixes", "fixed", "fixing", "adds", "added", "adding", "updates", "updated"];
|
|
18
|
+
return !blacklist.includes(firstWord) && !subject.toLowerCase().includes("please");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function scoreCommit(message: string, insights: DiffInsights): CommitScore {
|
|
22
|
+
const details: ScoreDetail[] = [];
|
|
23
|
+
const subject = message.split("\n")[0];
|
|
24
|
+
|
|
25
|
+
if (subject.length <= 72) {
|
|
26
|
+
details.push({ label: "Subject length", points: 20, reason: "Within 72 characters" });
|
|
27
|
+
} else {
|
|
28
|
+
details.push({ label: "Subject length", points: 5, reason: "Too long" });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isImperative(subject)) {
|
|
32
|
+
details.push({ label: "Imperative mood", points: 15, reason: "Starts with a verb" });
|
|
33
|
+
} else {
|
|
34
|
+
details.push({ label: "Imperative mood", points: 5, reason: "Consider imperative verb" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (message.includes("feat:") || message.includes("fix:") || message.includes("chore:") || message.includes("refactor:")) {
|
|
38
|
+
details.push({ label: "Conventional style", points: 15, reason: "Uses Conventional Commits" });
|
|
39
|
+
} else {
|
|
40
|
+
details.push({ label: "Conventional style", points: 5, reason: "Add a type prefix" });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const bulletLines = message.split("\n").filter((line) => line.trim().startsWith("-"));
|
|
44
|
+
if (bulletLines.length > 0) {
|
|
45
|
+
details.push({ label: "Bullets", points: 15, reason: "Includes bullet points" });
|
|
46
|
+
} else {
|
|
47
|
+
details.push({ label: "Bullets", points: 5, reason: "Add bullets for clarity" });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (insights.isFrontend) {
|
|
51
|
+
details.push({ label: "Frontend awareness", points: 10, reason: "Mentions UI-related changes" });
|
|
52
|
+
} else {
|
|
53
|
+
details.push({ label: "Frontend awareness", points: 8, reason: "General changes" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (insights.filesTouched > 3) {
|
|
57
|
+
details.push({ label: "Scope breadth", points: 8, reason: "Multiple files" });
|
|
58
|
+
} else {
|
|
59
|
+
details.push({ label: "Scope breadth", points: 6, reason: "Focused change" });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const total = Math.min(
|
|
63
|
+
100,
|
|
64
|
+
details.reduce((sum, item) => sum + item.points, 0)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return { total, details };
|
|
68
|
+
}
|