geeto 0.3.6 → 0.4.1

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 (44) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +104 -107
  3. package/lib/api/copilot-sdk.d.ts +5 -0
  4. package/lib/api/copilot-sdk.d.ts.map +1 -1
  5. package/lib/api/copilot-sdk.js +76 -0
  6. package/lib/api/copilot-sdk.js.map +1 -1
  7. package/lib/api/copilot.d.ts +2 -0
  8. package/lib/api/copilot.d.ts.map +1 -1
  9. package/lib/api/copilot.js +28 -1
  10. package/lib/api/copilot.js.map +1 -1
  11. package/lib/api/gemini-sdk.d.ts +5 -0
  12. package/lib/api/gemini-sdk.d.ts.map +1 -1
  13. package/lib/api/gemini-sdk.js +77 -0
  14. package/lib/api/gemini-sdk.js.map +1 -1
  15. package/lib/api/gemini.d.ts +2 -0
  16. package/lib/api/gemini.d.ts.map +1 -1
  17. package/lib/api/gemini.js +28 -1
  18. package/lib/api/gemini.js.map +1 -1
  19. package/lib/api/openrouter-sdk.d.ts +5 -0
  20. package/lib/api/openrouter-sdk.d.ts.map +1 -1
  21. package/lib/api/openrouter-sdk.js +83 -0
  22. package/lib/api/openrouter-sdk.js.map +1 -1
  23. package/lib/api/openrouter.d.ts +2 -0
  24. package/lib/api/openrouter.d.ts.map +1 -1
  25. package/lib/api/openrouter.js +28 -1
  26. package/lib/api/openrouter.js.map +1 -1
  27. package/lib/index.js +20 -6
  28. package/lib/index.js.map +1 -1
  29. package/lib/utils/git-ai.d.ts +3 -0
  30. package/lib/utils/git-ai.d.ts.map +1 -1
  31. package/lib/utils/git-ai.js +33 -0
  32. package/lib/utils/git-ai.js.map +1 -1
  33. package/lib/version.d.ts +3 -0
  34. package/lib/version.d.ts.map +1 -0
  35. package/lib/version.js +3 -0
  36. package/lib/version.js.map +1 -0
  37. package/lib/workflows/release.d.ts.map +1 -1
  38. package/lib/workflows/release.js +765 -27
  39. package/lib/workflows/release.js.map +1 -1
  40. package/lib/workflows/repo-settings.d.ts +9 -0
  41. package/lib/workflows/repo-settings.d.ts.map +1 -0
  42. package/lib/workflows/repo-settings.js +425 -0
  43. package/lib/workflows/repo-settings.js.map +1 -0
  44. package/package.json +16 -5
@@ -4,12 +4,41 @@
4
4
  * RELEASE.MD (user-friendly) and CHANGELOG.md (developer-facing)
5
5
  */
6
6
  import { readFileSync, writeFileSync } from 'node:fs';
7
- import { askQuestion, confirm, ProgressBar } from '../cli/input.js';
7
+ import { askQuestion, confirm, editInline, ProgressBar } from '../cli/input.js';
8
8
  import { select } from '../cli/menu.js';
9
9
  import { colors } from '../utils/colors.js';
10
10
  import { exec, execAsync, execSilent } from '../utils/exec.js';
11
+ import { chooseModelForProvider, generateReleaseNotesWithProvider, getAIProviderShortName, getModelValue, } from '../utils/git-ai.js';
11
12
  import { log } from '../utils/logging.js';
13
+ import { loadState } from '../utils/state.js';
12
14
  // ─── Helpers ───
