@grunnverk/kilde 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.
Files changed (75) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +40 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +31 -0
  3. package/.github/pull_request_template.md +48 -0
  4. package/.github/workflows/deploy-docs.yml +59 -0
  5. package/.github/workflows/npm-publish.yml +48 -0
  6. package/.github/workflows/test.yml +48 -0
  7. package/CHANGELOG.md +92 -0
  8. package/CONTRIBUTING.md +438 -0
  9. package/LICENSE +190 -0
  10. package/PROJECT_SUMMARY.md +318 -0
  11. package/README.md +444 -0
  12. package/RELEASE_CHECKLIST.md +182 -0
  13. package/dist/application.js +166 -0
  14. package/dist/application.js.map +1 -0
  15. package/dist/commands/release.js +326 -0
  16. package/dist/commands/release.js.map +1 -0
  17. package/dist/constants.js +122 -0
  18. package/dist/constants.js.map +1 -0
  19. package/dist/logging.js +176 -0
  20. package/dist/logging.js.map +1 -0
  21. package/dist/main.js +24 -0
  22. package/dist/main.js.map +1 -0
  23. package/dist/mcp-server.js +17467 -0
  24. package/dist/mcp-server.js.map +7 -0
  25. package/dist/utils/config.js +89 -0
  26. package/dist/utils/config.js.map +1 -0
  27. package/docs/AI_GUIDE.md +618 -0
  28. package/eslint.config.mjs +85 -0
  29. package/guide/architecture.md +776 -0
  30. package/guide/commands.md +580 -0
  31. package/guide/configuration.md +779 -0
  32. package/guide/mcp-integration.md +708 -0
  33. package/guide/overview.md +225 -0
  34. package/package.json +91 -0
  35. package/scripts/build-mcp.js +115 -0
  36. package/scripts/test-mcp-compliance.js +254 -0
  37. package/src/application.ts +246 -0
  38. package/src/commands/release.ts +450 -0
  39. package/src/constants.ts +162 -0
  40. package/src/logging.ts +210 -0
  41. package/src/main.ts +25 -0
  42. package/src/mcp/prompts/index.ts +98 -0
  43. package/src/mcp/resources.ts +121 -0
  44. package/src/mcp/server.ts +195 -0
  45. package/src/mcp/tools.ts +219 -0
  46. package/src/types.ts +131 -0
  47. package/src/utils/config.ts +181 -0
  48. package/tests/application.test.ts +114 -0
  49. package/tests/commands/commit.test.ts +248 -0
  50. package/tests/commands/release.test.ts +325 -0
  51. package/tests/constants.test.ts +118 -0
  52. package/tests/logging.test.ts +142 -0
  53. package/tests/mcp/prompts/index.test.ts +202 -0
  54. package/tests/mcp/resources.test.ts +166 -0
  55. package/tests/mcp/tools.test.ts +211 -0
  56. package/tests/utils/config.test.ts +212 -0
  57. package/tsconfig.json +32 -0
  58. package/vite.config.ts +107 -0
  59. package/vitest.config.ts +40 -0
  60. package/website/index.html +14 -0
  61. package/website/src/App.css +142 -0
  62. package/website/src/App.tsx +34 -0
  63. package/website/src/components/Commands.tsx +182 -0
  64. package/website/src/components/Configuration.tsx +214 -0
  65. package/website/src/components/Examples.tsx +234 -0
  66. package/website/src/components/Footer.css +99 -0
  67. package/website/src/components/Footer.tsx +93 -0
  68. package/website/src/components/GettingStarted.tsx +94 -0
  69. package/website/src/components/Hero.css +95 -0
  70. package/website/src/components/Hero.tsx +50 -0
  71. package/website/src/components/Navigation.css +102 -0
  72. package/website/src/components/Navigation.tsx +57 -0
  73. package/website/src/index.css +36 -0
  74. package/website/src/main.tsx +10 -0
  75. package/website/vite.config.ts +12 -0
