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
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Piqnote CLI by PromethIQ
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://github.com/promethiq/piqnote/actions)
|
|
5
|
+
[](https://www.npmjs.com/package/piqnote)
|
|
6
|
+
|
|
7
|
+
Piqnote is an AGPL-licensed, frontend-aware Git commit message generator by PromethIQ. It supports Conventional Commits, interactive review, commit quality scoring, offline heuristics, and optional auto-commit workflows.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
- Generates concise subjects (<=72 chars) with optional bullets (staged files fallback)
|
|
11
|
+
- Conventional Commits with configurable scope
|
|
12
|
+
- Interactive menu: Edit subject, Edit full message, Regenerate, Accept & commit, Accept & stage, Skip
|
|
13
|
+
- Commit quality scoring
|
|
14
|
+
- Branch selection and new branch creation before commit
|
|
15
|
+
- Offline/local/mock providers with AI abstraction (OpenAI/local/mock)
|
|
16
|
+
- Works on staged changes via `git diff --staged`
|
|
17
|
+
- Auto-commit mode (`--auto`) and dry-run mode (`--dry-run`)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
```bash
|
|
21
|
+
git clone https://github.com/promethiq/piqnote.git
|
|
22
|
+
cd piqnote
|
|
23
|
+
npm install
|
|
24
|
+
npm run build
|
|
25
|
+
npm install -g .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
```bash
|
|
30
|
+
git add .
|
|
31
|
+
piqnote --interactive --score
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
CLI options:
|
|
35
|
+
- `--interactive` / `--no-interactive`
|
|
36
|
+
- `--auto` commit automatically to current branch
|
|
37
|
+
- `--dry-run` show suggestions only, no commit
|
|
38
|
+
- `--score` show quality score breakdown
|
|
39
|
+
- `--offline` force offline/mock provider
|
|
40
|
+
- `--help` show all commands and options
|
|
41
|
+
|
|
42
|
+
Non-interactive examples:
|
|
43
|
+
```bash
|
|
44
|
+
piqnote --no-interactive
|
|
45
|
+
piqnote --auto --score
|
|
46
|
+
piqnote --auto --dry-run
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Interactive workflow actions:
|
|
50
|
+
- Edit subject (inline)
|
|
51
|
+
- Edit full message (editor)
|
|
52
|
+
- Regenerate suggestion
|
|
53
|
+
- Accept and stage only
|
|
54
|
+
- Accept and commit (choose branch or create new)
|
|
55
|
+
- Skip
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
Create a `.piqnoterc` in your repo root:
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"style": "conventional",
|
|
62
|
+
"scope": "web",
|
|
63
|
+
"maxSubjectLength": 72,
|
|
64
|
+
"language": "en",
|
|
65
|
+
"bulletPrefix": "-",
|
|
66
|
+
"provider": "mock",
|
|
67
|
+
"offline": false
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Environment (optional for OpenAI provider):
|
|
72
|
+
- OPENAI_API_KEY
|
|
73
|
+
- OPENAI_MODEL (default: gpt-4o-mini)
|
|
74
|
+
- OPENAI_BASE_URL (optional)
|
|
75
|
+
|
|
76
|
+
## Project Structure
|
|
77
|
+
```
|
|
78
|
+
src/
|
|
79
|
+
cli.ts
|
|
80
|
+
ai/
|
|
81
|
+
analyzer/
|
|
82
|
+
formatter/
|
|
83
|
+
git/
|
|
84
|
+
config/
|
|
85
|
+
.github/workflows/ci.yml
|
|
86
|
+
.piqnoterc
|
|
87
|
+
CHANGELOG.md
|
|
88
|
+
LICENSE
|
|
89
|
+
README.md
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Versioning and Releases
|
|
93
|
+
- Semantic Versioning (semver), starting at 0.1.0
|
|
94
|
+
- CHANGELOG maintained by CI via release-please
|
|
95
|
+
- GitHub Actions pipeline builds, lints, tests, and can publish on tagged releases
|
|
96
|
+
|
|
97
|
+
## CI/CD
|
|
98
|
+
- Install dependencies
|
|
99
|
+
- TypeScript lint/build
|
|
100
|
+
- Tests
|
|
101
|
+
- Release automation to update CHANGELOG and create GitHub releases (optional npm publish with token)
|
|
102
|
+
|
|
103
|
+
## License and Branding
|
|
104
|
+
- License: AGPL-3.0 (see [LICENSE](LICENSE))
|
|
105
|
+
- Project: Piqnote - by PromethIQ
|
|
106
|
+
- Maintainer: Adam Kudlík
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getProvider = getProvider;
|
|
4
|
+
exports.generateWithProvider = generateWithProvider;
|
|
5
|
+
const mockProvider_1 = require("./mockProvider");
|
|
6
|
+
const localProvider_1 = require("./localProvider");
|
|
7
|
+
const openAiProvider_1 = require("./openAiProvider");
|
|
8
|
+
function getProvider(config, options = {}) {
|
|
9
|
+
if (options.offline) {
|
|
10
|
+
return new mockProvider_1.MockAiProvider();
|
|
11
|
+
}
|
|
12
|
+
switch (config.provider) {
|
|
13
|
+
case "openai":
|
|
14
|
+
return new openAiProvider_1.OpenAiProvider();
|
|
15
|
+
case "local":
|
|
16
|
+
return new localProvider_1.LocalAiProvider();
|
|
17
|
+
case "mock":
|
|
18
|
+
default:
|
|
19
|
+
return new mockProvider_1.MockAiProvider();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function generateWithProvider(provider, request) {
|
|
23
|
+
return provider.generate(request);
|
|
24
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalAiProvider = void 0;
|
|
4
|
+
class LocalAiProvider {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.name = "local";
|
|
7
|
+
}
|
|
8
|
+
async generate(request) {
|
|
9
|
+
const { insights } = request;
|
|
10
|
+
const prefix = insights.scope ? `${insights.scope}: ` : "";
|
|
11
|
+
const subject = `${prefix}${insights.summary}`.slice(0, 72).trim();
|
|
12
|
+
const bullets = insights.bulletPoints.slice(0, 5).map((line) => line.replace(/^[+-]/, "").trim());
|
|
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
|
+
}
|
|
23
|
+
exports.LocalAiProvider = LocalAiProvider;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MockAiProvider = void 0;
|
|
4
|
+
const verbs = ["refine", "fix", "add", "improve", "update", "tune", "adjust", "harden"];
|
|
5
|
+
function pickVerb() {
|
|
6
|
+
return verbs[Math.floor(Math.random() * verbs.length)];
|
|
7
|
+
}
|
|
8
|
+
class MockAiProvider {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.name = "mock";
|
|
11
|
+
}
|
|
12
|
+
async generate(request) {
|
|
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
|
+
const bullets = insights.bulletPoints.slice(0, 5).map((point) => {
|
|
19
|
+
return point.replace(/^[+\-]/, "").trim();
|
|
20
|
+
});
|
|
21
|
+
const rationale = [
|
|
22
|
+
`Used mock provider with heuristic verb '${verb}'.`,
|
|
23
|
+
`Scope hint: ${insights.scope || "none"}.`,
|
|
24
|
+
];
|
|
25
|
+
return { subject, bullets, rationale };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.MockAiProvider = MockAiProvider;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OpenAiProvider = void 0;
|
|
4
|
+
const localProvider_1 = require("./localProvider");
|
|
5
|
+
class OpenAiProvider {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.name = "openai";
|
|
8
|
+
this.fallback = new localProvider_1.LocalAiProvider();
|
|
9
|
+
this.endpoint = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1/chat/completions";
|
|
10
|
+
this.apiKey = process.env.OPENAI_API_KEY || "";
|
|
11
|
+
this.model = process.env.OPENAI_MODEL || "gpt-4o-mini";
|
|
12
|
+
}
|
|
13
|
+
async generate(request) {
|
|
14
|
+
if (!this.apiKey) {
|
|
15
|
+
return this.fallback.generate(request);
|
|
16
|
+
}
|
|
17
|
+
const prompt = this.buildPrompt(request);
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch(this.endpoint, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
model: this.model,
|
|
27
|
+
temperature: 0.4,
|
|
28
|
+
messages: prompt,
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
return this.fallback.generate(request);
|
|
33
|
+
}
|
|
34
|
+
const data = (await response.json());
|
|
35
|
+
const content = data.choices?.[0]?.message?.content || "";
|
|
36
|
+
if (!content) {
|
|
37
|
+
return this.fallback.generate(request);
|
|
38
|
+
}
|
|
39
|
+
const parsed = this.parseContent(content);
|
|
40
|
+
return parsed || this.fallback.generate(request);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return this.fallback.generate(request);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
buildPrompt(request) {
|
|
47
|
+
const { diff, insights, language, style } = request;
|
|
48
|
+
const scopeText = insights.scope ? `Scope: ${insights.scope}` : "";
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
role: "system",
|
|
52
|
+
content: "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.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
role: "user",
|
|
56
|
+
content: [
|
|
57
|
+
`Language: ${language}`,
|
|
58
|
+
`Style: ${style}`,
|
|
59
|
+
scopeText,
|
|
60
|
+
"Diff:",
|
|
61
|
+
diff.slice(0, 6000),
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join("\n"),
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
parseContent(content) {
|
|
69
|
+
const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
70
|
+
if (!lines.length)
|
|
71
|
+
return null;
|
|
72
|
+
const subject = lines[0].replace(/^subject[:\-]\s*/i, "").slice(0, 72).trim();
|
|
73
|
+
const bullets = lines
|
|
74
|
+
.slice(1)
|
|
75
|
+
.filter((line) => /^[-*•]/.test(line))
|
|
76
|
+
.map((line) => line.replace(/^[-*•]\s*/, "").trim())
|
|
77
|
+
.slice(0, 5);
|
|
78
|
+
return {
|
|
79
|
+
subject: subject || lines[0].slice(0, 72),
|
|
80
|
+
bullets,
|
|
81
|
+
rationale: ["Generated via OpenAI"],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
exports.OpenAiProvider = OpenAiProvider;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyzeDiff = analyzeDiff;
|
|
4
|
+
function extractFilePaths(diff) {
|
|
5
|
+
return diff
|
|
6
|
+
.split("\n")
|
|
7
|
+
.filter((line) => line.startsWith("+++ b/") || line.startsWith("--- a/"))
|
|
8
|
+
.map((line) => line.replace("+++ b/", "").replace("--- a/", ""))
|
|
9
|
+
.filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
function detectScopeFromPaths(paths) {
|
|
12
|
+
const frontendExtensions = [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".sass", ".vue"];
|
|
13
|
+
const apiIndicators = ["api", "server", "backend", "routes", "controllers"];
|
|
14
|
+
const configIndicators = ["config", "settings", "env", "build", "webpack", "vite"];
|
|
15
|
+
for (const file of paths) {
|
|
16
|
+
const lower = file.toLowerCase();
|
|
17
|
+
if (frontendExtensions.some((ext) => lower.endsWith(ext))) {
|
|
18
|
+
if (lower.includes("component") || lower.includes("ui")) {
|
|
19
|
+
return "ui";
|
|
20
|
+
}
|
|
21
|
+
return "front";
|
|
22
|
+
}
|
|
23
|
+
if (apiIndicators.some((keyword) => lower.includes(keyword))) {
|
|
24
|
+
return "api";
|
|
25
|
+
}
|
|
26
|
+
if (configIndicators.some((keyword) => lower.includes(keyword))) {
|
|
27
|
+
return "build";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
function extractAddedRemovedLines(diff) {
|
|
33
|
+
return diff
|
|
34
|
+
.split("\n")
|
|
35
|
+
.filter((line) => line.startsWith("+") || line.startsWith("-"))
|
|
36
|
+
.filter((line) => !line.startsWith("+++"))
|
|
37
|
+
.filter((line) => !line.startsWith("---"))
|
|
38
|
+
.map((line) => line.substring(1).trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
function pickTopTopics(lines, limit = 3) {
|
|
42
|
+
const keywordCount = new Map();
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
const words = line
|
|
45
|
+
.replace(/[^a-zA-Z0-9_ ]/g, " ")
|
|
46
|
+
.split(/\s+/)
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((w) => w.toLowerCase())
|
|
49
|
+
.filter((w) => w.length > 3 && w.length < 30);
|
|
50
|
+
for (const word of words) {
|
|
51
|
+
keywordCount.set(word, (keywordCount.get(word) || 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return Array.from(keywordCount.entries())
|
|
55
|
+
.sort((a, b) => b[1] - a[1])
|
|
56
|
+
.slice(0, limit)
|
|
57
|
+
.map(([word]) => word);
|
|
58
|
+
}
|
|
59
|
+
function buildSummary(topics, scope) {
|
|
60
|
+
const topicText = topics.length ? topics.join(", ") : "changes";
|
|
61
|
+
if (scope) {
|
|
62
|
+
return `${scope} updates: ${topicText}`;
|
|
63
|
+
}
|
|
64
|
+
return `Updates around ${topicText}`;
|
|
65
|
+
}
|
|
66
|
+
function analyzeDiff(diff) {
|
|
67
|
+
const files = extractFilePaths(diff);
|
|
68
|
+
const scope = detectScopeFromPaths(files);
|
|
69
|
+
const lines = extractAddedRemovedLines(diff);
|
|
70
|
+
const topics = pickTopTopics(lines, 4);
|
|
71
|
+
const bulletPoints = lines.slice(0, 5).map((line) => line.slice(0, 80));
|
|
72
|
+
const summary = buildSummary(topics, scope);
|
|
73
|
+
const frontendExtensions = [".tsx", ".jsx", ".css", ".scss", ".sass", ".vue"];
|
|
74
|
+
const isFrontend = files.some((file) => frontendExtensions.some((ext) => file.toLowerCase().endsWith(ext)));
|
|
75
|
+
return {
|
|
76
|
+
scope,
|
|
77
|
+
topics,
|
|
78
|
+
bulletPoints,
|
|
79
|
+
summary,
|
|
80
|
+
isFrontend,
|
|
81
|
+
filesTouched: files.length,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scoreCommit = scoreCommit;
|
|
4
|
+
function isImperative(subject) {
|
|
5
|
+
const firstWord = subject.trim().split(/\s+/)[0]?.toLowerCase();
|
|
6
|
+
if (!firstWord)
|
|
7
|
+
return false;
|
|
8
|
+
const blacklist = ["fixes", "fixed", "fixing", "adds", "added", "adding", "updates", "updated"];
|
|
9
|
+
return !blacklist.includes(firstWord) && !subject.toLowerCase().includes("please");
|
|
10
|
+
}
|
|
11
|
+
function scoreCommit(message, insights) {
|
|
12
|
+
const details = [];
|
|
13
|
+
const subject = message.split("\n")[0];
|
|
14
|
+
if (subject.length <= 72) {
|
|
15
|
+
details.push({ label: "Subject length", points: 20, reason: "Within 72 characters" });
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
details.push({ label: "Subject length", points: 5, reason: "Too long" });
|
|
19
|
+
}
|
|
20
|
+
if (isImperative(subject)) {
|
|
21
|
+
details.push({ label: "Imperative mood", points: 15, reason: "Starts with a verb" });
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
details.push({ label: "Imperative mood", points: 5, reason: "Consider imperative verb" });
|
|
25
|
+
}
|
|
26
|
+
if (message.includes("feat:") || message.includes("fix:") || message.includes("chore:") || message.includes("refactor:")) {
|
|
27
|
+
details.push({ label: "Conventional style", points: 15, reason: "Uses Conventional Commits" });
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
details.push({ label: "Conventional style", points: 5, reason: "Add a type prefix" });
|
|
31
|
+
}
|
|
32
|
+
const bulletLines = message.split("\n").filter((line) => line.trim().startsWith("-"));
|
|
33
|
+
if (bulletLines.length > 0) {
|
|
34
|
+
details.push({ label: "Bullets", points: 15, reason: "Includes bullet points" });
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
details.push({ label: "Bullets", points: 5, reason: "Add bullets for clarity" });
|
|
38
|
+
}
|
|
39
|
+
if (insights.isFrontend) {
|
|
40
|
+
details.push({ label: "Frontend awareness", points: 10, reason: "Mentions UI-related changes" });
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
details.push({ label: "Frontend awareness", points: 8, reason: "General changes" });
|
|
44
|
+
}
|
|
45
|
+
if (insights.filesTouched > 3) {
|
|
46
|
+
details.push({ label: "Scope breadth", points: 8, reason: "Multiple files" });
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
details.push({ label: "Scope breadth", points: 6, reason: "Focused change" });
|
|
50
|
+
}
|
|
51
|
+
const total = Math.min(100, details.reduce((sum, item) => sum + item.points, 0));
|
|
52
|
+
return { total, details };
|
|
53
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const inquirer_1 = __importDefault(require("inquirer"));
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
const loader_1 = require("./config/loader");
|
|
11
|
+
const diffAnalyzer_1 = require("./analyzer/diffAnalyzer");
|
|
12
|
+
const scorer_1 = require("./analyzer/scorer");
|
|
13
|
+
const commitFormatter_1 = require("./formatter/commitFormatter");
|
|
14
|
+
const factory_1 = require("./ai/factory");
|
|
15
|
+
const gitClient_1 = require("./git/gitClient");
|
|
16
|
+
function ensureBullets(responseBullets, stagedFiles) {
|
|
17
|
+
if (responseBullets && responseBullets.length)
|
|
18
|
+
return responseBullets;
|
|
19
|
+
return stagedFiles.slice(0, 5).map((file) => file);
|
|
20
|
+
}
|
|
21
|
+
async function promptAction() {
|
|
22
|
+
const { action } = await inquirer_1.default.prompt([
|
|
23
|
+
{
|
|
24
|
+
type: "list",
|
|
25
|
+
name: "action",
|
|
26
|
+
message: "What do you want to do?",
|
|
27
|
+
choices: [
|
|
28
|
+
{ name: "Accept & commit", value: "accept-commit" },
|
|
29
|
+
{ name: "Accept & stage only", value: "accept-stage" },
|
|
30
|
+
{ name: "Edit subject", value: "edit-subject" },
|
|
31
|
+
{ name: "Edit full message", value: "edit-full" },
|
|
32
|
+
{ name: "Regenerate", value: "regenerate" },
|
|
33
|
+
{ name: "Skip", value: "skip" },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
return action;
|
|
38
|
+
}
|
|
39
|
+
async function promptSubjectEdit(subject) {
|
|
40
|
+
const { edited } = await inquirer_1.default.prompt([
|
|
41
|
+
{
|
|
42
|
+
type: "input",
|
|
43
|
+
name: "edited",
|
|
44
|
+
default: subject,
|
|
45
|
+
message: "Edit subject (<=72 chars)",
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
return edited;
|
|
49
|
+
}
|
|
50
|
+
async function promptFullEdit(initial) {
|
|
51
|
+
const { edited } = await inquirer_1.default.prompt([
|
|
52
|
+
{
|
|
53
|
+
type: "editor",
|
|
54
|
+
name: "edited",
|
|
55
|
+
default: initial,
|
|
56
|
+
message: "Edit full commit message",
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
return edited;
|
|
60
|
+
}
|
|
61
|
+
async function promptBranch(cwd) {
|
|
62
|
+
const branches = (0, gitClient_1.getBranches)(cwd);
|
|
63
|
+
const current = (0, gitClient_1.getCurrentBranch)(cwd);
|
|
64
|
+
const baseChoices = branches
|
|
65
|
+
.filter((b) => b !== current)
|
|
66
|
+
.map((b) => ({ name: b, value: b }));
|
|
67
|
+
const choices = [
|
|
68
|
+
{ name: `(current) ${current}`, value: current },
|
|
69
|
+
...baseChoices,
|
|
70
|
+
{ name: "Create new branch", value: "__create__" },
|
|
71
|
+
];
|
|
72
|
+
const { branch } = await inquirer_1.default.prompt([
|
|
73
|
+
{
|
|
74
|
+
type: "list",
|
|
75
|
+
name: "branch",
|
|
76
|
+
message: "Select branch for commit",
|
|
77
|
+
default: current,
|
|
78
|
+
choices,
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
if (branch === "__create__") {
|
|
82
|
+
const { name } = await inquirer_1.default.prompt([
|
|
83
|
+
{
|
|
84
|
+
type: "input",
|
|
85
|
+
name: "name",
|
|
86
|
+
message: "New branch name",
|
|
87
|
+
validate: (val) => (val && val.trim().length > 0 ? true : "Enter a branch name"),
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
(0, gitClient_1.createBranch)(cwd, name.trim());
|
|
91
|
+
return name.trim();
|
|
92
|
+
}
|
|
93
|
+
if (branch !== current) {
|
|
94
|
+
(0, gitClient_1.checkoutBranch)(cwd, branch);
|
|
95
|
+
}
|
|
96
|
+
return branch;
|
|
97
|
+
}
|
|
98
|
+
function renderSuggestion(message, showScore, diff) {
|
|
99
|
+
console.log("\n" + chalk_1.default.blue.bold("Piqnote suggestion:"));
|
|
100
|
+
console.log(chalk_1.default.green(message));
|
|
101
|
+
if (showScore) {
|
|
102
|
+
const insights = (0, diffAnalyzer_1.analyzeDiff)(diff);
|
|
103
|
+
const score = (0, scorer_1.scoreCommit)(message, insights);
|
|
104
|
+
console.log("\n" + chalk_1.default.yellow(`Quality score: ${score.total}/100`));
|
|
105
|
+
score.details.forEach((item) => {
|
|
106
|
+
const label = chalk_1.default.gray("-");
|
|
107
|
+
console.log(`${label} ${item.label}: ${item.points} (${item.reason})`);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function formatWithFallback(subject, bullets, insightsScope, config, stagedFiles) {
|
|
112
|
+
const mergedBullets = ensureBullets(bullets, stagedFiles);
|
|
113
|
+
return (0, commitFormatter_1.formatCommit)({ subject, bullets: mergedBullets, insightsScope }, config);
|
|
114
|
+
}
|
|
115
|
+
async function handleCommit(cwd, message, dryRun) {
|
|
116
|
+
if (dryRun) {
|
|
117
|
+
console.log(chalk_1.default.yellow("Dry-run: commit not created."));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
(0, gitClient_1.stageAll)(cwd);
|
|
121
|
+
(0, gitClient_1.commitMessage)(cwd, message);
|
|
122
|
+
console.log(chalk_1.default.green("Commit created."));
|
|
123
|
+
}
|
|
124
|
+
async function autoFlow(cwd, message, options, diff) {
|
|
125
|
+
renderSuggestion(message, options.score, diff);
|
|
126
|
+
await handleCommit(cwd, message, options.dryRun);
|
|
127
|
+
}
|
|
128
|
+
async function interactiveFlow(cwd, config, options, diff, providerInput) {
|
|
129
|
+
let message = formatWithFallback(providerInput.subject, providerInput.bullets, providerInput.insightsScope, config, (0, gitClient_1.getStagedFiles)(cwd));
|
|
130
|
+
let loop = true;
|
|
131
|
+
while (loop) {
|
|
132
|
+
renderSuggestion(message, options.score, diff);
|
|
133
|
+
const action = await promptAction();
|
|
134
|
+
if (action === "edit-subject") {
|
|
135
|
+
const subject = message.split("\n")[0];
|
|
136
|
+
const edited = await promptSubjectEdit(subject);
|
|
137
|
+
const rest = message.split("\n").slice(1);
|
|
138
|
+
message = [edited, ...rest].join("\n");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (action === "edit-full") {
|
|
142
|
+
message = await promptFullEdit(message);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (action === "regenerate") {
|
|
146
|
+
const next = await providerInput.regenerate();
|
|
147
|
+
message = formatWithFallback(next.subject, next.bullets, providerInput.insightsScope, config, (0, gitClient_1.getStagedFiles)(cwd));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (action === "accept-stage") {
|
|
151
|
+
(0, gitClient_1.stageAll)(cwd);
|
|
152
|
+
console.log(chalk_1.default.green("Staged changes updated."));
|
|
153
|
+
loop = false;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (action === "accept-commit") {
|
|
157
|
+
const branch = await promptBranch(cwd);
|
|
158
|
+
console.log(chalk_1.default.gray(`Using branch: ${branch}`));
|
|
159
|
+
await handleCommit(cwd, message, options.dryRun);
|
|
160
|
+
loop = false;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (action === "skip") {
|
|
164
|
+
console.log("Skipped committing.");
|
|
165
|
+
loop = false;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function main() {
|
|
171
|
+
const program = new commander_1.Command();
|
|
172
|
+
program
|
|
173
|
+
.name("piqnote")
|
|
174
|
+
.description("Piqnote CLI by PromethIQ - generate commit messages")
|
|
175
|
+
.option("-i, --interactive", "Review interactively")
|
|
176
|
+
.option("--no-interactive", "Disable interactive review")
|
|
177
|
+
.option("--auto", "Commit automatically to current branch", false)
|
|
178
|
+
.option("--dry-run", "Show suggestions only; no commit", false)
|
|
179
|
+
.option("--score", "Show commit quality score", false)
|
|
180
|
+
.option("--offline", "Use offline heuristics", false)
|
|
181
|
+
.version("0.1.0");
|
|
182
|
+
program.parse(process.argv);
|
|
183
|
+
const raw = program.opts();
|
|
184
|
+
const options = {
|
|
185
|
+
interactive: raw.noInteractive ? false : raw.interactive ?? true,
|
|
186
|
+
score: Boolean(raw.score),
|
|
187
|
+
offline: Boolean(raw.offline),
|
|
188
|
+
auto: Boolean(raw.auto),
|
|
189
|
+
dryRun: Boolean(raw.dryRun),
|
|
190
|
+
};
|
|
191
|
+
const cwd = process.cwd();
|
|
192
|
+
if (!(0, gitClient_1.isGitRepo)(cwd)) {
|
|
193
|
+
console.error("Piqnote: not a git repository.");
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
if (!(0, gitClient_1.hasStagedChanges)(cwd)) {
|
|
197
|
+
console.error("Piqnote: no staged changes. Stage files first with 'git add'.");
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
const diff = (0, gitClient_1.getStagedDiff)(cwd);
|
|
201
|
+
const config = (0, loader_1.loadConfig)(cwd) || (0, loader_1.getDefaultConfig)();
|
|
202
|
+
const insights = (0, diffAnalyzer_1.analyzeDiff)(diff);
|
|
203
|
+
const provider = (0, factory_1.getProvider)(config, { offline: options.offline || config.offline });
|
|
204
|
+
const stagedFiles = (0, gitClient_1.getStagedFiles)(cwd);
|
|
205
|
+
const generate = async () => {
|
|
206
|
+
const res = await (0, factory_1.generateWithProvider)(provider, {
|
|
207
|
+
diff,
|
|
208
|
+
insights,
|
|
209
|
+
language: config.language,
|
|
210
|
+
style: config.style,
|
|
211
|
+
});
|
|
212
|
+
return res;
|
|
213
|
+
};
|
|
214
|
+
const initial = await generate();
|
|
215
|
+
const formattedInitial = formatWithFallback(initial.subject, initial.bullets, insights.scope, config, stagedFiles);
|
|
216
|
+
if (!options.interactive || options.auto) {
|
|
217
|
+
await autoFlow(cwd, formattedInitial, options, diff);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await interactiveFlow(cwd, config, options, diff, {
|
|
221
|
+
subject: initial.subject,
|
|
222
|
+
bullets: ensureBullets(initial.bullets, stagedFiles),
|
|
223
|
+
insightsScope: insights.scope,
|
|
224
|
+
regenerate: async () => {
|
|
225
|
+
const next = await generate();
|
|
226
|
+
return {
|
|
227
|
+
subject: next.subject,
|
|
228
|
+
bullets: ensureBullets(next.bullets, (0, gitClient_1.getStagedFiles)(cwd)),
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
main().catch((error) => {
|
|
234
|
+
console.error("Piqnote failed:", error instanceof Error ? error.message : error);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
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.loadConfig = loadConfig;
|
|
7
|
+
exports.getDefaultConfig = getDefaultConfig;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const defaultConfig = {
|
|
11
|
+
style: "conventional",
|
|
12
|
+
scope: undefined,
|
|
13
|
+
maxSubjectLength: 72,
|
|
14
|
+
language: "en",
|
|
15
|
+
bulletPrefix: "-",
|
|
16
|
+
provider: "mock",
|
|
17
|
+
offline: false,
|
|
18
|
+
};
|
|
19
|
+
function loadConfig(cwd = process.cwd()) {
|
|
20
|
+
const configPath = path_1.default.join(cwd, ".piqnoterc");
|
|
21
|
+
if (!fs_1.default.existsSync(configPath)) {
|
|
22
|
+
return defaultConfig;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs_1.default.readFileSync(configPath, "utf-8");
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
return {
|
|
28
|
+
...defaultConfig,
|
|
29
|
+
...parsed,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.warn("Piqnote: failed to parse .piqnoterc, using defaults.");
|
|
34
|
+
return defaultConfig;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function getDefaultConfig() {
|
|
38
|
+
return { ...defaultConfig };
|
|
39
|
+
}
|