geeto 0.3.9 → 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 (42) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +104 -107
  3. package/lib/api/copilot-sdk.d.ts +3 -0
  4. package/lib/api/copilot-sdk.d.ts.map +1 -1
  5. package/lib/api/copilot-sdk.js +44 -3
  6. package/lib/api/copilot-sdk.js.map +1 -1
  7. package/lib/api/copilot.d.ts +1 -0
  8. package/lib/api/copilot.d.ts.map +1 -1
  9. package/lib/api/copilot.js +13 -1
  10. package/lib/api/copilot.js.map +1 -1
  11. package/lib/api/gemini-sdk.d.ts +3 -0
  12. package/lib/api/gemini-sdk.d.ts.map +1 -1
  13. package/lib/api/gemini-sdk.js +46 -3
  14. package/lib/api/gemini-sdk.js.map +1 -1
  15. package/lib/api/gemini.d.ts +1 -0
  16. package/lib/api/gemini.d.ts.map +1 -1
  17. package/lib/api/gemini.js +13 -1
  18. package/lib/api/gemini.js.map +1 -1
  19. package/lib/api/openrouter-sdk.d.ts +3 -0
  20. package/lib/api/openrouter-sdk.d.ts.map +1 -1
  21. package/lib/api/openrouter-sdk.js +46 -3
  22. package/lib/api/openrouter-sdk.js.map +1 -1
  23. package/lib/api/openrouter.d.ts +1 -0
  24. package/lib/api/openrouter.d.ts.map +1 -1
  25. package/lib/api/openrouter.js +13 -1
  26. package/lib/api/openrouter.js.map +1 -1
  27. package/lib/index.js +18 -0
  28. package/lib/index.js.map +1 -1
  29. package/lib/utils/git-ai.d.ts +2 -0
  30. package/lib/utils/git-ai.d.ts.map +1 -1
  31. package/lib/utils/git-ai.js +17 -0
  32. package/lib/utils/git-ai.js.map +1 -1
  33. package/lib/version.d.ts +1 -1
  34. package/lib/version.js +1 -1
  35. package/lib/workflows/release.d.ts.map +1 -1
  36. package/lib/workflows/release.js +518 -9
  37. package/lib/workflows/release.js.map +1 -1
  38. package/lib/workflows/repo-settings.d.ts +9 -0
  39. package/lib/workflows/repo-settings.d.ts.map +1 -0
  40. package/lib/workflows/repo-settings.js +425 -0
  41. package/lib/workflows/repo-settings.js.map +1 -0
  42. package/package.json +16 -5
@@ -12,6 +12,33 @@ import { chooseModelForProvider, generateReleaseNotesWithProvider, getAIProvider
12
12
  import { log } from '../utils/logging.js';
13
13
  import { loadState } from '../utils/state.js';
14
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
+ };
15
42
  const parseSemver = (version) => {
16
43
  const match = version.match(/^(\d+)\.(\d+)\.(\d+)/);
17
44
  if (!match)
@@ -223,10 +250,448 @@ const generateChangelogEntry = (version, commits, prevVersion) => {
223
250
  : [];
224
251
  return [...header, ...breakingSection, ...featureSection, ...fixSection, ...otherSection].join('\n');
225
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
+ };
226
672
  // ─── Main handler ───
227
673
  export const handleRelease = async () => {
228
674
  log.banner();
229
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
+ }
230
695
  const currentVersion = getCurrentVersion();
231
696
  const semver = parseSemver(currentVersion);
232
697
  if (!semver) {
@@ -366,10 +831,6 @@ export const handleRelease = async () => {
366
831
  copilotModel = savedState.copilotModel;
367
832
  openrouterModel = savedState.openrouterModel;
368
833
  geminiModel = savedState.geminiModel;
369
- const modelDisplay = getModelValue(copilotModel ?? openrouterModel ?? geminiModel ?? '');
370
- log.info(`Using saved AI config: ${getAIProviderShortName(aiProvider)}` +
371
- (modelDisplay ? ` (${modelDisplay})` : ''));
372
- console.log('');
373
834
  }
374
835
  else {
375
836
  // No saved config — ask user to pick provider + model
@@ -541,7 +1002,9 @@ export const handleRelease = async () => {
541
1002
  month: 'long',
542
1003
  day: 'numeric',
543
1004
  });
544
- newEntry = [`## v${newVersion} — ${date}`, '', aiReleaseNotes, '', '---', ''].join('\n');
1005
+ const normalizedNotes = normalizeReleaseMarkdown(aiReleaseNotes);
1006
+ newEntry =
1007
+ [`## v${newVersion} — ${date}`, '', normalizedNotes, '', '---', ''].join('\n') + '\n';
545
1008
  }
546
1009
  else {
547
1010
  newEntry = generateReleaseMd(newVersion, commits, currentVersion);
@@ -631,8 +1094,8 @@ export const handleRelease = async () => {
631
1094
  // 5. Push
632
1095
  console.log('');
633
1096
  const pushChoice = await select('Push to remote?', [
634
- { label: 'Push commit + tag', value: 'both' },
635
- { label: 'Push commit only', value: 'commit' },
1097
+ { label: 'Push release + tag', value: 'both' },
1098
+ { label: 'Push release only', value: 'commit' },
636
1099
  { label: 'Skip pushing', value: 'skip' },
637
1100
  ]);
638
1101
  if (pushChoice === 'both' || pushChoice === 'commit') {
@@ -645,13 +1108,13 @@ export const handleRelease = async () => {
645
1108
  try {
646
1109
  await execAsync(`git push`, true);
647
1110
  if (pushChoice === 'both') {
648
- await execAsync(`git push origin v${newVersion}`, true);
1111
+ await execAsync(`git push origin v${newVersion} --no-verify`, true);
649
1112
  }
650
1113
  clearInterval(interval);
651
1114
  progressBar.update(100);
652
1115
  progressBar.complete();
653
1116
  console.log('');
654
- log.success(pushChoice === 'both' ? 'Pushed commit + tag' : 'Pushed commit');
1117
+ log.success(pushChoice === 'both' ? 'Pushed release + tag' : 'Pushed release');
655
1118
  }
656
1119
  catch {
657
1120
  clearInterval(interval);
@@ -660,6 +1123,49 @@ export const handleRelease = async () => {
660
1123
  log.error('Failed to push');
661
1124
  }
662
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
+ }
663
1169
  // Summary
664
1170
  console.log('');
665
1171
  console.log(`${colors.cyan}┌${line}┐${colors.reset}`);
@@ -669,6 +1175,9 @@ export const handleRelease = async () => {
669
1175
  console.log(`${colors.cyan}│${colors.reset} ${colors.green}✓${colors.reset} RELEASE.MD generated ${colors.gray}(user-facing)${colors.reset}`);
670
1176
  console.log(`${colors.cyan}│${colors.reset} ${colors.green}✓${colors.reset} CHANGELOG.md updated ${colors.gray}(developer-facing)${colors.reset}`);
671
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
+ }
672
1181
  console.log(`${colors.cyan}└${line}┘${colors.reset}`);
673
1182
  };
674
1183
  //# sourceMappingURL=release.js.map