escribano 0.1.4 → 0.2.2
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/dist/actions/generate-artifact-v3.js +4 -2
- package/dist/actions/generate-summary-v3.js +4 -2
- package/dist/adapters/audio.silero.adapter.js +50 -3
- package/dist/adapters/intelligence.ollama.adapter.js +9 -7
- package/dist/adapters/video.ffmpeg.adapter.js +9 -5
- package/dist/index.js +10 -0
- package/dist/services/subject-grouping.js +4 -2
- package/dist/tests/utils/env-logger.test.js +262 -0
- package/dist/utils/env-logger.js +166 -0
- package/package.json +6 -3
- package/scripts/backfill-releases.mjs +207 -0
- package/scripts/create-release.mjs +201 -0
- package/src/scripts/audio_preprocessor.py +109 -0
- package/src/scripts/visual_observer_base.py +417 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment Variable Logger
|
|
3
|
+
*
|
|
4
|
+
* Parses .env.example to extract default values and descriptions,
|
|
5
|
+
* then logs all ESCRIBANO_* environment variables with comparisons
|
|
6
|
+
* to their defaults. Only logs when ESCRIBANO_VERBOSE=true.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
const SECRET_VARS = ['ESCRIBANO_OUTLINE_TOKEN'];
|
|
11
|
+
/**
|
|
12
|
+
* Parse .env.example file to extract variable names, defaults, and descriptions.
|
|
13
|
+
* Returns empty array if file not found.
|
|
14
|
+
*/
|
|
15
|
+
function parseEnvExample() {
|
|
16
|
+
const envExamplePath = resolve(process.cwd(), '.env.example');
|
|
17
|
+
let content;
|
|
18
|
+
try {
|
|
19
|
+
content = readFileSync(envExamplePath, 'utf-8');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// File not found or unreadable
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const vars = [];
|
|
26
|
+
const lines = content.split('\n');
|
|
27
|
+
let currentDescription = [];
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const trimmedLine = line.trim();
|
|
30
|
+
// Skip empty lines - reset description
|
|
31
|
+
if (trimmedLine === '') {
|
|
32
|
+
currentDescription = [];
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// Comment line
|
|
36
|
+
if (trimmedLine.startsWith('#')) {
|
|
37
|
+
const commentContent = trimmedLine.slice(1).trim();
|
|
38
|
+
// Skip section headers (pattern: # ===)
|
|
39
|
+
if (commentContent.startsWith('===')) {
|
|
40
|
+
currentDescription = [];
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// Skip deprecated section marker
|
|
44
|
+
if (commentContent.toLowerCase().includes('deprecated')) {
|
|
45
|
+
currentDescription = [];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// Accumulate description
|
|
49
|
+
currentDescription.push(commentContent);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Variable line (contains =)
|
|
53
|
+
if (trimmedLine.includes('=')) {
|
|
54
|
+
const eqIndex = trimmedLine.indexOf('=');
|
|
55
|
+
const name = trimmedLine.slice(0, eqIndex).trim();
|
|
56
|
+
const value = trimmedLine.slice(eqIndex + 1).trim();
|
|
57
|
+
// Skip if name starts with # (deprecated/commented)
|
|
58
|
+
if (name.startsWith('#')) {
|
|
59
|
+
currentDescription = [];
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// Only track ESCRIBANO_* variables
|
|
63
|
+
if (name.startsWith('ESCRIBANO_')) {
|
|
64
|
+
vars.push({
|
|
65
|
+
name,
|
|
66
|
+
defaultValue: value,
|
|
67
|
+
description: currentDescription.join(' '),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
currentDescription = [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return vars;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Check if a variable should be masked (secret).
|
|
77
|
+
*/
|
|
78
|
+
function isSecretVar(name) {
|
|
79
|
+
return SECRET_VARS.includes(name);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Format value for display, masking secrets if needed.
|
|
83
|
+
*/
|
|
84
|
+
function formatValue(value, isSecret) {
|
|
85
|
+
if (value === 'not set') {
|
|
86
|
+
return 'not set';
|
|
87
|
+
}
|
|
88
|
+
if (isSecret && value !== 'not set' && value !== '') {
|
|
89
|
+
return '***';
|
|
90
|
+
}
|
|
91
|
+
if (value === '') {
|
|
92
|
+
return '(empty)';
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Main logging function. Only runs when ESCRIBANO_VERBOSE=true.
|
|
98
|
+
*/
|
|
99
|
+
export function logEnvironmentVariables() {
|
|
100
|
+
if (process.env.ESCRIBANO_VERBOSE !== 'true') {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const envVars = parseEnvExample();
|
|
104
|
+
if (envVars.length === 0) {
|
|
105
|
+
console.log('\n=== Environment Variables ===');
|
|
106
|
+
console.log(' (Could not parse .env.example)\n');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Build list of vars with their current values
|
|
110
|
+
const varsWithValues = envVars.map((varDef) => {
|
|
111
|
+
const currentValue = process.env[varDef.name] ?? 'not set';
|
|
112
|
+
const isCustom = currentValue !== varDef.defaultValue && currentValue !== 'not set';
|
|
113
|
+
return {
|
|
114
|
+
...varDef,
|
|
115
|
+
currentValue,
|
|
116
|
+
isCustom,
|
|
117
|
+
isSecret: isSecretVar(varDef.name),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
// Sort alphabetically by name
|
|
121
|
+
varsWithValues.sort((a, b) => a.name.localeCompare(b.name));
|
|
122
|
+
// Log output
|
|
123
|
+
console.log('\n=== Environment Variables ===\n');
|
|
124
|
+
for (const varDef of varsWithValues) {
|
|
125
|
+
const marker = varDef.isCustom ? ' [CUSTOM]' : '';
|
|
126
|
+
const displayCurrent = formatValue(varDef.currentValue, varDef.isSecret);
|
|
127
|
+
const displayDefault = formatValue(varDef.defaultValue, false);
|
|
128
|
+
console.log(` ${varDef.name}${marker}`);
|
|
129
|
+
console.log(` Current: ${displayCurrent}`);
|
|
130
|
+
console.log(` Default: ${displayDefault}`);
|
|
131
|
+
if (varDef.description) {
|
|
132
|
+
// Wrap description to fit nicely (max ~60 chars per line)
|
|
133
|
+
const wrappedDesc = wrapText(varDef.description, 58);
|
|
134
|
+
for (const line of wrappedDesc) {
|
|
135
|
+
console.log(` ${line}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
console.log('');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Wrap text to specified width.
|
|
143
|
+
*/
|
|
144
|
+
function wrapText(text, width) {
|
|
145
|
+
if (text.length <= width) {
|
|
146
|
+
return [text];
|
|
147
|
+
}
|
|
148
|
+
const words = text.split(' ');
|
|
149
|
+
const lines = [];
|
|
150
|
+
let currentLine = '';
|
|
151
|
+
for (const word of words) {
|
|
152
|
+
if (`${currentLine} ${word}`.trim().length <= width) {
|
|
153
|
+
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
if (currentLine) {
|
|
157
|
+
lines.push(currentLine);
|
|
158
|
+
}
|
|
159
|
+
currentLine = word;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (currentLine) {
|
|
163
|
+
lines.push(currentLine);
|
|
164
|
+
}
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "escribano",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "AI-powered session intelligence tool — turn screen recordings into structured work summaries",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"dist",
|
|
12
12
|
"migrations",
|
|
13
13
|
"prompts",
|
|
14
|
-
"scripts"
|
|
14
|
+
"scripts",
|
|
15
|
+
"src/scripts/audio_preprocessor.py",
|
|
16
|
+
"src/scripts/visual_observer_base.py"
|
|
15
17
|
],
|
|
16
18
|
"scripts": {
|
|
17
19
|
"test": "vitest run",
|
|
@@ -20,6 +22,7 @@
|
|
|
20
22
|
"build": "tsc",
|
|
21
23
|
"postbuild": "node scripts/add-shebang.mjs",
|
|
22
24
|
"prepublishOnly": "pnpm build",
|
|
25
|
+
"postpublish": "node scripts/create-release.mjs",
|
|
23
26
|
"lint": "biome check .",
|
|
24
27
|
"lint:fix": "biome check --write .",
|
|
25
28
|
"format": "biome format --write .",
|
|
@@ -30,7 +33,7 @@
|
|
|
30
33
|
"dashboard": "node tools/dashboard/server.js",
|
|
31
34
|
"db:reset": "rm -f ~/.escribano/escribano.db*",
|
|
32
35
|
"ollama": "OLLAMA_NUM_PARALLEL=4 OLLAMA_MAX_LOADED_MODELS=3 OLLAMA_FLASH_ATTENTION=1 OLLAMA_KEEP_ALIVE=-1 OLLAMA_CONTEXT_LENGTH=262144 ollama serve",
|
|
33
|
-
"ollama-2": "OLLAMA_NUM_PARALLEL=1 OLLAMA_HOST=127.0.0.1
|
|
36
|
+
"ollama-2": "OLLAMA_NUM_PARALLEL=1 OLLAMA_HOST=127.0.0.1.11435 OLLAMA_MAX_LOADED_MODELS=1 OLLAMA_FLASH_ATTENTION=1 OLLAMA_KEEP_ALIVE=-1 OLLAMA_CONTEXT_LENGTH=262144 ollama serve",
|
|
34
37
|
"index:rebuild": "tsx --env-file=.env src/scripts/rebuild-index.ts"
|
|
35
38
|
},
|
|
36
39
|
"keywords": [
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const REPO_ROOT = join(import.meta.dirname, "..");
|
|
8
|
+
const CHANGELOG_PATH = join(REPO_ROOT, "CHANGELOG.md");
|
|
9
|
+
|
|
10
|
+
function run(cmd) {
|
|
11
|
+
return execSync(cmd, { encoding: "utf-8", cwd: REPO_ROOT }).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPreviousTag(currentTag, allTags) {
|
|
15
|
+
const currentIndex = allTags.indexOf(currentTag);
|
|
16
|
+
if (currentIndex === 0) return null;
|
|
17
|
+
return allTags[currentIndex - 1];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getCommitsBetweenTags(fromTag, toTag) {
|
|
21
|
+
const range = fromTag ? `${fromTag}..${toTag}` : toTag;
|
|
22
|
+
const format = "%H|%s|%an|%ad";
|
|
23
|
+
const dateformat = "--date=short";
|
|
24
|
+
|
|
25
|
+
const commits = run(`git log ${range} --pretty=format:'${format}' ${dateformat}`)
|
|
26
|
+
.split("\n")
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map(line => {
|
|
29
|
+
const [hash, subject, author, date] = line.split("|");
|
|
30
|
+
return { hash, subject, author, date };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return commits;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function generateReleaseNotes(commits, version, tagDate) {
|
|
37
|
+
const commitList = commits
|
|
38
|
+
.map(c => `- ${c.subject} (${c.author}, ${c.date})`)
|
|
39
|
+
.join("\n");
|
|
40
|
+
|
|
41
|
+
const prompt = `You are a release notes generator. Given these git commits, create a clean, user-friendly changelog entry.
|
|
42
|
+
|
|
43
|
+
Version: ${version}
|
|
44
|
+
Date: ${tagDate}
|
|
45
|
+
|
|
46
|
+
Commits:
|
|
47
|
+
${commitList}
|
|
48
|
+
|
|
49
|
+
Generate a changelog entry in this exact format (use markdown):
|
|
50
|
+
|
|
51
|
+
## [${version}] - ${tagDate}
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
- (new features)
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
- (bug fixes)
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
- (improvements, refactors)
|
|
61
|
+
|
|
62
|
+
### Removed
|
|
63
|
+
- (deprecated features removed)
|
|
64
|
+
|
|
65
|
+
Rules:
|
|
66
|
+
- Group commits into the most appropriate section
|
|
67
|
+
- Use present tense ("Add feature" not "Added feature")
|
|
68
|
+
- Be concise but descriptive
|
|
69
|
+
- If a section has no commits, omit it entirely
|
|
70
|
+
- Only output the changelog entry, no additional text`;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch("http://localhost:11434/api/generate", {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
model: process.env.ESCRIBANO_LLM_MODEL || "qwen3:8b",
|
|
78
|
+
prompt,
|
|
79
|
+
stream: false,
|
|
80
|
+
options: {
|
|
81
|
+
temperature: 0.3,
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new Error(`Ollama request failed: ${response.statusText}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const data = await response.json();
|
|
91
|
+
return data.response.trim();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error("⚠️ Failed to generate notes with Ollama, using fallback");
|
|
94
|
+
console.error(error.message);
|
|
95
|
+
|
|
96
|
+
const changes = commits.map(c => `- ${c.subject}`).join("\n");
|
|
97
|
+
|
|
98
|
+
return `## [${version}] - ${tagDate}
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
${changes}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function updateChangelog(releaseNotes) {
|
|
106
|
+
let changelog = "";
|
|
107
|
+
|
|
108
|
+
if (existsSync(CHANGELOG_PATH)) {
|
|
109
|
+
changelog = readFileSync(CHANGELOG_PATH, "utf-8");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const header = `# Changelog
|
|
113
|
+
|
|
114
|
+
All notable changes to this project will be documented in this file.
|
|
115
|
+
|
|
116
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
117
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
118
|
+
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
if (!changelog.includes("# Changelog")) {
|
|
122
|
+
changelog = header + changelog;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Insert new release after header
|
|
126
|
+
const lines = changelog.split("\n");
|
|
127
|
+
const insertIndex = lines.findIndex(line => line.startsWith("## [")) || 6;
|
|
128
|
+
|
|
129
|
+
lines.splice(insertIndex, 0, releaseNotes, "");
|
|
130
|
+
|
|
131
|
+
writeFileSync(CHANGELOG_PATH, lines.join("\n"));
|
|
132
|
+
console.log("✅ Updated CHANGELOG.md");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createGitHubRelease(tag, releaseNotes) {
|
|
136
|
+
const notesFile = `/tmp/escribano-release-${tag}.md`;
|
|
137
|
+
writeFileSync(notesFile, releaseNotes);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
run(`gh release create ${tag} --title "${tag}" --notes-file "${notesFile}"`);
|
|
141
|
+
console.log(`✅ Created GitHub release: ${tag}`);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error.message.includes("already exists")) {
|
|
144
|
+
console.log(`⚠️ Release ${tag} already exists, skipping`);
|
|
145
|
+
} else {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function main() {
|
|
152
|
+
const args = process.argv.slice(2);
|
|
153
|
+
|
|
154
|
+
if (args.length === 0) {
|
|
155
|
+
console.log("Usage: node scripts/backfill-releases.mjs <tag1> [tag2] [tag3] ...");
|
|
156
|
+
console.log("Example: node scripts/backfill-releases.mjs v0.1.1 v0.1.3 v0.2.0");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tagsToProcess = args;
|
|
161
|
+
const allTags = run("git tag --list 'v*' | sort -V").split("\n").filter(Boolean);
|
|
162
|
+
|
|
163
|
+
console.log(`🚀 Creating releases for ${tagsToProcess.length} tags...\n`);
|
|
164
|
+
|
|
165
|
+
for (const tag of tagsToProcess) {
|
|
166
|
+
if (!allTags.includes(tag)) {
|
|
167
|
+
console.error(`❌ Tag ${tag} not found`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(`\n📌 Processing ${tag}`);
|
|
172
|
+
|
|
173
|
+
const previousTag = getPreviousTag(tag, allTags);
|
|
174
|
+
console.log(` Previous tag: ${previousTag || "none (first release)"}`);
|
|
175
|
+
|
|
176
|
+
const commits = getCommitsBetweenTags(previousTag, tag);
|
|
177
|
+
console.log(` Found ${commits.length} commits`);
|
|
178
|
+
|
|
179
|
+
if (commits.length === 0) {
|
|
180
|
+
console.log(" ⚠️ No commits found, skipping");
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const version = tag.replace(/^v/, "");
|
|
185
|
+
const tagDate = run(`git log -1 --format=%ad --date=short ${tag}`);
|
|
186
|
+
|
|
187
|
+
const releaseNotes = await generateReleaseNotes(commits, version, tagDate);
|
|
188
|
+
|
|
189
|
+
console.log("\n Generated release notes:");
|
|
190
|
+
console.log(" " + releaseNotes.split("\n").join("\n "));
|
|
191
|
+
console.log("\n ---");
|
|
192
|
+
|
|
193
|
+
updateChangelog(releaseNotes);
|
|
194
|
+
createGitHubRelease(tag, releaseNotes);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log("\n🎉 All releases complete!");
|
|
198
|
+
console.log("\nNext steps:");
|
|
199
|
+
console.log(" 1. Review CHANGELOG.md changes");
|
|
200
|
+
console.log(" 2. Commit: git add CHANGELOG.md && git commit -m 'docs: add CHANGELOG'");
|
|
201
|
+
console.log(" 3. Push: git push");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main().catch(error => {
|
|
205
|
+
console.error("❌ Release creation failed:", error.message);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const REPO_ROOT = join(import.meta.dirname, "..");
|
|
8
|
+
const CHANGELOG_PATH = join(REPO_ROOT, "CHANGELOG.md");
|
|
9
|
+
|
|
10
|
+
function run(cmd) {
|
|
11
|
+
return execSync(cmd, { encoding: "utf-8", cwd: REPO_ROOT }).trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getCurrentTag() {
|
|
15
|
+
try {
|
|
16
|
+
return run("git describe --tags --exact-match");
|
|
17
|
+
} catch {
|
|
18
|
+
console.error("❌ No git tag found at current commit");
|
|
19
|
+
console.error(" Run: git tag v<x.y.z> && git push --tags");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getPreviousTag(currentTag) {
|
|
25
|
+
const allTags = run("git tag --list 'v*' | sort -V").split("\n").filter(Boolean);
|
|
26
|
+
const currentIndex = allTags.indexOf(currentTag);
|
|
27
|
+
|
|
28
|
+
if (currentIndex === 0) return null;
|
|
29
|
+
return allTags[currentIndex - 1];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getCommitsSinceTag(previousTag, currentTag) {
|
|
33
|
+
const range = previousTag ? `${previousTag}..${currentTag}` : currentTag;
|
|
34
|
+
const format = "%H|%s|%an|%ad";
|
|
35
|
+
const dateformat = "--date=short";
|
|
36
|
+
|
|
37
|
+
const commits = run(`git log ${range} --pretty=format:'${format}' ${dateformat}`)
|
|
38
|
+
.split("\n")
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map(line => {
|
|
41
|
+
const [hash, subject, author, date] = line.split("|");
|
|
42
|
+
return { hash, subject, author, date };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return commits;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function generateReleaseNotes(commits, version) {
|
|
49
|
+
const commitList = commits
|
|
50
|
+
.map(c => `- ${c.subject} (${c.author}, ${c.date})`)
|
|
51
|
+
.join("\n");
|
|
52
|
+
|
|
53
|
+
const prompt = `You are a release notes generator. Given these git commits, create a clean, user-friendly changelog entry.
|
|
54
|
+
|
|
55
|
+
Version: ${version}
|
|
56
|
+
|
|
57
|
+
Commits:
|
|
58
|
+
${commitList}
|
|
59
|
+
|
|
60
|
+
Generate a changelog entry in this exact format (use markdown):
|
|
61
|
+
|
|
62
|
+
## [${version}] - ${new Date().toISOString().split("T")[0]}
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
- (new features)
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
- (bug fixes)
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
- (improvements, refactors)
|
|
72
|
+
|
|
73
|
+
### Removed
|
|
74
|
+
- (deprecated features removed)
|
|
75
|
+
|
|
76
|
+
Rules:
|
|
77
|
+
- Group commits into the most appropriate section
|
|
78
|
+
- Use present tense ("Add feature" not "Added feature")
|
|
79
|
+
- Be concise but descriptive
|
|
80
|
+
- If a section has no commits, omit it entirely
|
|
81
|
+
- Only output the changelog entry, no additional text`;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch("http://localhost:11434/api/generate", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
model: process.env.ESCRIBANO_LLM_MODEL || "qwen3:8b",
|
|
89
|
+
prompt,
|
|
90
|
+
stream: false,
|
|
91
|
+
options: {
|
|
92
|
+
temperature: 0.3,
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`Ollama request failed: ${response.statusText}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data = await response.json();
|
|
102
|
+
return data.response.trim();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error("⚠️ Failed to generate notes with Ollama, using fallback");
|
|
105
|
+
console.error(error.message);
|
|
106
|
+
|
|
107
|
+
// Fallback: simple format
|
|
108
|
+
const date = new Date().toISOString().split("T")[0];
|
|
109
|
+
const changes = commits.map(c => `- ${c.subject}`).join("\n");
|
|
110
|
+
|
|
111
|
+
return `## [${version}] - ${date}
|
|
112
|
+
|
|
113
|
+
### Changed
|
|
114
|
+
${changes}`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function updateChangelog(releaseNotes) {
|
|
119
|
+
let changelog = "";
|
|
120
|
+
|
|
121
|
+
if (existsSync(CHANGELOG_PATH)) {
|
|
122
|
+
changelog = readFileSync(CHANGELOG_PATH, "utf-8");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const header = `# Changelog
|
|
126
|
+
|
|
127
|
+
All notable changes to this project will be documented in this file.
|
|
128
|
+
|
|
129
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
130
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
131
|
+
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
if (!changelog.includes("# Changelog")) {
|
|
135
|
+
changelog = header + changelog;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Insert new release after header
|
|
139
|
+
const lines = changelog.split("\n");
|
|
140
|
+
const insertIndex = lines.findIndex(line => line.startsWith("## [")) || 6;
|
|
141
|
+
|
|
142
|
+
lines.splice(insertIndex, 0, releaseNotes, "");
|
|
143
|
+
|
|
144
|
+
writeFileSync(CHANGELOG_PATH, lines.join("\n"));
|
|
145
|
+
console.log("✅ Updated CHANGELOG.md");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createGitHubRelease(tag, releaseNotes) {
|
|
149
|
+
const notesFile = `/tmp/escribano-release-${tag}.md`;
|
|
150
|
+
writeFileSync(notesFile, releaseNotes);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
run(`gh release create ${tag} --title "${tag}" --notes-file "${notesFile}"`);
|
|
154
|
+
console.log(`✅ Created GitHub release: ${tag}`);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error.message.includes("already exists")) {
|
|
157
|
+
console.log(`⚠️ Release ${tag} already exists, skipping`);
|
|
158
|
+
} else {
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function main() {
|
|
165
|
+
console.log("🚀 Creating GitHub release...\n");
|
|
166
|
+
|
|
167
|
+
const currentTag = getCurrentTag();
|
|
168
|
+
console.log(`📌 Current tag: ${currentTag}`);
|
|
169
|
+
|
|
170
|
+
const previousTag = getPreviousTag(currentTag);
|
|
171
|
+
console.log(`📌 Previous tag: ${previousTag || "none (first release)"}`);
|
|
172
|
+
|
|
173
|
+
const commits = getCommitsSinceTag(previousTag, currentTag);
|
|
174
|
+
console.log(`📝 Found ${commits.length} commits\n`);
|
|
175
|
+
|
|
176
|
+
if (commits.length === 0) {
|
|
177
|
+
console.log("⚠️ No commits found, skipping release creation");
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const version = currentTag.replace(/^v/, "");
|
|
182
|
+
const releaseNotes = await generateReleaseNotes(commits, version);
|
|
183
|
+
|
|
184
|
+
console.log("Generated release notes:\n");
|
|
185
|
+
console.log(releaseNotes);
|
|
186
|
+
console.log("\n---\n");
|
|
187
|
+
|
|
188
|
+
updateChangelog(releaseNotes);
|
|
189
|
+
createGitHubRelease(currentTag, releaseNotes);
|
|
190
|
+
|
|
191
|
+
console.log("\n🎉 Release complete!");
|
|
192
|
+
console.log("\nNext steps:");
|
|
193
|
+
console.log(" 1. Review CHANGELOG.md changes");
|
|
194
|
+
console.log(" 2. Commit: git add CHANGELOG.md && git commit -m 'docs: update CHANGELOG'");
|
|
195
|
+
console.log(" 3. Push: git push");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
main().catch(error => {
|
|
199
|
+
console.error("❌ Release creation failed:", error.message);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
});
|