create-app-release 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2025-03-18
9
+
10
+ ### Added
11
+
12
+ - Auto suggest repositories based on recent activities
13
+ - Added filter for listed pull requests for easy tracking
14
+ - Dynamic release version suggestion based on previous releases
15
+ - Added suggestion for source and target branches
16
+
17
+ ### Changed
18
+
19
+ - Updated release summary generation to exclude heading
20
+
21
+ ## [1.1.0] - 2025-02-12
22
+
23
+ ### Added
24
+
25
+ - Added support for multiple LLM providers:
26
+ - OpenAI models (default)
27
+ - Deepseek models
28
+ - QwenAI models
29
+ - Local LLM deployments
30
+ - New command-line options for LLM configuration:
31
+ - `--openai-key`: Set API key directly
32
+ - `--openai-model`: Choose model (e.g., gpt-4o, deepseek-r1, qwen2.5)
33
+ - `--openai-base-url`: Set custom API base URL for different providers
34
+ - Enhanced help information with detailed options and provider-specific examples
35
+
8
36
  ## [1.0.2] - 2025-02-09
9
37
 
10
38
  ### Changed
@@ -44,4 +72,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
44
72
  - ora - Terminal spinners
45
73
  - dotenv - Environment variable management
46
74
 
75
+ [1.2.0]: https://github.com/jamesgordo/create-app-release/releases/tag/v1.2.0
76
+ [1.1.0]: https://github.com/jamesgordo/create-app-release/releases/tag/v1.1.0
47
77
  [1.0.0]: https://github.com/jamesgordo/create-app-release/releases/tag/v1.0.0
package/README.md CHANGED
@@ -1,10 +1,18 @@
1
1
  # create-app-release
2
2
 