15
+ /**
16
+ * Normalize markdown spacing for consistent markdownlint-friendly output.
17
+ * Ensures: one blank line after ### and #### headings, one blank line between sections,
18
+ * no double blank lines, trailing newline.
19
+ */
20
+ const normalizeReleaseMarkdown = (md) => {
21
+ const lines = md.split('\n');
22
+ const result = [];
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const line = lines[i] ?? '';
25
+ const nextLine = lines[i + 1] ?? '';
26
+ result.push(line);
27
+ // After a heading (### or ####), ensure exactly one blank line before content
28
+ if ((line.startsWith('###') || line.startsWith('####')) && nextLine.trim() !== '') {
29
+ result.push('');
30
+ }
31
+ // After a bullet line, if next line is a heading, ensure blank line
32
+ if (line.startsWith('-') && (nextLine.startsWith('###') || nextLine.startsWith('####'))) {
33
+ result.push('');
34
+ }
35
+ }
36
+ // Collapse multiple blank lines into one
37
+ return result
38
+ .join('\n')
39
+ .replaceAll(/\n{3,}/g, '\n\n')
40
+ .trim();
41
+ };
13
42
  const parseSemver = (version) => {
14
43
  const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
15
44
  if (!match)
@@ -31,7 +60,9 @@ const getCurrentVersion = () => {
31
60
  };
32
61
  const getExistingTags = () => {
33
62
  try {
34
- const output = execSilent('git tag --list --sort=-v:refname').trim();
63
+ // Sort by creation date (newest first), NOT version number.
64
+ // Version sort breaks when older dummy/test tags have higher semver (e.g. v2.0.0 before v0.3.x).
65
+ const output = execSilent('git tag --list --sort=-creatordate').trim();
35
66
  return output ? output.split('\n').filter(Boolean) : [];
36
67
  }
37
68
  catch {
@@ -102,6 +133,14 @@ const updatePackageVersion = (newVersion) => {
102
133
  const pkg = JSON.parse(content);
103
134
  pkg.version = newVersion;
104
135
  writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n', 'utf8');
136
+ // Also update the compiled-binary-safe version constant
137
+ try {
138
+ const versionTs = readFileSync('src/version.ts', 'utf8');
139
+ writeFileSync('src/version.ts', versionTs.replace(/VERSION = '[^']*'/, `VERSION = '${newVersion}'`), 'utf8');
140
+ }
141
+ catch {
142
+ /* version.ts update is best-effort */
143
+ }
105
144
  };
106
145
  // ─── stripConventional helpers ───
107
146
  const stripFeatPrefix = (s) => {
@@ -137,24 +176,24 @@ const generateReleaseMd = (version, commits, prevVersion) => {
137
176
  day: 'numeric',
138
177
  });
139
178
  const cat = categorizeCommits(commits);
179
+ // Each version is a ## section so multiple versions stack in a single file
140
180
  const header = [
141
- `# Release v${version}`,
181
+ `## v${version} — ${date}`,
142
182
  '',
143
- `**Release date:** ${date}`,
144
- `**Previous version:** v${prevVersion}`,
183
+ `> Previous version: v${prevVersion}`,
145
184
  '',
146
- "## What's New?",
185
+ "### What's New?",
147
186
  '',
148
187
  ];
149
188
  const featureSection = cat.features.length > 0
150
- ? ['### New Features', '', ...cat.features.map((f) => `- ${stripFeatPrefix(f.subject)}`), '']
189
+ ? ['#### New Features', '', ...cat.features.map((f) => `- ${stripFeatPrefix(f.subject)}`), '']
151
190
  : [];
152
191
  const fixSection = cat.fixes.length > 0
153
- ? ['### Bug Fixes', '', ...cat.fixes.map((f) => `- ${stripFixPrefix(f.subject)}`), '']
192
+ ? ['#### Bug Fixes', '', ...cat.fixes.map((f) => `- ${stripFixPrefix(f.subject)}`), '']
154
193
  : [];
155
194
  const breakingSection = cat.breaking.length > 0
156
195
  ? [
157
- '### Important Changes',
196
+ '#### Important Changes',
158
197
  '',
159
198
  '> Note: Some changes in this version may require adjustments.',
160
199
  '',
@@ -164,19 +203,13 @@ const generateReleaseMd = (version, commits, prevVersion) => {
164
203
  : [];
165
204
  const otherSection = cat.other.length > 0
166
205
  ? [
167
- '### Other Improvements',
206
+ '#### Other Improvements',
168
207
  '',
169
208
  ...cat.other.map((o) => `- ${stripConventionalPrefix(o.subject)}`),
170
209
  '',
171
210
  ]
172
211
  : [];
173
212
  const empty = commits.length === 0 ? ['No significant changes in this version.', ''] : [];
174
- const footer = [
175
- '---',
176
- '',
177
- '*This document was automatically generated by [Geeto CLI](https://github.com/rust142/geeto)*',
178
- '',
179
- ];
180
213
  return [
181
214
  ...header,
182
215
  ...featureSection,
@@ -184,7 +217,8 @@ const generateReleaseMd = (version, commits, prevVersion) => {
184
217
  ...breakingSection,
185
218
  ...otherSection,
186
219
  ...empty,
187
- ...footer,
220
+ '---',
221
+ '',
188
222
  ].join('\n');
189
223
  };
190
224
  // ─── CHANGELOG.md generator (developer-facing, per-commit) ───
@@ -216,10 +250,448 @@ const generateChangelogEntry = (version, commits, prevVersion) => {
216
250
  : [];
217
251
  return [...header, ...breakingSection, ...featureSection, ...fixSection, ...otherSection].join('\n');
218
252
  };
253
+ // ─── Sync GitHub Releases for existing tags ───
254
+ const getExistingGithubReleases = () => {
255
+ try {
256
+ const output = execSilent('gh release list --limit 100 --json tagName --jq ".[].tagName"').trim();
257
+ return output ? output.split('\n').filter(Boolean) : [];
258
+ }
259
+ catch {
260
+ return [];
261
+ }
262
+ };
263
+ const handleSyncReleases = async () => {
264
+ const line = '─'.repeat(56);
265
+ // Check if gh CLI is available
266
+ try {
267
+ execSilent('gh --version');
268
+ }
269
+ catch {
270
+ log.error('GitHub CLI (gh) is not installed. Install it: https://cli.github.com');
271
+ return;
272
+ }
273
+ console.log('');
274
+ const spinner = log.spinner();
275
+ spinner.start('Checking GitHub releases...');
276
+ const localTags = getExistingTags();
277
+ const ghReleases = getExistingGithubReleases();
278
+ const missingTags = localTags.filter((t) => !ghReleases.includes(t));
279
+ spinner.succeed(`Found ${localTags.length} tags, ${ghReleases.length} GitHub releases`);
280
+ if (missingTags.length === 0) {
281
+ console.log('');
282
+ log.success('All tags have GitHub Releases! Nothing to sync.');
283
+ return;
284
+ }
285
+ console.log('');
286
+ log.info(`${colors.bright}${missingTags.length}${colors.reset} tags missing GitHub Releases:`);
287
+ for (const tag of missingTags) {
288
+ console.log(` ${colors.yellow}${tag}${colors.reset}`);
289
+ }
290
+ console.log('');
291
+ const action = await select('What do you want to do?', [
292
+ { label: 'Create releases for all missing tags', value: 'all' },
293
+ { label: 'Select which tags to release', value: 'select' },
294
+ { label: 'Cancel', value: 'cancel' },
295
+ ]);
296
+ if (action === 'cancel')
297
+ return;
298
+ let tagsToRelease = missingTags;
299
+ if (action === 'select') {
300
+ const { multiSelect } = await import('../cli/menu.js');
301
+ const choices = missingTags.map((t) => ({ label: t, value: t }));
302
+ const selected = await multiSelect('Select tags to create releases for:', choices);
303
+ if (selected.length === 0) {
304
+ log.info('No tags selected.');
305
+ return;
306
+ }
307
+ tagsToRelease = selected;
308
+ }
309
+ // Choose release notes mode: AI or template
310
+ console.log('');
311
+ const notesMode = await select('How should release notes be generated?', [
312
+ { label: 'AI-generated (recommended)', value: 'ai' },
313
+ { label: 'Auto-generate (template-based)', value: 'auto' },
314
+ ]);
315
+ // AI setup if needed
316
+ let useAI = notesMode === 'ai';
317
+ let language = 'en';
318
+ let aiProvider = 'copilot';
319
+ let copilotModel;
320
+ let openrouterModel;
321
+ let geminiModel;
322
+ if (useAI) {
323
+ language = (await select('Release notes language:', [
324
+ { label: 'English', value: 'en' },
325
+ { label: 'Indonesian (Bahasa Indonesia)', value: 'id' },
326
+ ]));
327
+ // Check saved config
328
+ const savedState = loadState();
329
+ if (savedState?.aiProvider &&
330
+ savedState.aiProvider !== 'manual' &&
331
+ (savedState.copilotModel || savedState.openrouterModel || savedState.geminiModel)) {
332
+ aiProvider = savedState.aiProvider;
333
+ copilotModel = savedState.copilotModel;
334
+ openrouterModel = savedState.openrouterModel;
335
+ geminiModel = savedState.geminiModel;
336
+ }
337
+ else {
338
+ let providerChosen = false;
339
+ while (!providerChosen) {
340
+ aiProvider = (await select('Choose AI Provider:', [
341
+ { label: 'GitHub Copilot (Recommended)', value: 'copilot' },
342
+ { label: 'Gemini', value: 'gemini' },
343
+ { label: 'OpenRouter', value: 'openrouter' },
344
+ ]));
345
+ const chosen = await chooseModelForProvider(aiProvider, undefined, 'Back to AI provider menu');
346
+ if (!chosen || chosen === 'back')
347
+ continue;
348
+ switch (aiProvider) {
349
+ case 'gemini': {
350
+ geminiModel = chosen;
351
+ break;
352
+ }
353
+ case 'copilot': {
354
+ copilotModel = chosen;
355
+ break;
356
+ }
357
+ case 'openrouter': {
358
+ openrouterModel = chosen;
359
+ break;
360
+ }
361
+ }
362
+ providerChosen = true;
363
+ }
364
+ }
365
+ }
366
+ // Preview and confirm
367
+ console.log('');
368
+ console.log(`${colors.cyan}┌${line}┐${colors.reset}`);
369
+ console.log(`${colors.cyan}│${colors.reset} ${colors.bright}Sync Plan${colors.reset}`);
370
+ console.log(`${colors.cyan}├${line}┤${colors.reset}`);
371
+ for (const tag of tagsToRelease) {
372
+ const ver = tag.replace(/^v/, '');
373
+ console.log(`${colors.cyan}│${colors.reset} ${colors.green}+${colors.reset} Create release for ${colors.yellow}${ver}${colors.reset}`);
374
+ }
375
+ console.log(`${colors.cyan}│${colors.reset} ${colors.gray}Mode: ${useAI ? 'AI-generated' : 'Template-based'}${colors.reset}`);
376
+ console.log(`${colors.cyan}└${line}┘${colors.reset}`);
377
+ console.log('');
378
+ const proceed = confirm(`Create ${tagsToRelease.length} GitHub Releases?`);
379
+ if (!proceed)
380
+ return;
381
+ // Create releases one by one
382
+ const allTags = getExistingTags();
383
+ let successCount = 0;
384
+ for (const tag of tagsToRelease) {
385
+ const ver = tag.replace(/^v/, '');
386
+ const tagIdx = allTags.indexOf(tag);
387
+ const prevTag = allTags[tagIdx + 1];
388
+ const commits = getCommitsSinceTag(prevTag);
389
+ const commitList = commits.map((c) => c.subject).join('\n');
390
+ let releaseBody;
391
+ if (useAI && commits.length > 0) {
392
+ console.log('');
393
+ const aiSpinner = log.spinner();
394
+ const modelDisplay = getModelValue(copilotModel ?? openrouterModel ?? geminiModel ?? '');
395
+ aiSpinner.start(`Generating notes for ${tag} with ${getAIProviderShortName(aiProvider)}` +
396
+ (modelDisplay ? ` (${modelDisplay})` : '') +
397
+ '...');
398
+ const aiResult = await generateReleaseNotesWithProvider(aiProvider, commitList, language, undefined, copilotModel, openrouterModel, geminiModel);
399
+ if (aiResult) {
400
+ aiSpinner.succeed(`Notes generated for ${tag}`);
401
+ releaseBody = normalizeReleaseMarkdown(aiResult);
402
+ // Preview notes for user review
403
+ console.log('');
404
+ console.log(`${colors.cyan}┌${'─'.repeat(56)}┐${colors.reset}`);
405
+ console.log(`${colors.cyan}│${colors.reset} ${colors.bright}Release Notes — ${tag}${colors.reset}`);
406
+ console.log(`${colors.cyan}├${'─'.repeat(56)}┤${colors.reset}`);
407
+ for (const noteLine of releaseBody.split('\n')) {
408
+ console.log(`${colors.cyan}│${colors.reset} ${noteLine}`);
409
+ }
410
+ console.log(`${colors.cyan}└${'─'.repeat(56)}┘${colors.reset}`);
411
+ console.log('');
412
+ const reviewAction = await select(`Publish release for ${tag}?`, [
413
+ { label: 'Yes, publish', value: 'accept' },
414
+ { label: 'Skip this tag', value: 'skip' },
415
+ ]);
416
+ if (reviewAction === 'skip')
417
+ continue;
418
+ }
419
+ else {
420
+ aiSpinner.fail(`AI failed for ${tag}, using template`);
421
+ useAI = false;
422
+ releaseBody = generateReleaseMd(ver, commits, prevTag?.replace(/^v/, '') ?? '0.0.0')
423
+ .replace(/^## .*\n+/, '')
424
+ .replace(/\n---\n*$/, '')
425
+ .trim();
426
+ }
427
+ }
428
+ else {
429
+ releaseBody = generateReleaseMd(ver, commits, prevTag?.replace(/^v/, '') ?? '0.0.0')
430
+ .replace(/^## .*\n+/, '')
431
+ .replace(/\n---\n*$/, '')
432
+ .trim();
433
+ }
434
+ console.log('');
435
+ const releaseSpinner = log.spinner();
436
+ // Ensure tag exists on remote before creating GitHub Release
437
+ releaseSpinner.start(`Pushing tag ${colors.yellow}${tag}${colors.reset} to remote...`);
438
+ try {
439
+ await execAsync(`git push origin ${tag} --no-verify`, true);
440
+ releaseSpinner.succeed(`Tag ${tag} pushed to remote`);
441
+ }
442
+ catch {
443
+ // Tag might already exist on remote — that's fine, continue
444
+ }
445
+ console.log('');
446
+ const createSpinner = log.spinner();
447
+ createSpinner.start(`Creating release ${colors.yellow}${tag}${colors.reset}...`);
448
+ const os = await import('node:os');
449
+ const tempFile = `${os.tmpdir()}/geeto-sync-${Date.now()}.md`;
450
+ writeFileSync(tempFile, releaseBody, 'utf8');
451
+ try {
452
+ await execAsync(`gh release create ${tag} --title "${tag}" --notes-file "${tempFile}"`, true);
453
+ createSpinner.succeed(`Release ${tag} created`);
454
+ successCount++;
455
+ }
456
+ catch (error) {
457
+ const stderr = error.stderr?.trim();
458
+ createSpinner.fail(`Failed to create release for ${tag}`);
459
+ if (stderr)
460
+ log.error(` ${stderr.split('\n')[0]}`);
461
+ }
462
+ // Cleanup temp file
463
+ try {
464
+ const { unlinkSync } = await import('node:fs');
465
+ unlinkSync(tempFile);
466
+ }
467
+ catch {
468
+ /* ignore */
469
+ }
470
+ }
471
+ console.log('');
472
+ if (successCount === tagsToRelease.length) {
473
+ log.success(`All ${successCount} GitHub Releases created!`);
474
+ }
475
+ else {
476
+ log.warn(`${successCount}/${tagsToRelease.length} releases created`);
477
+ }
478
+ };
479
+ // ─── Delete GitHub Releases ───
480
+ const handleDeleteReleases = async () => {
481
+ // Check if gh CLI is available
482
+ try {
483
+ execSilent('gh --version');
484
+ }
485
+ catch {
486
+ log.error('GitHub CLI (gh) is not installed. Install it: https://cli.github.com');
487
+ return;
488
+ }
489
+ console.log('');
490
+ const spinner = log.spinner();
491
+ spinner.start('Fetching GitHub releases...');
492
+ const ghReleases = getExistingGithubReleases();
493
+ spinner.succeed(`Found ${ghReleases.length} GitHub releases`);
494
+ if (ghReleases.length === 0) {
495
+ console.log('');
496
+ log.info('No GitHub Releases to delete.');
497
+ return;
498
+ }
499
+ console.log('');
500
+ const { multiSelect } = await import('../cli/menu.js');
501
+ const choices = ghReleases.map((t) => ({ label: t, value: t }));
502
+ const selected = await multiSelect('Select releases to delete:', choices);
503
+ if (selected.length === 0) {
504
+ log.info('No releases selected.');
505
+ return;
506
+ }
507
+ console.log('');
508
+ const alsoDeleteTag = confirm('Also delete the associated git tags?');
509
+ console.log('');
510
+ const proceed = confirm(`Delete ${selected.length} GitHub Release(s)${alsoDeleteTag ? ' + tags' : ''}?`);
511
+ if (!proceed)
512
+ return;
513
+ let successCount = 0;
514
+ for (const release of selected) {
515
+ console.log('');
516
+ const releaseSpinner = log.spinner();
517
+ releaseSpinner.start(`Deleting release ${colors.yellow}${release}${colors.reset}...`);
518
+ try {
519
+ await execAsync(`gh release delete ${release} --yes`, true);
520
+ if (alsoDeleteTag) {
521
+ try {
522
+ await execAsync(`git tag -d ${release}`, true);
523
+ await execAsync(`git push origin --delete ${release} --no-verify`, true);
524
+ }
525
+ catch {
526
+ /* Tag deletion is best-effort */
527
+ }
528
+ }
529
+ releaseSpinner.succeed(`Release ${release} deleted${alsoDeleteTag ? ' + tag' : ''}`);
530
+ successCount++;
531
+ }
532
+ catch (error) {
533
+ const stderr = error.stderr?.trim();
534
+ releaseSpinner.fail(`Failed to delete ${release}`);
535
+ if (stderr)
536
+ log.error(` ${stderr.split('\n')[0]}`);
537
+ }
538
+ }
539
+ console.log('');
540
+ if (successCount === selected.length) {
541
+ log.success(`All ${successCount} releases deleted!`);
542
+ }
543
+ else {
544
+ log.warn(`${successCount}/${selected.length} releases deleted`);
545
+ }
546
+ };
547
+ // ─── Recover missing tags ───
548
+ const handleRecoverTags = async () => {
549
+ const line = '─'.repeat(56);
550
+ console.log('');
551
+ const spinner = log.spinner();
552
+ spinner.start('Scanning release commits...');
553
+ // Find all release commits: "chore(release): vX.Y.Z"
554
+ let gitLog;
555
+ try {
556
+ gitLog = exec('git log --all --oneline --grep="^chore(release): v" --format="%H %s"', true);
557
+ }
558
+ catch {
559
+ spinner.fail('Failed to scan git log');
560
+ return;
561
+ }
562
+ const releasePattern = /^([a-f0-9]+) chore\(release\): v(.+)$/;
563
+ const releaseCommits = [];
564
+ for (const logLine of gitLog.split('\n').filter(Boolean)) {
565
+ const match = logLine.match(releasePattern);
566
+ if (match?.[1] && match[2]) {
567
+ releaseCommits.push({
568
+ hash: match[1],
569
+ version: match[2],
570
+ tag: `v${match[2]}`,
571
+ });
572
+ }
573
+ }
574
+ if (releaseCommits.length === 0) {
575
+ spinner.fail('No release commits found');
576
+ return;
577
+ }
578
+ // Compare with existing tags
579
+ const existingTags = new Set(getExistingTags());
580
+ const missingTags = releaseCommits.filter((rc) => !existingTags.has(rc.tag));
581
+ spinner.succeed(`Found ${releaseCommits.length} release commits, ${existingTags.size} tags`);
582
+ if (missingTags.length === 0) {
583
+ console.log('');
584
+ log.success('All release commits have matching tags! Nothing to recover.');
585
+ return;
586
+ }
587
+ // Show missing tags
588
+ console.log('');
589
+ log.info(`${colors.bright}${missingTags.length}${colors.reset} tags missing (release commit exists but no tag):`);
590
+ for (const mt of missingTags) {
591
+ console.log(` ${colors.yellow}${mt.tag}${colors.reset} ${colors.gray}← ${mt.hash.slice(0, 7)}${colors.reset}`);
592
+ }
593
+ console.log('');
594
+ const action = await select('What do you want to do?', [
595
+ { label: 'Recover all missing tags', value: 'all' },
596
+ { label: 'Select which tags to recover', value: 'select' },
597
+ { label: 'Cancel', value: 'cancel' },
598
+ ]);
599
+ if (action === 'cancel')
600
+ return;
601
+ let tagsToRecover = missingTags;
602
+ if (action === 'select') {
603
+ const { multiSelect } = await import('../cli/menu.js');
604
+ const choices = missingTags.map((mt) => ({
605
+ label: `${mt.tag} (${mt.hash.slice(0, 7)})`,
606
+ value: mt.tag,
607
+ }));
608
+ const selected = await multiSelect('Select tags to recover:', choices);
609
+ if (selected.length === 0) {
610
+ log.info('No tags selected.');
611
+ return;
612
+ }
613
+ tagsToRecover = missingTags.filter((mt) => selected.includes(mt.tag));
614
+ }
615
+ // Preview
616
+ console.log('');
617
+ console.log(`${colors.cyan}┌${line}┐${colors.reset}`);
618
+ console.log(`${colors.cyan}│${colors.reset} ${colors.bright}Recovery Plan${colors.reset}`);
619
+ console.log(`${colors.cyan}├${line}┤${colors.reset}`);
620
+ for (const mt of tagsToRecover) {
621
+ console.log(`${colors.cyan}│${colors.reset} ${colors.green}+${colors.reset} ${colors.yellow}${mt.tag}${colors.reset} → commit ${colors.gray}${mt.hash.slice(0, 7)}${colors.reset}`);
622
+ }
623
+ console.log(`${colors.cyan}└${line}┘${colors.reset}`);
624
+ console.log('');
625
+ const proceed = confirm(`Create ${tagsToRecover.length} tags?`);
626
+ if (!proceed)
627
+ return;
628
+ let successCount = 0;
629
+ for (const mt of tagsToRecover) {
630
+ console.log('');
631
+ const tagSpinner = log.spinner();
632
+ tagSpinner.start(`Creating tag ${colors.yellow}${mt.tag}${colors.reset}...`);
633
+ try {
634
+ exec(`git tag -a ${mt.tag} ${mt.hash} -m "Release ${mt.tag}"`, true);
635
+ tagSpinner.succeed(`Tag ${mt.tag} created`);
636
+ successCount++;
637
+ }
638
+ catch (error) {
639
+ const errMsg = error instanceof Error ? error.message : String(error);
640
+ tagSpinner.fail(`Failed to create ${mt.tag}`);
641
+ log.error(` ${errMsg.split('\n')[0]}`);
642
+ }
643
+ }
644
+ console.log('');
645
+ if (successCount === tagsToRecover.length) {
646
+ log.success(`All ${successCount} tags recovered!`);
647
+ }
648
+ else {
649
+ log.warn(`${successCount}/${tagsToRecover.length} tags recovered`);
650
+ }
651
+ // Offer to push tags to remote
652
+ if (successCount > 0) {
653
+ console.log('');
654
+ const pushTags = confirm('Push recovered tags to remote?');
655
+ if (pushTags) {
656
+ console.log('');
657
+ const pushSpinner = log.spinner();
658
+ pushSpinner.start('Pushing tags to remote...');
659
+ try {
660
+ await execAsync('git push --tags --no-verify', true);
661
+ pushSpinner.succeed('Tags pushed to remote');
662
+ }
663
+ catch (error) {
664
+ const stderr = error.stderr?.trim();
665
+ pushSpinner.fail('Failed to push tags');
666
+ if (stderr)
667
+ log.error(` ${stderr.split('\n')[0]}`);
668
+ }
669
+ }
670
+ }
671
+ };
219
672
  // ─── Main handler ───
220
673
  export const handleRelease = async () => {
221
674
  log.banner();
222
675
  log.step(`${colors.cyan}Release / Tag Manager${colors.reset}\n`);
676
+ // Main menu: create new release, sync, or manage releases
677
+ const mode = await select('What do you want to do?', [
678
+ { label: 'Create a new release', value: 'create' },
679
+ { label: 'Sync GitHub Releases for existing tags', value: 'sync' },
680
+ { label: 'Recover missing tags from release commits', value: 'recover' },
681
+ { label: 'Delete GitHub Releases', value: 'delete' },
682
+ ]);
683
+ if (mode === 'sync') {
684
+ await handleSyncReleases();
685
+ return;
686
+ }
687
+ if (mode === 'recover') {
688
+ await handleRecoverTags();
689
+ return;
690
+ }
691
+ if (mode === 'delete') {
692
+ await handleDeleteReleases();
693
+ return;
694
+ }
223
695
  const currentVersion = getCurrentVersion();
224
696
  const semver = parseSemver(currentVersion);
225
697
  if (!semver) {
@@ -330,6 +802,182 @@ export const handleRelease = async () => {
330
802
  log.info('Cancelled.');
331
803
  return;
332
804
  }
805
+ // ─── AI Release Notes Generation ───
806
+ // Ask how to generate release notes for RELEASE.MD
807
+ const commitList = commits.map((c) => c.subject).join('\n');
808
+ console.log('');
809
+ const releaseNotesMode = await select('How do you want to generate RELEASE.MD?', [
810
+ { label: 'AI-generated (recommended)', value: 'ai' },
811
+ { label: 'Auto-generate (template-based)', value: 'auto' },
812
+ ]);
813
+ let aiReleaseNotes = null;
814
+ if (releaseNotesMode === 'ai' && commits.length > 0) {
815
+ // Choose language
816
+ const language = (await select('Release notes language:', [
817
+ { label: 'English', value: 'en' },
818
+ { label: 'Indonesian (Bahasa Indonesia)', value: 'id' },
819
+ ]));
820
+ // Read saved AI config from state
821
+ const savedState = loadState();
822
+ let aiProvider = 'copilot';
823
+ let copilotModel;
824
+ let openrouterModel;
825
+ let geminiModel;
826
+ // Use saved provider/model if available, otherwise ask user
827
+ if (savedState?.aiProvider &&
828
+ savedState.aiProvider !== 'manual' &&
829
+ (savedState.copilotModel || savedState.openrouterModel || savedState.geminiModel)) {
830
+ aiProvider = savedState.aiProvider;
831
+ copilotModel = savedState.copilotModel;
832
+ openrouterModel = savedState.openrouterModel;
833
+ geminiModel = savedState.geminiModel;
834
+ }
835
+ else {
836
+ // No saved config — ask user to pick provider + model
837
+ let providerChosen = false;
838
+ while (!providerChosen) {
839
+ aiProvider = (await select('Choose AI Provider:', [
840
+ { label: 'GitHub Copilot (Recommended)', value: 'copilot' },
841
+ { label: 'Gemini', value: 'gemini' },
842
+ { label: 'OpenRouter', value: 'openrouter' },
843
+ ]));
844
+ const chosen = await chooseModelForProvider(aiProvider, undefined, 'Back to AI provider menu');
845
+ if (!chosen || chosen === 'back')
846
+ continue;
847
+ switch (aiProvider) {
848
+ case 'gemini': {
849
+ geminiModel = chosen;
850
+ break;
851
+ }
852
+ case 'copilot': {
853
+ copilotModel = chosen;
854
+ break;
855
+ }
856
+ case 'openrouter': {
857
+ openrouterModel = chosen;
858
+ break;
859
+ }
860
+ }
861
+ providerChosen = true;
862
+ }
863
+ }
864
+ // Generate/regenerate loop (similar to commit flow)
865
+ let correction;
866
+ let accepted = false;
867
+ while (!accepted) {
868
+ const spinner = log.spinner();
869
+ const modelDisplay = getModelValue(copilotModel ?? openrouterModel ?? geminiModel ?? '');
870
+ spinner.start(`Generating release notes with ${getAIProviderShortName(aiProvider)}` +
871
+ (modelDisplay ? ` (${modelDisplay})` : '') +
872
+ '...');
873
+ const result = await generateReleaseNotesWithProvider(aiProvider, commitList, language, correction, copilotModel, openrouterModel, geminiModel);
874
+ spinner.succeed('Release notes generated');
875
+ console.log('');
876
+ if (!result) {
877
+ log.warn('AI returned no result. Falling back to template-based generation.');
878
+ break;
879
+ }
880
+ aiReleaseNotes = result;
881
+ // Preview
882
+ console.log(`${colors.cyan}┌${'─'.repeat(56)}┐${colors.reset}`);
883
+ console.log(`${colors.cyan}│${colors.reset} ${colors.bright}Release Notes Preview${colors.reset}`);
884
+ console.log(`${colors.cyan}├${'─'.repeat(56)}┤${colors.reset}`);
885
+ for (const line of aiReleaseNotes.split('\n')) {
886
+ console.log(`${colors.cyan}│${colors.reset} ${line}`);
887
+ }
888
+ console.log(`${colors.cyan}└${'─'.repeat(56)}┘${colors.reset}`);
889
+ console.log('');
890
+ const action = await select('Accept these release notes?', [
891
+ { label: 'Yes, use it', value: 'accept' },
892
+ { label: 'Regenerate', value: 'regenerate' },
893
+ { label: 'Edit inline', value: 'edit' },
894
+ { label: 'Correct AI (give feedback)', value: 'correct' },
895
+ { label: 'Change model', value: 'change-model' },
896
+ { label: 'Change AI provider', value: 'change-provider' },
897
+ { label: 'Use template instead', value: 'template' },
898
+ ]);
899
+ switch (action) {
900
+ case 'accept': {
901
+ accepted = true;
902
+ break;
903
+ }
904
+ case 'regenerate': {
905
+ correction = undefined;
906
+ continue;
907
+ }
908
+ case 'edit': {
909
+ const edited = await editInline(aiReleaseNotes, 'Release Notes', '.md');
910
+ aiReleaseNotes = edited;
911
+ accepted = true;
912
+ break;
913
+ }
914
+ case 'correct': {
915
+ if (process.stdin.isTTY)
916
+ process.stdin.setRawMode(false);
917
+ const feedback = askQuestion('Feedback for AI: ').trim();
918
+ if (feedback)
919
+ correction = feedback;
920
+ continue;
921
+ }
922
+ case 'change-model': {
923
+ const newModel = await chooseModelForProvider(aiProvider, undefined, 'Back');
924
+ if (newModel && newModel !== 'back') {
925
+ switch (aiProvider) {
926
+ case 'gemini': {
927
+ geminiModel = newModel;
928
+ break;
929
+ }
930
+ case 'copilot': {
931
+ copilotModel = newModel;
932
+ break;
933
+ }
934
+ case 'openrouter': {
935
+ openrouterModel = newModel;
936
+ break;
937
+ }
938
+ }
939
+ }
940
+ correction = undefined;
941
+ continue;
942
+ }
943
+ case 'change-provider': {
944
+ const prov = (await select('Choose AI provider:', [
945
+ { label: 'GitHub Copilot', value: 'copilot' },
946
+ { label: 'Gemini', value: 'gemini' },
947
+ { label: 'OpenRouter', value: 'openrouter' },
948
+ ]));
949
+ aiProvider = prov;
950
+ copilotModel = undefined;
951
+ openrouterModel = undefined;
952
+ geminiModel = undefined;
953
+ const newModel = await chooseModelForProvider(aiProvider, undefined, 'Back');
954
+ if (newModel && newModel !== 'back') {
955
+ switch (aiProvider) {
956
+ case 'gemini': {
957
+ geminiModel = newModel;
958
+ break;
959
+ }
960
+ case 'copilot': {
961
+ copilotModel = newModel;
962
+ break;
963
+ }
964
+ case 'openrouter': {
965
+ openrouterModel = newModel;
966
+ break;
967
+ }
968
+ }
969
+ }
970
+ correction = undefined;
971
+ continue;
972
+ }
973
+ case 'template': {
974
+ aiReleaseNotes = null;
975
+ accepted = true;
976
+ break;
977
+ }
978
+ }
979
+ }
980
+ }
333
981
  // Execute release steps
334
982
  const spinner = log.spinner();
335
983
  // 1. Update package.json
@@ -342,14 +990,58 @@ export const handleRelease = async () => {
342
990
  spinner.fail('Failed to update package.json');
343
991
  return;
344
992
  }
345
- // 2. Generate RELEASE.MD
346
- spinner.start('Generating RELEASE.MD...');
993
+ // 2. Update RELEASE.MD (prepend new version, keep old ones)
994
+ spinner.start('Updating RELEASE.MD...');
347
995
  try {
348
- writeFileSync('RELEASE.MD', generateReleaseMd(newVersion, commits, currentVersion), 'utf8');
349
- spinner.succeed('RELEASE.MD generated');
996
+ // Use AI-generated notes if available, otherwise fallback to template
997
+ let newEntry;
998
+ if (aiReleaseNotes) {
999
+ const now = new Date();
1000
+ const date = now.toLocaleDateString('en-US', {
1001
+ year: 'numeric',
1002
+ month: 'long',
1003
+ day: 'numeric',
1004
+ });
1005
+ const normalizedNotes = normalizeReleaseMarkdown(aiReleaseNotes);
1006
+ newEntry =
1007
+ [`## v${newVersion} — ${date}`, '', normalizedNotes, '', '---', ''].join('\n') + '\n';
1008
+ }
1009
+ else {
1010
+ newEntry = generateReleaseMd(newVersion, commits, currentVersion);
1011
+ }
1012
+ let existing = '';
1013
+ try {
1014
+ existing = readFileSync('RELEASE.MD', 'utf8');
1015
+ }
1016
+ catch {
1017
+ // File doesn't exist yet
1018
+ }
1019
+ const header = '# Releases\n\n';
1020
+ const footer = '\n*This document was automatically generated by [Geeto CLI](https://github.com/rust142/geeto)*\n';
1021
+ let releaseMd;
1022
+ if (existing.startsWith('# Releases')) {
1023
+ // Strip old header and footer, prepend new entry after header
1024
+ const headerEnd = existing.indexOf('\n\n') + 2;
1025
+ let body = existing.slice(headerEnd);
1026
+ // Remove trailing auto-generated footer if present
1027
+ const footerIdx = body.lastIndexOf('*This document was automatically generated by [Geeto CLI]');
1028
+ if (footerIdx !== -1) {
1029
+ body = body.slice(0, footerIdx).trimEnd() + '\n\n';
1030
+ }
1031
+ releaseMd = header + newEntry + body + footer;
1032
+ }
1033
+ else if (existing) {
1034
+ // Old format without "# Releases" header — keep existing below new entry
1035
+ releaseMd = header + newEntry + existing + '\n' + footer;
1036
+ }
1037
+ else {
1038
+ releaseMd = header + newEntry + footer;
1039
+ }
1040
+ writeFileSync('RELEASE.MD', releaseMd, 'utf8');
1041
+ spinner.succeed('RELEASE.MD updated');
350
1042
  }
351
1043
  catch {
352
- spinner.fail('Failed to generate RELEASE.MD');
1044
+ spinner.fail('Failed to update RELEASE.MD');
353
1045
  }
354
1046
  // 3. Update CHANGELOG.md
355
1047
  spinner.start('Updating CHANGELOG.md...');
@@ -382,7 +1074,7 @@ export const handleRelease = async () => {
382
1074
  // 4. Stage, commit, tag
383
1075
  spinner.start('Creating release commit...');
384
1076
  try {
385
- exec('git add package.json RELEASE.MD CHANGELOG.md', true);
1077
+ exec('git add package.json src/version.ts RELEASE.MD CHANGELOG.md', true);
386
1078
  exec(`git commit --no-verify -m "chore(release): v${newVersion}"`, true);
387
1079
  spinner.succeed('Release commit created');
388
1080
  }
@@ -402,8 +1094,8 @@ export const handleRelease = async () => {
402
1094
  // 5. Push
403
1095
  console.log('');
404
1096
  const pushChoice = await select('Push to remote?', [
405
- { label: 'Push commit + tag', value: 'both' },
406
- { label: 'Push commit only', value: 'commit' },
1097
+ { label: 'Push release + tag', value: 'both' },
1098
+ { label: 'Push release only', value: 'commit' },
407
1099
  { label: 'Skip pushing', value: 'skip' },
408
1100
  ]);
409
1101
  if (pushChoice === 'both' || pushChoice === 'commit') {
@@ -416,13 +1108,13 @@ export const handleRelease = async () => {
416
1108
  try {
417
1109
  await execAsync(`git push`, true);
418
1110
  if (pushChoice === 'both') {
419
- await execAsync(`git push origin v${newVersion}`, true);
1111
+ await execAsync(`git push origin v${newVersion} --no-verify`, true);
420
1112
  }
421
1113
  clearInterval(interval);
422
1114
  progressBar.update(100);
423
1115
  progressBar.complete();
424
1116
  console.log('');
425
- log.success(pushChoice === 'both' ? 'Pushed commit + tag' : 'Pushed commit');
1117
+ log.success(pushChoice === 'both' ? 'Pushed release + tag' : 'Pushed release');
426
1118
  }
427
1119
  catch {
428
1120
  clearInterval(interval);
@@ -431,6 +1123,49 @@ export const handleRelease = async () => {
431
1123
  log.error('Failed to push');
432
1124
  }
433
1125
  }
1126
+ // 6. Create GitHub Release (if tag was pushed and gh CLI is available)
1127
+ let ghReleaseCreated = false;
1128
+ if (pushChoice === 'both') {
1129
+ try {
1130
+ execSilent('gh --version');
1131
+ // gh CLI is available — create a GitHub Release
1132
+ // Build release body from AI notes or template
1133
+ const releaseBody = aiReleaseNotes
1134
+ ? normalizeReleaseMarkdown(aiReleaseNotes)
1135
+ : generateReleaseMd(newVersion, commits, currentVersion)
1136
+ .replace(/^## .*\n+/, '')
1137
+ .replace(/\n---\n*$/, '')
1138
+ .trim();
1139
+ // Write to temp file to avoid shell quoting issues
1140
+ const os = await import('node:os');
1141
+ const tempFile = `${os.tmpdir()}/geeto-release-${Date.now()}.md`;
1142
+ writeFileSync(tempFile, releaseBody, 'utf8');
1143
+ const releaseSpinner = log.spinner();
1144
+ releaseSpinner.start('Creating GitHub Release...');
1145
+ try {
1146
+ await execAsync(`gh release create v${newVersion} --title "v${newVersion}" --notes-file "${tempFile}"`, true);
1147
+ releaseSpinner.succeed('GitHub Release created');
1148
+ ghReleaseCreated = true;
1149
+ }
1150
+ catch (error) {
1151
+ const stderr = error.stderr?.trim();
1152
+ releaseSpinner.fail('Failed to create GitHub Release');
1153
+ if (stderr)
1154
+ log.error(` ${stderr.split('\n')[0]}`);
1155
+ }
1156
+ // Cleanup temp file
1157
+ try {
1158
+ const { unlinkSync } = await import('node:fs');
1159
+ unlinkSync(tempFile);
1160
+ }
1161
+ catch {
1162
+ /* ignore cleanup errors */
1163
+ }
1164
+ }
1165
+ catch {
1166
+ // gh CLI not available — skip silently
1167
+ }
1168
+ }
434
1169
  // Summary
435
1170
  console.log('');
436
1171
  console.log(`${colors.cyan}┌${line}┐${colors.reset}`);
@@ -440,6 +1175,9 @@ export const handleRelease = async () => {
440
1175
  console.log(`${colors.cyan}│${colors.reset} ${colors.green}✓${colors.reset} RELEASE.MD generated ${colors.gray}(user-facing)${colors.reset}`);
441
1176
  console.log(`${colors.cyan}│${colors.reset} ${colors.green}✓${colors.reset} CHANGELOG.md updated ${colors.gray}(developer-facing)${colors.reset}`);
442
1177
  console.log(`${colors.cyan}│${colors.reset} ${colors.green}✓${colors.reset} Tag v${newVersion} created`);
1178
+ if (ghReleaseCreated) {
1179
+ console.log(`${colors.cyan}│${colors.reset} ${colors.green}✓${colors.reset} GitHub Release published`);
1180
+ }
443
1181
  console.log(`${colors.cyan}└${line}┘${colors.reset}`);
444
1182
  };
445
1183
  //# sourceMappingURL=release.js.map