@@ -0,0 +1,246 @@
1
+ // Load .env file if it exists, but NEVER override existing environment variables
2
+ import { config as dotenvConfig } from 'dotenv';
3
+ dotenvConfig({ override: false, debug: false });
4
+
5
+ import { setLogger as setGitLogger } from '@grunnverk/git-tools';
6
+ import { initializeTemplates } from '@grunnverk/ai-service';
7
+ import { Config } from '@grunnverk/core';
8
+ import { Command } from 'commander';
9
+
10
+ // Import commands
11
+ import * as CommandsGit from '@grunnverk/commands-git';
12
+ import * as ReleaseCommand from './commands/release';
13
+
14
+ import {
15
+ COMMAND_COMMIT,
16
+ COMMAND_RELEASE,
17
+ VERSION,
18
+ BUILD_HOSTNAME,
19
+ BUILD_TIMESTAMP,
20
+ KILDE_DEFAULTS,
21
+ PROGRAM_NAME
22
+ } from './constants';
23
+ import { getLogger, setLogLevel } from './logging';
24
+ import { getEffectiveConfig } from './utils/config';
25
+
26
+ /**
27
+ * Check Node.js version and exit with clear error message if version is too old.
28
+ */
29
+ function checkNodeVersion(): void {
30
+ const requiredMajorVersion = 24;
31
+ const currentVersion = process.version;
32
+ const majorVersion = parseInt(currentVersion.slice(1).split('.')[0], 10);
33
+
34
+ if (majorVersion < requiredMajorVersion) {
35
+ // eslint-disable-next-line no-console
36
+ console.error(`\n❌ ERROR: Node.js version ${requiredMajorVersion}.0.0 or higher is required.`);
37
+ // eslint-disable-next-line no-console
38
+ console.error(` Current version: ${currentVersion}`);
39
+ // eslint-disable-next-line no-console
40
+ console.error(` Please upgrade your Node.js version to continue.\n`);
41
+ process.exit(1);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Get formatted version information including build metadata.
47
+ */
48
+ export function getVersionInfo(): { version: string; buildHostname: string; buildTimestamp: string; formatted: string } {
49
+ return {
50
+ version: VERSION,
51
+ buildHostname: BUILD_HOSTNAME,
52
+ buildTimestamp: BUILD_TIMESTAMP,
53
+ formatted: `${VERSION}\nBuilt on: ${BUILD_HOSTNAME}\nBuild time: ${BUILD_TIMESTAMP}`
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Parse command line arguments and merge with config
59
+ */
60
+ async function parseArguments(): Promise<{ commandName: string; config: Config }> {
61
+ const program = new Command();
62
+
63
+ program
64
+ .name(PROGRAM_NAME)
65
+ .description('Universal Git Automation Tool - AI-powered commit and release messages')
66
+ .version(VERSION)
67
+ .option('-v, --verbose', 'Enable verbose logging')
68
+ .option('-d, --debug', 'Enable debug logging')
69
+ .option('--dry-run', 'Preview without making changes')
70
+ .option('--model <model>', 'AI model to use (default: gpt-4o-mini)')
71
+ .option('--reasoning <level>', 'OpenAI reasoning level: low, medium, high (default: low)');
72
+
73
+ // Commit command
74
+ program
75
+ .command('commit')
76
+ .description('Generate AI-powered commit message')
77
+ .option('--add', 'Stage all changes before committing')
78
+ .option('--cached', 'Only use staged changes')
79
+ .option('--sendit', 'Automatically commit with generated message')
80
+ .option('--interactive', 'Interactive mode for reviewing message')
81
+ .option('--amend', 'Amend the previous commit')
82
+ .option('--push [remote]', 'Push after committing (optionally specify remote)')
83
+ .option('--issue <number>', 'Reference issue number')
84
+ .option('--context <text>', 'Additional context for commit message')
85
+ .option('--context-files <files...>', 'Context files to include')
86
+ .action(async (options) => {
87
+ const config = await buildConfig('commit', options);
88
+ await executeCommand(COMMAND_COMMIT, config);
89
+ });
90
+
91
+ // Release command
92
+ program
93
+ .command('release')
94
+ .description('Generate release notes from git history')
95
+ .option('--from-tag <tag>', 'Start tag for release notes')
96
+ .option('--to-tag <tag>', 'End tag for release notes (default: HEAD)')
97
+ .option('--version <version>', 'Version number for release')
98
+ .option('--output <file>', 'Output file path')
99
+ .option('--interactive', 'Interactive mode for reviewing notes')
100
+ .option('--focus <text>', 'Focus area for release notes')
101
+ .option('--context <text>', 'Additional context for release notes')
102
+ .option('--context-files <files...>', 'Context files to include')
103
+ .action(async (options) => {
104
+ const config = await buildConfig('release', options);
105
+ await executeCommand(COMMAND_RELEASE, config);
106
+ });
107
+
108
+ program.parse();
109
+
110
+ // Return empty config if help or version was shown
111
+ return { commandName: '', config: KILDE_DEFAULTS };
112
+ }
113
+
114
+ /**
115
+ * Build final configuration from defaults, config file, and CLI arguments
116
+ */
117
+ async function buildConfig(commandName: string, cliOptions: any): Promise<Config> {
118
+ // Load config from file
119
+ const fileConfig = await getEffectiveConfig();
120
+
121
+ // Start with defaults
122
+ const config: Config = { ...KILDE_DEFAULTS };
123
+
124
+ // Merge file config
125
+ Object.assign(config, fileConfig);
126
+
127
+ // Merge CLI global options
128
+ if (cliOptions.parent.verbose !== undefined) config.verbose = cliOptions.parent.verbose;
129
+ if (cliOptions.parent.debug !== undefined) config.debug = cliOptions.parent.debug;
130
+ if (cliOptions.parent.dryRun !== undefined) config.dryRun = cliOptions.parent.dryRun;
131
+ if (cliOptions.parent.model) config.model = cliOptions.parent.model;
132
+ if (cliOptions.parent.reasoning) config.openaiReasoning = cliOptions.parent.reasoning;
133
+
134
+ // Merge command-specific options
135
+ if (commandName === 'commit') {
136
+ config.commit = config.commit || {};
137
+ if (cliOptions.add !== undefined) config.commit.add = cliOptions.add;
138
+ if (cliOptions.cached !== undefined) config.commit.cached = cliOptions.cached;
139
+ if (cliOptions.sendit !== undefined) config.commit.sendit = cliOptions.sendit;
140
+ if (cliOptions.interactive !== undefined) config.commit.interactive = cliOptions.interactive;
141
+ if (cliOptions.amend !== undefined) config.commit.amend = cliOptions.amend;
142
+ if (cliOptions.push !== undefined) config.commit.push = cliOptions.push;
143
+ if (cliOptions.context) config.commit.context = cliOptions.context;
144
+ if (cliOptions.contextFiles) config.commit.contextFiles = cliOptions.contextFiles;
145
+ } else if (commandName === 'release') {
146
+ config.release = config.release || {};
147
+ if (cliOptions.fromTag) config.release.from = cliOptions.fromTag;
148
+ if (cliOptions.toTag) config.release.to = cliOptions.toTag;
149
+ if (cliOptions.interactive !== undefined) config.release.interactive = cliOptions.interactive;
150
+ if (cliOptions.focus) config.release.focus = cliOptions.focus;
151
+ if (cliOptions.context) config.release.context = cliOptions.context;
152
+ if (cliOptions.contextFiles) config.release.contextFiles = cliOptions.contextFiles;
153
+ // Add non-standard fields as any
154
+ if (cliOptions.version) (config.release as any).version = cliOptions.version;
155
+ if (cliOptions.output) (config.release as any).output = cliOptions.output;
156
+ }
157
+
158
+ // Return config (no validation needed - already built from defaults)
159
+ return config as unknown as Config;
160
+ }
161
+
162
+ /**
163
+ * Execute the specified command
164
+ */
165
+ async function executeCommand(commandName: string, runConfig: Config): Promise<void> {
166
+ const logger = getLogger();
167
+
168
+ // Configure logging level
169
+ if (runConfig.verbose) {
170
+ setLogLevel('verbose');
171
+ }
172
+ if (runConfig.debug) {
173
+ setLogLevel('debug');
174
+ }
175
+
176
+ // Configure external packages to use our logger
177
+ setGitLogger(logger);
178
+
179
+ logger.info('APPLICATION_STARTING: Kilde application initializing | Version: %s | BuildHost: %s | BuildTime: %s | Status: starting',
180
+ VERSION, BUILD_HOSTNAME, BUILD_TIMESTAMP);
181
+
182
+ logger.debug(`Executing command: ${commandName}`);
183
+
184
+ let summary: string = '';
185
+
186
+ try {
187
+ if (commandName === COMMAND_COMMIT) {
188
+ summary = await CommandsGit.commit(runConfig);
189
+ } else if (commandName === COMMAND_RELEASE) {
190
+ const releaseSummary = await ReleaseCommand.execute(runConfig);
191
+ summary = `Release notes generated:\nTitle: ${releaseSummary.title}\n\n${releaseSummary.body}`;
192
+ } else {
193
+ throw new Error(`Unknown command: ${commandName}`);
194
+ }
195
+
196
+ if (summary) {
197
+ logger.info('COMMAND_COMPLETE: Command executed successfully | Status: success');
198
+ if (runConfig.verbose || runConfig.debug) {
199
+ logger.info('COMMAND_SUMMARY: %s', summary);
200
+ }
201
+ }
202
+
203
+ } catch (error: any) {
204
+ // Handle user cancellation gracefully
205
+ if (error.name === 'UserCancellationError' || error.message?.includes('cancelled')) {
206
+ logger.info('COMMAND_CANCELLED: Command cancelled by user | Status: cancelled');
207
+ return;
208
+ }
209
+
210
+ // Log and re-throw other errors
211
+ logger.error('COMMAND_FAILED: Command execution failed | Error: %s | Status: error', error.message);
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Configure early logging based on command line flags.
218
+ */
219
+ export function configureEarlyLogging(): void {
220
+ const hasVerbose = process.argv.includes('--verbose') || process.argv.includes('-v');
221
+ const hasDebug = process.argv.includes('--debug') || process.argv.includes('-d');
222
+
223
+ // Set log level based on early flag detection
224
+ if (hasDebug) {
225
+ setLogLevel('debug');
226
+ } else if (hasVerbose) {
227
+ setLogLevel('verbose');
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Main application entry point
233
+ */
234
+ export async function runApplication(): Promise<void> {
235
+ // Check Node.js version first
236
+ checkNodeVersion();
237
+
238
+ // Configure logging early
239
+ configureEarlyLogging();
240
+
241
+ // Initialize RiotPrompt templates for ai-service
242
+ initializeTemplates();
243
+
244
+ // Parse arguments and execute command
245
+ await parseArguments();
246
+ }
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pure Git-Only Release Notes Generator
4
+ *
5
+ * This is a simplified version of the release command that works with pure git
6
+ * operations only - no GitHub API dependencies, making it work with any git host
7
+ * (GitHub, GitLab, Bitbucket, self-hosted, etc.)
8
+ */
9
+ import 'dotenv/config';
10
+ import type { ChatCompletionMessageParam } from 'openai/resources';
11
+ import { getDefaultFromRef, getCurrentBranch } from '@grunnverk/git-tools';
12
+ import { Formatter, Model } from '@riotprompt/riotprompt';
13
+ import {
14
+ Config,
15
+ Log,
16
+ Diff,
17
+ DEFAULT_EXCLUDED_PATTERNS,
18
+ DEFAULT_TO_COMMIT_ALIAS,
19
+ DEFAULT_OUTPUT_DIRECTORY,
20
+ DEFAULT_MAX_DIFF_BYTES,
21
+ improveContentWithLLM,
22
+ toAIConfig,
23
+ createStorageAdapter,
24
+ createLoggerAdapter,
25
+ getDryRunLogger,
26
+ getOutputPath,
27
+ getTimestampedRequestFilename,
28
+ getTimestampedResponseFilename,
29
+ getTimestampedReleaseNotesFilename,
30
+ validateReleaseSummary,
31
+ ReleaseSummary,
32
+ filterContent,
33
+ type LLMImprovementConfig,
34
+ } from '@grunnverk/core';
35
+ import {
36
+ createCompletionWithRetry,
37
+ getUserChoice,
38
+ editContentInEditor,
39
+ getLLMFeedbackInEditor,
40
+ requireTTY,
41
+ STANDARD_CHOICES,
42
+ ReleaseContext,
43
+ runAgenticRelease,
44
+ generateReflectionReport,
45
+ createReleasePrompt,
46
+ } from '@grunnverk/ai-service';
47
+ import { createStorage } from '@grunnverk/shared';
48
+
49
+ // Helper function to read context files
50
+ async function readContextFiles(contextFiles: string[] | undefined, logger: any): Promise<string> {
51
+ if (!contextFiles || contextFiles.length === 0) {
52
+ return '';
53
+ }
54
+
55
+ const storage = createStorage();
56
+ const contextParts: string[] = [];
57
+
58
+ for (const filePath of contextFiles) {
59
+ try {
60
+ const content = await storage.readFile(filePath, 'utf8');
61
+ contextParts.push(`## Context from ${filePath}\n\n${content}\n`);
62
+ logger.debug(`Read context from file: ${filePath}`);
63
+ } catch (error: any) {
64
+ logger.warn(`Failed to read context file ${filePath}: ${error.message}`);
65
+ }
66
+ }
67
+
68
+ return contextParts.join('\n---\n\n');
69
+ }
70
+
71
+ // Helper function to edit release notes using editor
72
+ async function editReleaseNotesInteractively(releaseSummary: ReleaseSummary): Promise<ReleaseSummary> {
73
+ const templateLines = [
74
+ '# Edit your release notes below. Lines starting with "#" will be ignored.',
75
+ '# The first line is the title, everything else is the body.',
76
+ '# Save and close the editor when you are done.'
77
+ ];
78
+
79
+ const content = `${releaseSummary.title}\n\n${releaseSummary.body}`;
80
+ const result = await editContentInEditor(content, templateLines, '.md');
81
+
82
+ const lines = result.content.split('\n');
83
+ const title = lines[0].trim();
84
+ const body = lines.slice(1).join('\n').trim();
85
+
86
+ return { title, body };
87
+ }
88
+
89
+ // Helper function to improve release notes using LLM
90
+ async function improveReleaseNotesWithLLM(
91
+ releaseSummary: ReleaseSummary,
92
+ runConfig: Config,
93
+ promptConfig: any,
94
+ promptContext: any,
95
+ outputDirectory: string,
96
+ logContent: string,
97
+ diffContent: string
98
+ ): Promise<ReleaseSummary> {
99
+ // Get user feedback on what to improve using the editor
100
+ const releaseNotesContent = `${releaseSummary.title}\n\n${releaseSummary.body}`;
101
+ const userFeedback = await getLLMFeedbackInEditor('release notes', releaseNotesContent);
102
+
103
+ const improvementConfig: LLMImprovementConfig = {
104
+ contentType: 'release notes',
105
+ createImprovedPrompt: async (promptConfig, currentSummary, promptContext) => {
106
+ const improvementPromptContent = {
107
+ logContent: logContent,
108
+ diffContent: diffContent,
109
+ releaseFocus: `Please improve these release notes based on the user's feedback: "${userFeedback}".
110
+
111
+ Current release notes:
112
+ Title: "${currentSummary.title}"
113
+ Body: "${currentSummary.body}"
114
+
115
+ Please revise the release notes according to the user's feedback while maintaining accuracy and following good release note practices.`,
116
+ };
117
+ const promptResult = await createReleasePrompt(promptConfig, improvementPromptContent, promptContext);
118
+ // Format the prompt into a proper request with messages
119
+ const aiConfig = toAIConfig(runConfig);
120
+ const modelToUse = aiConfig.commands?.release?.model || aiConfig.model || 'gpt-4o-mini';
121
+ return Formatter.create({ logger: getDryRunLogger(false) }).formatPrompt(modelToUse as Model, promptResult.prompt);
122
+ },
123
+ callLLM: async (request, runConfig, outputDirectory) => {
124
+ const aiConfig = toAIConfig(runConfig);
125
+ const aiStorageAdapter = createStorageAdapter(outputDirectory);
126
+ const aiLogger = createLoggerAdapter(false);
127
+ const modelToUse = aiConfig.commands?.release?.model || aiConfig.model || 'gpt-4o-mini';
128
+ const openaiReasoning = aiConfig.commands?.release?.reasoning || aiConfig.reasoning;
129
+ return await createCompletionWithRetry(
130
+ request.messages as ChatCompletionMessageParam[],
131
+ {
132
+ model: modelToUse,
133
+ openaiReasoning,
134
+ responseFormat: { type: 'json_object' },
135
+ debug: runConfig.debug,
136
+ debugRequestFile: getOutputPath(outputDirectory, getTimestampedRequestFilename('release-improve')),
137
+ debugResponseFile: getOutputPath(outputDirectory, getTimestampedResponseFilename('release-improve')),
138
+ storage: aiStorageAdapter,
139
+ logger: aiLogger,
140
+ }
141
+ );
142
+ },
143
+ processResponse: (response: any) => {
144
+ return validateReleaseSummary(response);
145
+ }
146
+ };
147
+
148
+ return await improveContentWithLLM(
149
+ releaseSummary,
150
+ runConfig,
151
+ promptConfig,
152
+ promptContext,
153
+ outputDirectory,
154
+ improvementConfig
155
+ );
156
+ }
157
+
158
+ // Helper function to generate self-reflection output for release notes using observability module
159
+ async function generateSelfReflection(
160
+ agenticResult: any,
161
+ outputDirectory: string,
162
+ storage: any,
163
+ logger: any
164
+ ): Promise<void> {
165
+ try {
166
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0];
167
+ const reflectionPath = getOutputPath(outputDirectory, `agentic-reflection-release-${timestamp}.md`);
168
+
169
+ // Use new observability reflection generator
170
+ const report = await generateReflectionReport({
171
+ iterations: agenticResult.iterations || 0,
172
+ toolCallsExecuted: agenticResult.toolCallsExecuted || 0,
173
+ maxIterations: agenticResult.maxIterations || 30,
174
+ toolMetrics: agenticResult.toolMetrics || [],
175
+ conversationHistory: agenticResult.conversationHistory || [],
176
+ releaseNotes: agenticResult.releaseNotes,
177
+ logger
178
+ });
179
+
180
+ // Save the report to output directory
181
+ await storage.writeFile(reflectionPath, report, 'utf8');
182
+
183
+ logger.info('');
184
+ logger.info('═'.repeat(80));
185
+ logger.info('📊 SELF-REFLECTION REPORT GENERATED');
186
+ logger.info('═'.repeat(80));
187
+ logger.info('');
188
+ logger.info('📁 Location: %s', reflectionPath);
189
+ logger.info('');
190
+ logger.info('📈 Report Summary:');
191
+ const iterations = agenticResult.iterations || 0;
192
+ const toolCalls = agenticResult.toolCallsExecuted || 0;
193
+ const uniqueTools = new Set((agenticResult.toolMetrics || []).map((m: any) => m.name)).size;
194
+ logger.info(` • ${iterations} iterations completed`);
195
+ logger.info(` • ${toolCalls} tool calls executed`);
196
+ logger.info(` • ${uniqueTools} unique tools used`);
197
+ logger.info('');
198
+ logger.info('💡 Use this report to:');
199
+ logger.info(' • Understand which tools were most effective');
200
+ logger.info(' • Identify performance bottlenecks');
201
+ logger.info(' • Optimize tool selection and usage patterns');
202
+ logger.info(' • Improve agentic release notes generation');
203
+ logger.info('');
204
+ logger.info('═'.repeat(80));
205
+ } catch (error: any) {
206
+ logger.warn('Failed to generate self-reflection report: %s', error.message);
207
+ }
208
+ }
209
+
210
+ // Interactive feedback loop for release notes
211
+ async function handleInteractiveReleaseFeedback(
212
+ releaseSummary: ReleaseSummary,
213
+ runConfig: Config,
214
+ promptConfig: any,
215
+ promptContext: any,
216
+ outputDirectory: string,
217
+ storage: any,
218
+ logContent: string,
219
+ diffContent: string
220
+ ): Promise<{ action: 'confirm' | 'skip', finalSummary: ReleaseSummary }> {
221
+ const logger = getDryRunLogger(false);
222
+ let currentSummary = releaseSummary;
223
+
224
+ while (true) {
225
+ // Display the current release notes
226
+ logger.info('\nRELEASE_NOTES_GENERATED: Generated release notes from AI | Title Length: ' + currentSummary.title.length + ' | Body Length: ' + currentSummary.body.length);
227
+ logger.info('─'.repeat(50));
228
+ logger.info('RELEASE_NOTES_TITLE: %s', currentSummary.title);
229
+ logger.info('');
230
+ logger.info('RELEASE_NOTES_BODY: Release notes content:');
231
+ logger.info(currentSummary.body);
232
+ logger.info('─'.repeat(50));
233
+
234
+ // Get user choice
235
+ const userChoice = await getUserChoice(
236
+ '\nWhat would you like to do with these release notes?',
237
+ [
238
+ STANDARD_CHOICES.CONFIRM,
239
+ STANDARD_CHOICES.EDIT,
240
+ STANDARD_CHOICES.SKIP,
241
+ STANDARD_CHOICES.IMPROVE
242
+ ],
243
+ {
244
+ nonTtyErrorSuggestions: ['Use --dry-run to see the generated content without interaction']
245
+ }
246
+ );
247
+
248
+ switch (userChoice) {
249
+ case 'c':
250
+ return { action: 'confirm', finalSummary: currentSummary };
251
+
252
+ case 'e':
253
+ try {
254
+ currentSummary = await editReleaseNotesInteractively(currentSummary);
255
+ } catch (error: any) {
256
+ logger.error(`RELEASE_NOTES_EDIT_FAILED: Unable to edit release notes | Error: ${error.message} | Impact: Using original notes`);
257
+ // Continue the loop to show options again
258
+ }
259
+ break;
260
+
261
+ case 's':
262
+ return { action: 'skip', finalSummary: currentSummary };
263
+
264
+ case 'i':
265
+ try {
266
+ currentSummary = await improveReleaseNotesWithLLM(
267
+ currentSummary,
268
+ runConfig,
269
+ promptConfig,
270
+ promptContext,
271
+ outputDirectory,
272
+ logContent,
273
+ diffContent
274
+ );
275
+ } catch (error: any) {
276
+ logger.error(`RELEASE_NOTES_IMPROVE_FAILED: Unable to improve release notes | Error: ${error.message} | Impact: Using current version`);
277
+ // Continue the loop to show options again
278
+ }
279
+ break;
280
+
281
+ default:
282
+ // This shouldn't happen, but continue the loop
283
+ break;
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Execute release notes generation (pure git version)
290
+ *
291
+ * This implementation uses ONLY git operations - no GitHub API calls.
292
+ * Works with any git repository regardless of host or language.
293
+ */
294
+ export const execute = async (runConfig: Config): Promise<ReleaseSummary> => {
295
+ const isDryRun = runConfig.dryRun || false;
296
+ const logger = getDryRunLogger(isDryRun);
297
+
298
+ // Get current branch to help determine best tag comparison
299
+ const currentBranch = await getCurrentBranch();
300
+
301
+ // Resolve the from reference with fallback logic if not explicitly provided
302
+ const fromRef = runConfig.release?.from ?? await getDefaultFromRef(false, currentBranch);
303
+ const toRef = runConfig.release?.to ?? DEFAULT_TO_COMMIT_ALIAS;
304
+
305
+ logger.debug(`Using git references: from=${fromRef}, to=${toRef}`);
306
+
307
+ const log = await Log.create({
308
+ from: fromRef,
309
+ to: toRef,
310
+ limit: runConfig.release?.messageLimit
311
+ });
312
+ let logContent = '';
313
+
314
+ const maxDiffBytes = runConfig.release?.maxDiffBytes ?? DEFAULT_MAX_DIFF_BYTES;
315
+ const diff = await Diff.create({
316
+ from: fromRef,
317
+ to: toRef,
318
+ excludedPatterns: runConfig.excludedPatterns ?? DEFAULT_EXCLUDED_PATTERNS,
319
+ maxDiffBytes
320
+ });
321
+ let diffContent = '';
322
+
323
+ diffContent = await diff.get();
324
+ logContent = await log.get();
325
+
326
+ const promptConfig = {
327
+ overridePaths: runConfig.discoveredConfigDirs || [],
328
+ overrides: runConfig.overrides || false,
329
+ };
330
+
331
+ // Always ensure output directory exists for request/response files
332
+ const outputDirectory = runConfig.outputDirectory || DEFAULT_OUTPUT_DIRECTORY;
333
+ const storage = createStorage();
334
+ await storage.ensureDirectory(outputDirectory);
335
+
336
+ // Create adapters for ai-service
337
+ const aiConfig = toAIConfig(runConfig);
338
+ const aiStorageAdapter = createStorageAdapter(outputDirectory);
339
+ const aiLogger = createLoggerAdapter(isDryRun);
340
+
341
+ // Read context from files if provided
342
+ const contextFromFiles = await readContextFiles(runConfig.release?.contextFiles, logger);
343
+
344
+ // Combine file context with existing context
345
+ const combinedContext = [
346
+ runConfig.release?.context,
347
+ contextFromFiles
348
+ ].filter(Boolean).join('\n\n---\n\n');
349
+
350
+ // Run agentic release notes generation
351
+ // NOTE: No milestone/GitHub integration - pure git only
352
+ const agenticResult = await runAgenticRelease({
353
+ fromRef,
354
+ toRef,
355
+ logContent,
356
+ diffContent,
357
+ milestoneIssues: '', // Empty - no GitHub API integration
358
+ releaseFocus: runConfig.release?.focus,
359
+ userContext: combinedContext || undefined,
360
+ targetVersion: (runConfig.release as any)?.version,
361
+ model: aiConfig.commands?.release?.model || aiConfig.model || 'gpt-4o',
362
+ maxIterations: runConfig.release?.maxAgenticIterations || 30,
363
+ debug: runConfig.debug,
364
+ debugRequestFile: getOutputPath(outputDirectory, getTimestampedRequestFilename('release')),
365
+ debugResponseFile: getOutputPath(outputDirectory, getTimestampedResponseFilename('release')),
366
+ storage: aiStorageAdapter,
367
+ logger: aiLogger,
368
+ openaiReasoning: aiConfig.commands?.release?.reasoning || aiConfig.reasoning,
369
+ });
370
+
371
+ const iterations = agenticResult.iterations || 0;
372
+ const toolCalls = agenticResult.toolCallsExecuted || 0;
373
+ logger.info(`🔍 Analysis complete: ${iterations} iterations, ${toolCalls} tool calls`);
374
+
375
+ // Generate self-reflection output if enabled
376
+ if (runConfig.release?.selfReflection) {
377
+ await generateSelfReflection(agenticResult, outputDirectory, storage, logger);
378
+ }
379
+
380
+ // Apply stop-context filtering to release notes
381
+ const titleFilterResult = filterContent(agenticResult.releaseNotes.title, runConfig.stopContext);
382
+ const bodyFilterResult = filterContent(agenticResult.releaseNotes.body, runConfig.stopContext);
383
+ let releaseSummary: ReleaseSummary = {
384
+ title: titleFilterResult.filtered,
385
+ body: bodyFilterResult.filtered,
386
+ };
387
+
388
+ // Handle interactive mode
389
+ if (runConfig.release?.interactive && !isDryRun) {
390
+ requireTTY('Interactive mode requires a terminal. Use --dry-run instead.');
391
+
392
+ const interactivePromptContext: ReleaseContext = {
393
+ context: combinedContext || undefined,
394
+ directories: runConfig.contextDirectories,
395
+ };
396
+
397
+ const interactiveResult = await handleInteractiveReleaseFeedback(
398
+ releaseSummary,
399
+ runConfig,
400
+ promptConfig,
401
+ interactivePromptContext,
402
+ outputDirectory,
403
+ storage,
404
+ logContent,
405
+ diffContent
406
+ );
407
+
408
+ if (interactiveResult.action === 'skip') {
409
+ logger.info('RELEASE_ABORTED: Release notes generation aborted by user | Reason: User choice | Status: cancelled');
410
+ } else {
411
+ logger.info('RELEASE_FINALIZED: Release notes finalized and accepted | Status: ready | Next: Create release or save');
412
+ }
413
+
414
+ releaseSummary = interactiveResult.finalSummary;
415
+ }
416
+
417
+ // Save timestamped copy of release notes to output directory
418
+ try {
419
+ const timestampedFilename = getTimestampedReleaseNotesFilename();
420
+ const outputPath = getOutputPath(outputDirectory, timestampedFilename);
421
+
422
+ // Format the release notes as markdown
423
+ const releaseNotesContent = `# ${releaseSummary.title}\n\n${releaseSummary.body}`;
424
+
425
+ await storage.writeFile(outputPath, releaseNotesContent, 'utf-8');
426
+ logger.debug('Saved timestamped release notes: %s', outputPath);
427
+ } catch (error: any) {
428
+ logger.warn('RELEASE_SAVE_FAILED: Failed to save timestamped release notes | Error: %s | Impact: Notes not persisted to file', error.message);
429
+ }
430
+
431
+ // Save to specified output file if provided
432
+ const outputFile = (runConfig.release as any)?.output;
433
+ if (outputFile) {
434
+ try {
435
+ const releaseNotesContent = `# ${releaseSummary.title}\n\n${releaseSummary.body}`;
436
+ await storage.writeFile(outputFile, releaseNotesContent, 'utf-8');
437
+ logger.info(`Saved release notes to: ${outputFile}`);
438
+ } catch (error: any) {
439
+ logger.error(`Failed to save release notes to ${outputFile}: ${error.message}`);
440
+ }
441
+ }
442
+
443
+ if (isDryRun) {
444
+ logger.info('RELEASE_SUMMARY_COMPLETE: Generated release summary successfully | Status: completed');
445
+ logger.info('RELEASE_SUMMARY_TITLE: %s', releaseSummary.title);
446
+ logger.info('RELEASE_SUMMARY_BODY: %s', releaseSummary.body);
447
+ }
448
+
449
+ return releaseSummary;
450
+ }