3
- An AI-powered GitHub release automation tool that helps you create release pull requests with automatically generated summaries using OpenAI's GPT-4 model. The tool intelligently groups your changes and creates professional release notes, making the release process smoother and more efficient.
3
+ [![NPM Version](https://img.shields.io/npm/v/create-app-release.svg)](https://www.npmjs.com/package/create-app-release)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ An AI-powered GitHub release automation tool that helps you create release pull requests with automatically generated summaries using various LLM providers. The tool intelligently groups your changes and creates professional release notes, making the release process smoother and more efficient.
4
7
 
5
8
  ## Features
6
9
 
7
10
  - 🤖 AI-powered release notes generation using GPT-4
11
+ - 🔄 Flexible LLM support:
12
+ - OpenAI models (GPT-4o, GPT-3.5-turbo)
13
+ - Deepseek models
14
+ - QwenAI models
15
+ - Local LLM deployments
8
16
  - 📦 Zero configuration - works right out of the box
9
17
  - 🔑 Secure token management through git config
10
18
  - 🎯 Interactive pull request selection
@@ -47,6 +55,38 @@ You'll need two tokens to use this tool:
47
55
  2. **OpenAI API Key** - Get from [OpenAI Platform](https://platform.openai.com/api-keys)
48
56
  - Will be stored in git config as `openai.token`
49
57
 
58
+ ### Command-Line Options
59
+
60
+ Customize the tool's behavior using these command-line options:
61
+
62
+ ```bash
63
+ # Set OpenAI API key directly (alternative to env/git config)
64
+ --openai-key <key>
65
+
66
+ # Choose OpenAI model (default: "gpt-4o")
67
+ --openai-model <model>
68
+ # Examples: gpt-4o, gpt-3.5-turbo, deepseek-r1, qwen2.5
69
+
70
+ # Set custom OpenAI API base URL
71
+ --openai-base-url <url>
72
+ # Examples:
73
+ # - Deepseek: https://api.deepseek.com/v1
74
+ # - QwenAI: https://api.qwen.ai/v1
75
+ # - Local: http://localhost:8000/v1
76
+ # - Custom: https://custom-openai-endpoint.com/v1
77
+
78
+ # Full example with different providers:
79
+
80
+ # Using Deepseek
81
+ npx create-app-release --openai-base-url https://api.deepseek.com/v1 --openai-key your_deepseek_key --openai-model deepseek-chat
82
+
83
+ # Using QwenAI
84
+ npx create-app-release --openai-base-url https://api.qwen.ai/v1 --openai-key your_qwen_key --openai-model qwen-14b-chat
85
+
86
+ # Using Local LLM
87
+ npx create-app-release --openai-base-url http://localhost:8000/v1 --openai-model local-model
88
+ ```
89
+
50
90
  ### Environment Variables (Optional)
51
91
 
52
92
  Tokens can also be provided via environment variables:
@@ -85,3 +125,7 @@ The tool generates professional release notes in this format:
85
125
  ## License
86
126
 
87
127
  MIT
128
+
129
+ ## Author
130
+
131
+ [James Gordo](https://github.com/jamesgordo)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-app-release",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "AI-powered GitHub release automation tool",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -27,6 +27,14 @@
27
27
  ],
28
28
  "author": "James Gordo",
29
29
  "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/jamesgordo/create-app-release.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/jamesgordo/create-app-release/issues"
36
+ },
37
+ "homepage": "https://github.com/jamesgordo/create-app-release#readme",
30
38
  "lint-staged": {
31
39
  "*.{js,jsx,ts,tsx}": [
32
40
  "eslint --fix",
package/src/index.js CHANGED
@@ -18,6 +18,22 @@ const require = createRequire(import.meta.url);
18
18
  // Load environment variables
19
19
  config();
20
20
 
21
+ // Setup graceful exit handlers
22
+ process.stdin.setRawMode(true);
23
+ process.stdin.resume();
24
+ process.stdin.setEncoding('utf8');
25
+
26
+ // Display exit instructions
27
+ console.log(chalk.cyan('Press Ctrl+C or q to exit at any time'));
28
+
29
+ process.stdin.on('data', (key) => {
30
+ // Ctrl+C or 'q' to exit
31
+ if (key === '\u0003' || key.toLowerCase() === 'q') {
32
+ console.log(chalk.yellow('\nExiting gracefully...'));
33
+ process.exit(0);
34
+ }
35
+ });
36
+
21
37
  // Initialize CLI program
22
38
  const program = new Command();
23
39
  const pkg = require('../package.json');
@@ -154,14 +170,130 @@ function extractPRNumbersFromDescription(description) {
154
170
  return new Set(prMatches.map((match) => parseInt(match.replace(/[^0-9]/g, ''))));
155
171
  }
156
172
 
173
+ /**
174
+ * Fetch repositories the user has contributed to, including personal and organization repos
175
+ * @returns {Promise<Array>} List of repositories
176
+ */
177
+ async function fetchUserRepositories() {
178
+ const spinner = ora('Fetching your repositories...').start();
179
+ try {
180
+ // Get authenticated user info
181
+ const { data: user } = await octokit.rest.users.getAuthenticated();
182
+ const username = user.login;
183
+
184
+ // Get user's repositories (both owned and contributed to)
185
+ const repos = [];
186
+
187
+ // Fetch user's own repositories
188
+ const userReposIterator = octokit.paginate.iterator(
189
+ octokit.rest.repos.listForAuthenticatedUser,
190
+ {
191
+ per_page: 100,
192
+ sort: 'updated',
193
+ direction: 'desc',
194
+ }
195
+ );
196
+
197
+ for await (const { data: userRepos } of userReposIterator) {
198
+ repos.push(
199
+ ...userRepos.map((repo) => ({
200
+ name: `${repo.owner.login}/${repo.name}`,
201
+ fullName: `${repo.owner.login}/${repo.name}`,
202
+ owner: repo.owner.login,
203
+ repoName: repo.name,
204
+ updatedAt: new Date(repo.updated_at),
205
+ pushedAt: new Date(repo.pushed_at || repo.updated_at),
206
+ isPersonal: repo.owner.login === username,
207
+ activityScore: 0, // Will be calculated based on user activity
208
+ }))
209
+ );
210
+
211
+ // Limit to 100 repositories to avoid excessive API calls
212
+ if (repos.length >= 100) break;
213
+ }
214
+
215
+ // Get recent user activity for each repository (limited to top 15 to avoid API rate limits)
216
+ const topRepos = repos.slice(0, 15);
217
+ spinner.text = 'Analyzing your recent activity...';
218
+
219
+ // Process repositories in parallel with rate limiting
220
+ await Promise.all(
221
+ topRepos.map(async (repo, index) => {
222
+ // Add delay to avoid hitting rate limits
223
+ await new Promise((resolve) => setTimeout(resolve, index * 100));
224
+
225
+ try {
226
+ // Check for user's recent commits
227
+ const { data: commits } = await octokit.rest.repos
228
+ .listCommits({
229
+ owner: repo.owner,
230
+ repo: repo.repoName,
231
+ author: username,
232
+ per_page: 100,
233
+ })
234
+ .catch(() => ({ data: [] }));
235
+
236
+ // Check for user's recent PRs
237
+ const { data: prs } = await octokit.rest.pulls
238
+ .list({
239
+ owner: repo.owner,
240
+ repo: repo.repoName,
241
+ state: 'all',
242
+ per_page: 100,
243
+ })
244
+ .catch(() => ({ data: [] }));
245
+
246
+ // Calculate activity score based on recency and count
247
+ const now = new Date();
248
+ let score = 0;
249
+
250
+ // Add points for recent commits
251
+ commits.forEach((commit) => {
252
+ const daysAgo = (now - new Date(commit.commit.author.date)) / (1000 * 60 * 60 * 24);
253
+ score += Math.max(30 - daysAgo, 0); // More points for more recent commits
254
+ });
255
+
256
+ // Add points for PRs authored or reviewed by user
257
+ prs.forEach((pr) => {
258
+ if (pr.user.login === username) {
259
+ const daysAgo = (now - new Date(pr.updated_at)) / (1000 * 60 * 60 * 24);
260
+ score += Math.max(20 - daysAgo, 0); // Points for authoring PRs
261
+ }
262
+ });
263
+
264
+ // Update the repository's activity score
265
+ repo.activityScore = score;
266
+ } catch (error) {
267
+ // Silently continue if we hit API limits or other issues
268
+ }
269
+ })
270
+ );
271
+
272
+ // Sort repositories by activity score first, then by pushed date
273
+ repos.sort((a, b) => {
274
+ if (a.activityScore !== b.activityScore) {
275
+ return b.activityScore - a.activityScore; // Higher score first
276
+ }
277
+ return b.pushedAt - a.pushedAt; // Then by most recent push
278
+ });
279
+
280
+ spinner.succeed(`Found ${repos.length} repositories, sorted by your recent activity`);
281
+ return repos;
282
+ } catch (error) {
283
+ spinner.fail('Failed to fetch repositories');
284
+ console.error(chalk.red(`Error: ${error.message}`));
285
+ return [];
286
+ }
287
+ }
288
+
157
289
  /**
158
290
  * Fetch closed pull requests from the repository
159
291
  * @param {string} owner - Repository owner
160
292
  * @param {string} repo - Repository name
161
- * @param {string} targetBranch - Target branch name
293
+ * @param {string} baseBranch - Base branch name
162
294
  * @returns {Promise<Array>} List of pull requests
163
295
  */
164
- async function fetchPullRequests(owner, repo) {
296
+ async function fetchPullRequests(owner, repo, baseBranch) {
165
297
  const spinner = ora('Fetching pull requests...').start();
166
298
  try {
167
299
  // Get the latest release PR first
@@ -179,10 +311,14 @@ async function fetchPullRequests(owner, repo) {
179
311
  });
180
312
 
181
313
  for await (const { data } of iterator) {
182
- // Filter PRs that are merged after the last release and not included in it
314
+ // Filter PRs that are merged after the last release, not included in it, and merged to the target branch
183
315
  const relevantPRs = data.filter((pr) => {
316
+ // Skip PRs that aren't merged
184
317
  if (!pr.merged_at) return false;
185
318
 
319
+ // Skip PRs that aren't targeting the specified branch
320
+ if (pr.base && pr.base.ref !== baseBranch) return false;
321
+
186
322
  const isAfterLastRelease = latestReleasePR
187
323
  ? new Date(pr.merged_at) >= new Date(latestReleasePR.merged_at)
188
324
  : true;
@@ -234,14 +370,16 @@ async function generateSummary(selectedPRs) {
234
370
  1.2 For each type, make each bullet point concise and easy to read and understand for non-tech people.
235
371
  1.3 Don't link the bullet points to a pull requests
236
372
  2. The last section should be a list of pull requests included in the release. Format: "#<number> - <title> by [@<author>](<authorUrl>) (<date>)".
373
+ 3. Don't add Release Summary title/heading.
237
374
 
238
375
  Pull Requests to summarize:
239
376
  ${JSON.stringify(prDetails, null, 2)}
240
377
 
241
378
  Keep the summary concise, clear, and focused on the user impact. Use professional but easy-to-understand language.`;
242
379
 
380
+ const model = program.opts().openaiModel || 'gpt-4o';
243
381
  const response = await openai.chat.completions.create({
244
- model: 'gpt-4o',
382
+ model,
245
383
  messages: [{ role: 'user', content: prompt }],
246
384
  temperature: 0.7,
247
385
  });
@@ -341,8 +479,18 @@ ${summary}`;
341
479
  }
342
480
 
343
481
  async function run() {
344
- // Initialize tokens sequentially
345
- const { githubToken, openaiToken } = await initializeTokens();
482
+ // Get command line options
483
+ const options = program.opts();
484
+
485
+ // Initialize GitHub token
486
+ const { githubToken } = await initializeTokens();
487
+
488
+ // Get OpenAI token from command line or fallback to configuration
489
+ let openaiToken = options.openaiKey;
490
+ if (!openaiToken) {
491
+ const tokens = await initializeTokens();
492
+ openaiToken = tokens.openaiToken;
493
+ }
346
494
 
347
495
  // Initialize clients with tokens
348
496
  octokit = new Octokit({
@@ -351,24 +499,95 @@ async function run() {
351
499
 
352
500
  openai = new OpenAI({
353
501
  apiKey: openaiToken,
502
+ baseURL: options.openaiBaseUrl,
354
503
  });
355
504
 
356
- const { owner, repo } = await inquirer.prompt([
505
+ // Fetch repositories the user has contributed to
506
+ const userRepos = await fetchUserRepositories();
507
+
508
+ // Prepare repository choices
509
+ const repoChoices =
510
+ userRepos.length > 0
511
+ ? userRepos.map((repo) => ({
512
+ name: repo.fullName + (repo.isPersonal ? ' (personal)' : ''),
513
+ value: { owner: repo.owner, repo: repo.repoName },
514
+ }))
515
+ : [];
516
+
517
+ // Add option for manual entry
518
+ repoChoices.push({ name: '-- Enter repository manually --', value: 'manual' });
519
+
520
+ let repoInfo = { owner: '', repo: '' };
521
+ const { repoSelection } = await inquirer.prompt([
522
+ {
523
+ type: 'list',
524
+ name: 'repoSelection',
525
+ message: 'Select a repository:',
526
+ choices: repoChoices,
527
+ pageSize: 5,
528
+ },
529
+ ]);
530
+
531
+ // Handle manual repository entry
532
+ if (repoSelection === 'manual') {
533
+ const manualEntry = await inquirer.prompt([
534
+ {
535
+ type: 'input',
536
+ name: 'owner',
537
+ message: 'Enter repository owner:',
538
+ validate: (input) => input.length > 0,
539
+ },
540
+ {
541
+ type: 'input',
542
+ name: 'repo',
543
+ message: 'Enter repository name:',
544
+ validate: (input) => input.length > 0,
545
+ },
546
+ ]);
547
+ repoInfo = manualEntry;
548
+ } else {
549
+ repoInfo = repoSelection;
550
+ }
551
+
552
+ const { owner, repo } = repoInfo;
553
+
554
+ const { sourceBranch, targetBranch } = await inquirer.prompt([
357
555
  {
358
556
  type: 'input',
359
- name: 'owner',
360
- message: 'Enter repository owner:',
557
+ name: 'sourceBranch',
558
+ message: 'Enter source branch name:',
559
+ default: 'staging',
361
560
  validate: (input) => input.length > 0,
362
561
  },
363
562
  {
364
563
  type: 'input',
365
- name: 'repo',
366
- message: 'Enter repository name:',
564
+ name: 'targetBranch',
565
+ message: 'Enter target branch name:',
566
+ default: 'main',
367
567
  validate: (input) => input.length > 0,
368
568
  },
369
569
  ]);
370
570
 
371
- const pulls = await fetchPullRequests(owner, repo);
571
+ // Get the latest release version for the repository
572
+ let suggestedVersion = '1.0.0';
573
+ try {
574
+ const { data: releases } = await octokit.rest.repos.listReleases({
575
+ owner,
576
+ repo,
577
+ per_page: 100,
578
+ });
579
+
580
+ if (releases && releases.length > 0) {
581
+ // Extract version from tag_name (removing any 'v' prefix)
582
+ const latestTag = releases[0].tag_name.replace(/^v/, '');
583
+ const [major, minor, patch] = latestTag.split('.');
584
+ suggestedVersion = `${major}.${minor}.${parseInt(patch) + 1}`;
585
+ }
586
+ } catch (error) {
587
+ console.log(chalk.yellow(`Could not fetch latest release version: ${error.message}`));
588
+ }
589
+
590
+ const pulls = await fetchPullRequests(owner, repo, sourceBranch);
372
591
 
373
592
  const { selectedPRs } = await inquirer.prompt([
374
593
  {
@@ -410,11 +629,12 @@ async function run() {
410
629
  console.log(chalk.cyan('\nSummary:'));
411
630
  console.log(summary);
412
631
 
413
- const { version, confirm, sourceBranch, targetBranch } = await inquirer.prompt([
632
+ const { version, confirm } = await inquirer.prompt([
414
633
  {
415
634
  type: 'input',
416
635
  name: 'version',
417
- message: 'Enter the version number for this release (e.g., 1.2.3):',
636
+ message: `Enter the version number for this release (suggested: ${suggestedVersion}):`,
637
+ default: suggestedVersion,
418
638
  validate: (input) => {
419
639
  // Validate semantic versioning format (x.y.z)
420
640
  const semverRegex = /^\d+\.\d+\.\d+$/;
@@ -429,20 +649,6 @@ async function run() {
429
649
  name: 'confirm',
430
650
  message: 'Would you like to create a release PR with this summary?',
431
651
  },
432
- {
433
- type: 'input',
434
- name: 'sourceBranch',
435
- message: 'Enter source branch name:',
436
- when: (answers) => answers.confirm,
437
- validate: (input) => input.length > 0,
438
- },
439
- {
440
- type: 'input',
441
- name: 'targetBranch',
442
- message: 'Enter target branch name:',
443
- when: (answers) => answers.confirm,
444
- validate: (input) => input.length > 0,
445
- },
446
652
  ]);
447
653
 
448
654
  if (confirm) {
@@ -459,9 +665,30 @@ async function run() {
459
665
  }
460
666
  }
461
667
 
668
+ const description = `AI-powered GitHub release automation tool
669
+
670
+ Options:
671
+ --openai-key <key> Set OpenAI API key directly (alternative to env/git config)
672
+ --openai-model <model> Set OpenAI model to use (default: "gpt-4")
673
+ Examples: gpt-4, gpt-3.5-turbo
674
+ --openai-base-url <url> Set custom OpenAI API base URL
675
+ Example: https://custom-openai-endpoint.com/v1
676
+
677
+ Environment Variables:
678
+ GITHUB_TOKEN GitHub personal access token
679
+ OPENAI_API_KEY OpenAI API key (if not using --openai-key)
680
+
681
+ Git Config:
682
+ github.token GitHub token in git config
683
+ openai.token OpenAI token in git config (if not using --openai-key)
684
+ `;
685
+
462
686
  program
463
687
  .name('create-app-release')
464
- .description('AI-powered GitHub release automation tool')
688
+ .description(description)
465
689
  .version(pkg.version)
690
+ .option('--openai-base-url <url>', 'Set custom OpenAI API base URL')
691
+ .option('--openai-model <model>', 'Set OpenAI model to use (default: "gpt-4")')
692
+ .option('--openai-key <key>', 'Set OpenAI API key directly (alternative to env/git config)')
466
693
  .action(run)
467
694
  .parse(process.argv);