git-coco 0.3.3 → 0.4.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 (38) hide show
  1. package/dist/commands/changelog/handler.d.ts +3 -0
  2. package/dist/commands/changelog/index.d.ts +10 -0
  3. package/dist/commands/changelog/options.d.ts +16 -0
  4. package/dist/commands/commit/handler.d.ts +3 -0
  5. package/dist/commands/commit/index.d.ts +10 -0
  6. package/dist/commands/commit/options.d.ts +15 -0
  7. package/dist/commands/types.d.ts +14 -0
  8. package/dist/index.d.ts +1 -3
  9. package/dist/index.esm.mjs +689 -470
  10. package/dist/index.esm.mjs.map +1 -1
  11. package/dist/index.js +689 -471
  12. package/dist/lib/langchain/executeChain.d.ts +6 -0
  13. package/dist/lib/langchain/prompts/changelog.d.ts +3 -0
  14. package/dist/lib/langchain/utils.d.ts +2 -2
  15. package/dist/lib/parsers/default/index.d.ts +2 -2
  16. package/dist/lib/parsers/default/utils/createDiffTree.d.ts +1 -0
  17. package/dist/lib/parsers/noResult.d.ts +4 -2
  18. package/dist/lib/simple-git/getChanges.d.ts +2 -2
  19. package/dist/lib/simple-git/getChangesByCommit.d.ts +2 -2
  20. package/dist/lib/simple-git/getCommitLogRange.d.ts +7 -0
  21. package/dist/lib/simple-git/getDiff.d.ts +2 -2
  22. package/dist/lib/simple-git/getStatus.d.ts +1 -1
  23. package/dist/lib/simple-git/getSummaryText.d.ts +1 -1
  24. package/dist/lib/types.d.ts +19 -8
  25. package/dist/lib/ui/editPrompt.d.ts +2 -0
  26. package/dist/lib/ui/editResult.d.ts +2 -0
  27. package/dist/lib/ui/generateAndReviewLoop.d.ts +16 -0
  28. package/dist/lib/ui/getUserReviewDecision.d.ts +2 -0
  29. package/dist/lib/ui/handleResult.d.ts +5 -0
  30. package/dist/lib/ui/helpers.d.ts +3 -0
  31. package/dist/lib/ui/logResult.d.ts +1 -0
  32. package/dist/lib/ui/logSuccess.d.ts +1 -0
  33. package/dist/stats.html +1 -1
  34. package/package.json +3 -3
  35. package/dist/commands/commit.d.ts +0 -16
  36. package/dist/lib/langchain/chains/llm.d.ts +0 -6
  37. package/dist/lib/ui.d.ts +0 -24
  38. package/dist/types.d.ts +0 -10
@@ -1,231 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  import yargs from 'yargs';
3
- import { simpleGit } from 'simple-git';
4
- import * as fs from 'fs';
5
- import * as os from 'os';
6
- import * as path from 'path';
7
- import path__default from 'path';
8
- import * as ini from 'ini';
9
- import { PromptTemplate } from 'langchain/prompts';
10
- import chalk from 'chalk';
11
- import { select, editor } from '@inquirer/prompts';
12
3
  import pQueue from 'p-queue';
13
4
  import { Document } from 'langchain/document';
14
5
  import { HuggingFaceInference } from 'langchain/llms/hf';
6
+ import { PromptTemplate } from 'langchain/prompts';
15
7
  import { loadSummarizationChain, LLMChain } from 'langchain/chains';
16
8
  import { OpenAI } from 'langchain/llms/openai';
17
9
  import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
18
10
  import { createTwoFilesPatch } from 'diff';
19
- import { minimatch } from 'minimatch';
20
11
  import GPT3NodeTokenizer from 'gpt3-tokenizer';
12
+ import chalk from 'chalk';
21
13
  import ora from 'ora';
22
14
  import now from 'performance-now';
23
15
  import prettyMilliseconds from 'pretty-ms';
24
-
25
- /**
26
- * Returns a new object with all undefined keys removed
27
- *
28
- * @param obj Object to remove undefined keys from
29
- * @returns
30
- */
31
- function removeUndefined(obj) {
32
- return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
33
- }
34
-
35
- /**
36
- * Load environment variables
37
- *
38
- * @param {Config} config
39
- * @returns {Config} Updated config
40
- **/
41
- function loadEnvConfig(config) {
42
- const envConfig = {
43
- model: process.env.COCO_MODEL || undefined,
44
- openAIApiKey: process.env.OPENAI_API_KEY || undefined,
45
- huggingFaceHubApiKey: process.env.HUGGINGFACE_HUB_API_KEY || undefined,
46
- tokenLimit: process.env.COCO_TOKEN_LIMIT
47
- ? parseInt(process.env.COCO_TOKEN_LIMIT)
48
- : undefined,
49
- prompt: process.env.COCO_PROMPT,
50
- mode: process.env.COCO_MODE,
51
- summarizePrompt: process.env.COCO_SUMMARIZE_PROMPT,
52
- ignoredFiles: process.env.COCO_IGNORED_FILES
53
- ? process.env.COCO_IGNORED_FILES.split(',')
54
- : undefined,
55
- ignoredExtensions: process.env.COCO_IGNORED_EXTENSIONS
56
- ? process.env.COCO_IGNORED_EXTENSIONS.split(',')
57
- : undefined,
58
- };
59
- config = { ...config, ...removeUndefined(envConfig) };
60
- return config;
61
- }
62
-
63
- /**
64
- * Load git profile config (from ~/.gitconfig)
65
- *
66
- * @param {Config} config
67
- * @returns {Config} Updated config
68
- **/
69
- function loadGitConfig(config) {
70
- const gitConfigPath = path.join(os.homedir(), '.gitconfig');
71
- if (fs.existsSync(gitConfigPath)) {
72
- const gitConfigRaw = fs.readFileSync(gitConfigPath, 'utf-8');
73
- const gitConfigParsed = ini.parse(gitConfigRaw);
74
- config = {
75
- ...config,
76
- model: gitConfigParsed.coco?.model || config.model,
77
- openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
78
- huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
79
- tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
80
- prompt: gitConfigParsed.coco?.prompt || config.prompt,
81
- mode: gitConfigParsed.coco?.mode || config.mode,
82
- temperature: gitConfigParsed.coco?.temperature || config.temperature,
83
- summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
84
- ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
85
- ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
86
- };
87
- }
88
- return config;
89
- }
90
-
91
- /**
92
- * Load .gitignore in project root
93
- *
94
- * @param {Config} config
95
- * @returns
96
- */
97
- function loadGitignore(config) {
98
- if (fs.existsSync('.gitignore')) {
99
- const gitignoreContent = fs.readFileSync('.gitignore', 'utf-8');
100
- config.ignoredFiles = [
101
- ...(config?.ignoredFiles || []),
102
- ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
103
- ];
104
- }
105
- return config;
106
- }
107
- /**
108
- * Load .ignore in project root
109
- *
110
- * @param {Config} config
111
- * @returns
112
- */
113
- function loadIgnore(config) {
114
- if (fs.existsSync('.ignore')) {
115
- const ignoreContent = fs.readFileSync('.ignore', 'utf-8');
116
- config.ignoredFiles = [
117
- ...(config?.ignoredFiles || []),
118
- ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
119
- ];
120
- }
121
- return config;
122
- }
123
-
124
- /**
125
- * Load project config
126
- *
127
- * @param {Config} config
128
- * @returns {Config} Updated config
129
- **/
130
- function loadProjectConfig(config) {
131
- if (fs.existsSync('.coco.config.json')) {
132
- const projectConfig = JSON.parse(fs.readFileSync('.coco.config.json', 'utf-8'));
133
- config = { ...config, ...projectConfig };
134
- }
135
- return config;
136
- }
137
-
138
- /**
139
- * Load XDG config
140
- *
141
- * @param {Config} config
142
- * @returns {Config} Updated config
143
- */
144
- function loadXDGConfig(config) {
145
- const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
146
- const xdgConfigPath = path.join(xdgConfigHome, 'coco', 'config.json');
147
- if (fs.existsSync(xdgConfigPath)) {
148
- const xdgConfig = JSON.parse(fs.readFileSync(xdgConfigPath, 'utf-8'));
149
- config = { ...config, ...xdgConfig };
150
- }
151
- return config;
152
- }
153
-
154
- const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
155
- Commit Messages must have a short description that is less than 50 characters followed by a newline character and then a more verbose detailed description.
156
-
157
- - Typically a hyphen or asterisk is used for the bullet
158
- - Write concisely using an informal tone
159
- - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
160
- - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
161
- - DO NOT use specific names or files from the code
162
- - Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
163
-
164
- """{summary}"""
165
-
166
- Commit:`;
167
- const inputVariables$1 = ['summary'];
168
- const COMMIT_PROMPT = new PromptTemplate({
169
- template: template$1,
170
- inputVariables: inputVariables$1,
171
- });
172
-
173
- const template = `GOAL: Use functional abstractions to summarize the following text
174
-
175
- RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
176
-
177
- TEXT:"""{text}"""
178
- `;
179
- const inputVariables = ['text'];
180
- const SUMMARIZE_PROMPT = new PromptTemplate({
181
- template,
182
- inputVariables,
183
- });
184
-
185
- /**
186
- * Default Config
187
- *
188
- * @type {Config}
189
- */
190
- const DEFAULT_CONFIG = {
191
- model: 'openai/gpt-4',
192
- verbose: false,
193
- tokenLimit: 1024,
194
- prompt: COMMIT_PROMPT.template,
195
- summarizePrompt: SUMMARIZE_PROMPT.template,
196
- temperature: 0.4,
197
- mode: 'stdout',
198
- ignoredFiles: ['package-lock.json'],
199
- ignoredExtensions: ['.map', '.lock'],
200
- };
201
- /**
202
- * Load application config
203
- *
204
- * Merge config from multiple sources.
205
- *
206
- * \* Order of precedence:
207
- * \* 1. Command line flags
208
- * \* 2. Environment variables
209
- * \* 3. Project config
210
- * \* 4. Git config
211
- * \* 5. XDG config
212
- * \* 6. .gitignore
213
- * \* 7. .ignore
214
- * \* 8. Default config
215
- *
216
- * @returns {Config} application config
217
- **/
218
- function loadConfig(argv = {}) {
219
- // Default config
220
- let config = DEFAULT_CONFIG;
221
- config = loadGitignore(config);
222
- config = loadIgnore(config);
223
- config = loadXDGConfig(config);
224
- config = loadGitConfig(config);
225
- config = loadProjectConfig(config);
226
- config = loadEnvConfig(config);
227
- return { ...config, ...argv };
228
- }
16
+ import * as path from 'path';
17
+ import path__default from 'path';
18
+ import { minimatch } from 'minimatch';
19
+ import * as fs from 'fs';
20
+ import * as os from 'os';
21
+ import * as ini from 'ini';
22
+ import { simpleGit } from 'simple-git';
23
+ import { editor, select } from '@inquirer/prompts';
229
24
 
230
25
  /**
231
26
  * Extract the path from a file path string.
@@ -368,6 +163,23 @@ class DiffTreeNode {
368
163
  getPath() {
369
164
  return this.path.join('/');
370
165
  }
166
+ print(indentation = 0) {
167
+ const indent = ' '.repeat(indentation);
168
+ let output = `${indent}- Path: ${this.getPath()}\n`;
169
+ if (this.files.length > 0) {
170
+ output += `${indent} Files:\n`;
171
+ for (const file of this.files) {
172
+ output += `${indent} - ${file.summary}\n`;
173
+ }
174
+ }
175
+ if (this.children.size > 0) {
176
+ output += `${indent} Children:\n`;
177
+ for (const [, child] of this.children) {
178
+ output += child.print(indentation + 4);
179
+ }
180
+ }
181
+ return output;
182
+ }
371
183
  }
372
184
  const createDiffTree = (changes) => {
373
185
  const root = new DiffTreeNode();
@@ -461,7 +273,7 @@ function getModel(name, key, fields) {
461
273
  * @param options
462
274
  * @returns
463
275
  */
464
- function getModelAPIKey(name, options) {
276
+ function getApiKeyForModel(name, options) {
465
277
  const [llm, model] = name.split(/\/(.*)/s);
466
278
  if (!model) {
467
279
  throw new Error(`Invalid model: ${name}`);
@@ -518,19 +330,47 @@ function validatePromptTemplate(text, inputVariables) {
518
330
  return true;
519
331
  }
520
332
 
521
- const parseDefaultFileDiff = async (nodeFile, git) => {
522
- return await git.diff(['--staged', nodeFile.filePath]);
523
- };
524
- const parseRenamedFileDiff = async (nodeFile, git, logger) => {
333
+ const template$2 = `GOAL: Use functional abstractions to summarize the following text
334
+
335
+ RULES: Avoid phrases like "this change", "this code", or "this function" etc. Instead refer to the function, variable, or class by name.
336
+
337
+ TEXT:"""{text}"""
338
+ `;
339
+ const inputVariables$2 = ['text'];
340
+ const SUMMARIZE_PROMPT = new PromptTemplate({
341
+ template: template$2,
342
+ inputVariables: inputVariables$2,
343
+ });
344
+
345
+ async function parseDefaultFileDiff(nodeFile, commit = '--staged', git) {
346
+ if (commit !== '--staged') {
347
+ return await git.diff([`${commit}~1..${commit}`, '--', nodeFile.filePath]);
348
+ }
349
+ return await git.diff([commit, nodeFile.filePath]);
350
+ }
351
+ async function parseRenamedFileDiff(nodeFile, commit, git, logger) {
525
352
  let result = '';
526
353
  const oldFilePath = nodeFile?.oldFilePath || nodeFile.filePath;
354
+ let previousCommitHash = 'HEAD';
355
+ let newCommitHash = '';
356
+ if (commit !== '--staged') {
357
+ try {
358
+ previousCommitHash = await git.revparse([`${commit}~1`]);
359
+ }
360
+ catch (err) {
361
+ logger.verbose(`Error getting previous commit hash for ${nodeFile.filePath}`, {
362
+ color: 'red',
363
+ });
364
+ }
365
+ newCommitHash = commit;
366
+ }
527
367
  try {
528
- const [headContent, indexContent] = await Promise.all([
529
- git.show([`HEAD:${oldFilePath}`]),
530
- git.show([`:${nodeFile.filePath}`]),
368
+ const [previousContent, newContent] = await Promise.all([
369
+ git.show([`${previousCommitHash}:${oldFilePath}`]),
370
+ git.show([`${newCommitHash}:${nodeFile.filePath}`]),
531
371
  ]);
532
- if (headContent !== indexContent) {
533
- result = createTwoFilesPatch(oldFilePath, nodeFile.filePath, headContent, indexContent, '', '', {
372
+ if (previousContent !== newContent) {
373
+ result = createTwoFilesPatch(oldFilePath, nodeFile.filePath, previousContent, newContent, '', '', {
534
374
  context: 3,
535
375
  });
536
376
  // remove the first 4 lines of the patch (they contain the old and new file names)
@@ -545,23 +385,23 @@ const parseRenamedFileDiff = async (nodeFile, git, logger) => {
545
385
  result = 'Error comparing file contents.';
546
386
  }
547
387
  return result;
548
- };
549
- const getDiff = async (nodeFile, { git, logger, }) => {
388
+ }
389
+ async function getDiff(nodeFile, commit, { git, logger, }) {
550
390
  if (nodeFile.status === 'deleted') {
551
391
  return 'This file has been deleted.';
552
392
  }
553
393
  if (nodeFile.status === 'renamed' && nodeFile.oldFilePath) {
554
- const renamedDiff = await parseRenamedFileDiff(nodeFile, git, logger);
394
+ const renamedDiff = await parseRenamedFileDiff(nodeFile, commit, git, logger);
555
395
  return renamedDiff;
556
396
  }
557
397
  // If not deleted or renamed, get the diff from the index
558
- const defaultDiff = await parseDefaultFileDiff(nodeFile, git);
398
+ const defaultDiff = await parseDefaultFileDiff(nodeFile, commit, git);
559
399
  return defaultDiff;
560
- };
400
+ }
561
401
 
562
402
  const MAX_TOKENS_PER_SUMMARY = 2048;
563
- const fileChangeParser = async (changes, { tokenizer, git, model, logger }) => {
564
- const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125, });
403
+ async function fileChangeParser({ changes, commit, options: { tokenizer, git, model, logger }, }) {
404
+ const textSplitter = getTextSplitter({ chunkSize: 2000, chunkOverlap: 125 });
565
405
  const summarizationChain = getChain(model, {
566
406
  type: 'map_reduce',
567
407
  combineMapPrompt: SUMMARIZE_PROMPT,
@@ -572,7 +412,7 @@ const fileChangeParser = async (changes, { tokenizer, git, model, logger }) => {
572
412
  logger.stopTimer('Created file hierarchy');
573
413
  // Collect diffs
574
414
  logger.startTimer().startSpinner(`Collecting Diffs...\n`, { color: 'blue' });
575
- const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, { git, logger }), tokenizer, logger);
415
+ const diffs = await collectDiffs(rootTreeNode, (path) => getDiff(path, commit, { git, logger }), tokenizer, logger);
576
416
  logger.stopSpinner('Diffs Collected').stopTimer();
577
417
  // Summarize diffs
578
418
  logger.startTimer();
@@ -581,36 +421,105 @@ const fileChangeParser = async (changes, { tokenizer, git, model, logger }) => {
581
421
  maxTokens: MAX_TOKENS_PER_SUMMARY,
582
422
  textSplitter,
583
423
  chain: summarizationChain,
584
- logger
424
+ logger,
585
425
  });
586
426
  logger.stopTimer(`\nSummary generated for ${changes.length} staged files`, { color: 'green' });
587
427
  return summary;
588
- };
428
+ }
589
429
 
590
- const llm = async ({ llm, prompt, variables }) => {
591
- if (!llm || !prompt || !variables) {
592
- throw new Error('The input parameters "llm", "prompt", and "variables" are all required.');
593
- }
594
- const chain = new LLMChain({ llm, prompt });
595
- let res;
596
- try {
597
- res = await chain.call(variables);
430
+ /**
431
+ * Wrapper around GPT3NodeTokenizer to handle default export.
432
+ *
433
+ * @see https://github.com/botisan-ai/gpt3-tokenizer/issues/18
434
+ *
435
+ * @returns {GPT3NodeTokenizer} The GPT3NodeTokenizer instance.
436
+ */
437
+ const getTokenizer = () => {
438
+ let tokenizer;
439
+ // eslint-disable-next-line
440
+ // @ts-ignore
441
+ if (GPT3NodeTokenizer.default) {
442
+ // eslint-disable-next-line
443
+ // @ts-ignore
444
+ tokenizer = new GPT3NodeTokenizer.default({ type: 'gpt3' });
598
445
  }
599
- catch (error) {
600
- if (error instanceof Error) {
601
- throw new Error(`LLMChain call error: ${error.message}`);
446
+ else {
447
+ tokenizer = new GPT3NodeTokenizer({ type: 'gpt3' });
448
+ }
449
+ return tokenizer;
450
+ };
451
+
452
+ class Logger {
453
+ constructor(config) {
454
+ this.config = config;
455
+ this.spinner = null;
456
+ }
457
+ log(message, options = { color: 'blue' }) {
458
+ let outputMessage = message;
459
+ if (options.color) {
460
+ outputMessage = chalk[options.color](outputMessage);
602
461
  }
462
+ console.log(outputMessage);
463
+ return this;
603
464
  }
604
- if (!res) {
605
- throw new Error('Empty response from LLMChain call');
465
+ verbose(message, options = {}) {
466
+ if (!this.config?.verbose) {
467
+ return this;
468
+ }
469
+ this.log(message, options);
470
+ return this;
606
471
  }
607
- if (res.error) {
608
- throw new Error(`LLMChain response error: ${res.error}`);
472
+ startTimer() {
473
+ this.timerStart = now();
474
+ return this;
609
475
  }
610
- return res.text.trim();
611
- };
476
+ stopTimer(message, options = { color: 'yellow' }) {
477
+ if (!this.config?.verbose || !this.timerStart) {
478
+ return this;
479
+ }
480
+ const elapsedTime = prettyMilliseconds(now() - this.timerStart);
481
+ let outputMessage = message
482
+ ? `${message} (⏲ ${elapsedTime})`
483
+ : `⏲ ${elapsedTime}`;
484
+ if (options.color) {
485
+ outputMessage = chalk[options.color](outputMessage);
486
+ }
487
+ console.log(outputMessage);
488
+ return this;
489
+ }
490
+ startSpinner(message, options = { color: 'green' }) {
491
+ const spinnerMessage = options.color ? chalk[options.color](message) : message;
492
+ this.spinner = ora(spinnerMessage).start();
493
+ return this;
494
+ }
495
+ stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
496
+ const spinnerMessage = options?.color ? chalk[options.color](message) : message;
497
+ this.spinner?.[options.mode || 'succeed'](spinnerMessage);
498
+ this.spinner = null;
499
+ return this;
500
+ }
501
+ }
502
+
503
+ const template$1 = `Write informative git commit message, in the imperative, based on the diffs & file changes provided in the "Diff Summary" section.
504
+ Commit Messages must have a short description that is less than 50 characters followed by a newline character and then a more verbose detailed description.
505
+
506
+ - Typically a hyphen or asterisk is used for the bullet
507
+ - Write concisely using an informal tone
508
+ - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
509
+ - DO NOT use phrases like "this commit", "this change", "this function", etc. Instead refer to the function, variable, or class by name
510
+ - DO NOT use specific names or files from the code
511
+ - Wrap variable, class, function, components, and dependency names in back ticks e.g. \`variable\`
512
+
513
+ """{summary}"""
612
514
 
613
- const getStatus = (file, location = 'index') => {
515
+ Commit:`;
516
+ const inputVariables$1 = ['summary'];
517
+ const COMMIT_PROMPT = new PromptTemplate({
518
+ template: template$1,
519
+ inputVariables: inputVariables$1,
520
+ });
521
+
522
+ function getStatus(file, location = 'index') {
614
523
  if ('index' in file && 'working_dir' in file) {
615
524
  const statusCode = file[location];
616
525
  switch (statusCode) {
@@ -642,11 +551,11 @@ const getStatus = (file, location = 'index') => {
642
551
  return 'unknown';
643
552
  }
644
553
  else {
645
- throw new Error("Invalid file type");
554
+ throw new Error('Invalid file type');
646
555
  }
647
- };
556
+ }
648
557
 
649
- const getSummaryText = (file, change) => {
558
+ function getSummaryText(file, change) {
650
559
  const status = change.status || getStatus(file);
651
560
  let filePath;
652
561
  if ('path' in file) {
@@ -656,13 +565,186 @@ const getSummaryText = (file, change) => {
656
565
  filePath = change?.filePath || file.file;
657
566
  }
658
567
  else {
659
- throw new Error("Invalid file type");
568
+ throw new Error('Invalid file type');
660
569
  }
661
570
  if (change.oldFilePath) {
662
571
  return `${status}: ${change.oldFilePath} -> ${filePath}`;
663
572
  }
664
573
  return `${status}: ${filePath}`;
574
+ }
575
+
576
+ /**
577
+ * Returns a new object with all undefined keys removed
578
+ *
579
+ * @param obj Object to remove undefined keys from
580
+ * @returns
581
+ */
582
+ function removeUndefined(obj) {
583
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined));
584
+ }
585
+
586
+ /**
587
+ * Load environment variables
588
+ *
589
+ * @param {Config} config
590
+ * @returns {Config} Updated config
591
+ **/
592
+ function loadEnvConfig(config) {
593
+ const envConfig = {
594
+ model: process.env.COCO_MODEL || undefined,
595
+ openAIApiKey: process.env.OPENAI_API_KEY || undefined,
596
+ huggingFaceHubApiKey: process.env.HUGGINGFACE_HUB_API_KEY || undefined,
597
+ tokenLimit: process.env.COCO_TOKEN_LIMIT
598
+ ? parseInt(process.env.COCO_TOKEN_LIMIT)
599
+ : undefined,
600
+ prompt: process.env.COCO_PROMPT,
601
+ mode: process.env.COCO_MODE,
602
+ summarizePrompt: process.env.COCO_SUMMARIZE_PROMPT,
603
+ ignoredFiles: process.env.COCO_IGNORED_FILES
604
+ ? process.env.COCO_IGNORED_FILES.split(',')
605
+ : undefined,
606
+ ignoredExtensions: process.env.COCO_IGNORED_EXTENSIONS
607
+ ? process.env.COCO_IGNORED_EXTENSIONS.split(',')
608
+ : undefined,
609
+ };
610
+ config = { ...config, ...removeUndefined(envConfig) };
611
+ return config;
612
+ }
613
+
614
+ /**
615
+ * Load git profile config (from ~/.gitconfig)
616
+ *
617
+ * @param {Config} config
618
+ * @returns {Config} Updated config
619
+ **/
620
+ function loadGitConfig(config) {
621
+ const gitConfigPath = path.join(os.homedir(), '.gitconfig');
622
+ if (fs.existsSync(gitConfigPath)) {
623
+ const gitConfigRaw = fs.readFileSync(gitConfigPath, 'utf-8');
624
+ const gitConfigParsed = ini.parse(gitConfigRaw);
625
+ config = {
626
+ ...config,
627
+ model: gitConfigParsed.coco?.model || config.model,
628
+ openAIApiKey: gitConfigParsed.coco?.openAIApiKey || config.openAIApiKey,
629
+ huggingFaceHubApiKey: gitConfigParsed.coco?.huggingFaceHubApiKey || config.huggingFaceHubApiKey,
630
+ tokenLimit: parseInt(gitConfigParsed.coco?.tokenLimit) || config.tokenLimit,
631
+ prompt: gitConfigParsed.coco?.prompt || config.prompt,
632
+ mode: gitConfigParsed.coco?.mode || config.mode,
633
+ temperature: gitConfigParsed.coco?.temperature || config.temperature,
634
+ summarizePrompt: gitConfigParsed.coco?.summarizePrompt || config.summarizePrompt,
635
+ ignoredFiles: gitConfigParsed.coco?.ignoredFiles || config.ignoredFiles,
636
+ ignoredExtensions: gitConfigParsed.coco?.ignoredExtensions || config.ignoredExtensions,
637
+ };
638
+ }
639
+ return config;
640
+ }
641
+
642
+ /**
643
+ * Load .gitignore in project root
644
+ *
645
+ * @param {Config} config
646
+ * @returns
647
+ */
648
+ function loadGitignore(config) {
649
+ if (fs.existsSync('.gitignore')) {
650
+ const gitignoreContent = fs.readFileSync('.gitignore', 'utf-8');
651
+ config.ignoredFiles = [
652
+ ...(config?.ignoredFiles || []),
653
+ ...gitignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
654
+ ];
655
+ }
656
+ return config;
657
+ }
658
+ /**
659
+ * Load .ignore in project root
660
+ *
661
+ * @param {Config} config
662
+ * @returns
663
+ */
664
+ function loadIgnore(config) {
665
+ if (fs.existsSync('.ignore')) {
666
+ const ignoreContent = fs.readFileSync('.ignore', 'utf-8');
667
+ config.ignoredFiles = [
668
+ ...(config?.ignoredFiles || []),
669
+ ...ignoreContent.split('\n').filter((line) => line.trim() !== '' && !line.startsWith('#')),
670
+ ];
671
+ }
672
+ return config;
673
+ }
674
+
675
+ /**
676
+ * Load project config
677
+ *
678
+ * @param {Config} config
679
+ * @returns {Config} Updated config
680
+ **/
681
+ function loadProjectConfig(config) {
682
+ if (fs.existsSync('.coco.config.json')) {
683
+ const projectConfig = JSON.parse(fs.readFileSync('.coco.config.json', 'utf-8'));
684
+ config = { ...config, ...projectConfig };
685
+ }
686
+ return config;
687
+ }
688
+
689
+ /**
690
+ * Load XDG config
691
+ *
692
+ * @param {Config} config
693
+ * @returns {Config} Updated config
694
+ */
695
+ function loadXDGConfig(config) {
696
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
697
+ const xdgConfigPath = path.join(xdgConfigHome, 'coco', 'config.json');
698
+ if (fs.existsSync(xdgConfigPath)) {
699
+ const xdgConfig = JSON.parse(fs.readFileSync(xdgConfigPath, 'utf-8'));
700
+ config = { ...config, ...xdgConfig };
701
+ }
702
+ return config;
703
+ }
704
+
705
+ /**
706
+ * Default Config
707
+ *
708
+ * @type {Config}
709
+ */
710
+ const DEFAULT_CONFIG = {
711
+ model: 'openai/gpt-4',
712
+ verbose: false,
713
+ tokenLimit: 1024,
714
+ summarizePrompt: SUMMARIZE_PROMPT.template,
715
+ temperature: 0.4,
716
+ mode: 'stdout',
717
+ ignoredFiles: ['package-lock.json'],
718
+ ignoredExtensions: ['.map', '.lock'],
665
719
  };
720
+ /**
721
+ * Load application config
722
+ *
723
+ * Merge config from multiple sources.
724
+ *
725
+ * \* Order of precedence:
726
+ * \* 1. Command line flags
727
+ * \* 2. Environment variables
728
+ * \* 3. Project config
729
+ * \* 4. Git config
730
+ * \* 5. XDG config
731
+ * \* 6. .gitignore
732
+ * \* 7. .ignore
733
+ * \* 8. Default config
734
+ *
735
+ * @returns {Config} application config
736
+ **/
737
+ function loadConfig(argv = {}) {
738
+ // Default config
739
+ let config = DEFAULT_CONFIG;
740
+ config = loadGitignore(config);
741
+ config = loadIgnore(config);
742
+ config = loadXDGConfig(config);
743
+ config = loadGitConfig(config);
744
+ config = loadProjectConfig(config);
745
+ config = loadEnvConfig(config);
746
+ return { ...config, ...argv };
747
+ }
666
748
 
667
749
  const config = loadConfig();
668
750
  const DEFAULT_IGNORED_FILES = config?.ignoredFiles?.length ? config.ignoredFiles : [];
@@ -720,7 +802,7 @@ async function getChanges({ git, options }) {
720
802
  };
721
803
  }
722
804
 
723
- const noResult = async ({ git, logger }) => {
805
+ async function noResult({ git, logger }) {
724
806
  const { staged, unstaged, untracked } = await getChanges({ git });
725
807
  const hasStaged = staged && staged.length > 0;
726
808
  const hasUnstaged = unstaged && unstaged.length > 0;
@@ -747,112 +829,117 @@ const noResult = async ({ git, logger }) => {
747
829
  else {
748
830
  logger.log('No repo changes detected. 👀', { color: 'blue' });
749
831
  }
750
- };
751
-
752
- async function createCommit(commitMsg, git) {
753
- return await git.commit(commitMsg);
754
832
  }
755
833
 
756
- const SEPERATOR = chalk.blue('----------------');
757
834
  const isInteractive = (argv) => {
758
835
  return argv?.mode === 'interactive' || argv.interactive;
759
836
  };
760
- const logCommit = (commit) => {
761
- console.log(`\n${chalk.bgBlue(chalk.bold('Proposed Commit:'))}\n${SEPERATOR}\n${commit}\n${SEPERATOR}\n`);
762
- };
763
- const logSuccess = () => {
764
- console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
765
- };
766
- const generateCommitMessageAndReviewLoop = async (changes, options) => {
767
- const { logger, model, git, tokenizer } = options;
768
- let summary = '';
769
- let commitMsg = '';
770
- let promptTemplate = options?.prompt || '';
771
- let modifyPrompt = false;
772
- // determine if we continue generating commit messages
837
+ const SEPERATOR = chalk.blue('----------------');
838
+
839
+ function logResult(result) {
840
+ console.log(`\n${chalk.bgBlue(chalk.bold('Proposed Commit:'))}\n${SEPERATOR}\n${result}\n${SEPERATOR}\n`);
841
+ }
842
+
843
+ async function editResult(result, options) {
844
+ if (options.openInEditor) {
845
+ return await editor({
846
+ message: 'Edit the commit message',
847
+ default: result,
848
+ waitForUseInput: false,
849
+ validate: (text) => (text ? true : 'Commit message cannot be empty'),
850
+ });
851
+ }
852
+ return result;
853
+ }
854
+
855
+ async function getUserReviewDecision() {
856
+ return await select({
857
+ message: 'Would you like to make any changes to the commit message?',
858
+ choices: [
859
+ {
860
+ name: '✨ Looks good!',
861
+ value: 'approve',
862
+ description: 'Commit staged changes with generated commit message',
863
+ },
864
+ {
865
+ name: '📝 Edit',
866
+ value: 'edit',
867
+ description: 'Edit the commit message before proceeding',
868
+ },
869
+ {
870
+ name: '🪶 Modify Prompt',
871
+ value: 'modifyPrompt',
872
+ description: 'Modify the prompt template and regenerate the commit message',
873
+ },
874
+ {
875
+ name: '🔄 Retry - Message Only',
876
+ value: 'retryMessageOnly',
877
+ description: 'Restart the function execution from generating the commit message',
878
+ },
879
+ {
880
+ name: '🔄 Retry - Full',
881
+ value: 'retryFull',
882
+ description: 'Restart the function execution from the beginning, regenerating both the diff summary and commit message',
883
+ },
884
+ {
885
+ name: '💣 Cancel',
886
+ value: 'cancel',
887
+ },
888
+ ],
889
+ });
890
+ }
891
+
892
+ async function editPrompt(options) {
893
+ return await editor({
894
+ message: 'Edit the prompt',
895
+ default: options.prompt?.length ? options.prompt : COMMIT_PROMPT.template,
896
+ waitForUseInput: false,
897
+ validate: (text) => validatePromptTemplate(text, COMMIT_PROMPT.inputVariables),
898
+ });
899
+ }
900
+
901
+ async function generateAndReviewLoop({ label, factory, parser, noResult, agent, options, }) {
902
+ const { logger } = options;
773
903
  let continueLoop = true;
904
+ let modifyPrompt = false;
905
+ let context = '';
906
+ let result = '';
907
+ const changes = await factory();
908
+ // if we don't have any changes, bail.
909
+ if (!changes || !changes.length) {
910
+ await noResult(options);
911
+ }
774
912
  while (continueLoop) {
775
- if (changes.length !== 0 && !summary.length) {
776
- logger.verbose(`\nChanged Files: \n ${changes.map(({ summary }) => summary).join('\n ')}`, {
777
- color: 'blue',
778
- });
779
- summary = await fileChangeParser(changes, { tokenizer, git, model, logger });
913
+ if (!context.length) {
914
+ context = await parser(changes, result, options);
780
915
  }
781
- // Handle empty summary
782
- if (!summary.length) {
783
- await noResult({ git, logger });
784
- process.exit(0);
916
+ // if we still don't have a context, bail.
917
+ if (!context.length) {
918
+ await noResult(options);
785
919
  }
786
- // Prompt user for commit template prompt, if necessary
787
920
  if (modifyPrompt) {
788
- promptTemplate = await editor({
789
- message: 'Edit the prompt',
790
- default: promptTemplate.length ? promptTemplate : COMMIT_PROMPT.template,
791
- waitForUseInput: false,
792
- validate: (text) => validatePromptTemplate(text, COMMIT_PROMPT.inputVariables),
793
- });
921
+ options.prompt = await editPrompt(options);
794
922
  }
795
- logger.startTimer().startSpinner(`Generating Commit Message\n`, {
923
+ logger.startTimer().startSpinner(`Generating ${label}\n`, {
796
924
  color: 'blue',
797
925
  });
798
- commitMsg = await llm({
799
- llm: model,
800
- prompt: getPrompt({
801
- template: promptTemplate,
802
- variables: COMMIT_PROMPT.inputVariables,
803
- fallback: COMMIT_PROMPT,
804
- }),
805
- variables: { summary },
806
- });
807
- if (!commitMsg) {
808
- logger.stopSpinner('💀 Failed to generate commit message.', {
926
+ result = await agent(context, options);
927
+ if (!result) {
928
+ logger.stopSpinner('💀 Agent failed to return content.', {
809
929
  mode: 'fail',
810
930
  color: 'red',
811
931
  });
812
932
  process.exit(0);
813
933
  }
814
934
  logger
815
- .stopSpinner('Generated Commit Message', {
935
+ .stopSpinner(`Generated ${label}`, {
816
936
  color: 'green',
817
937
  mode: 'succeed',
818
938
  })
819
939
  .stopTimer();
820
940
  if (options?.interactive) {
821
- logCommit(commitMsg);
822
- const reviewAnswer = await select({
823
- message: 'Would you like to make any changes to the commit message?',
824
- choices: [
825
- {
826
- name: '✨ Looks good!',
827
- value: 'approve',
828
- description: 'Commit staged changes with generated commit message',
829
- },
830
- {
831
- name: '📝 Edit',
832
- value: 'edit',
833
- description: 'Edit the commit message before proceeding',
834
- },
835
- {
836
- name: '🪶 Modify Prompt',
837
- value: 'modifyPrompt',
838
- description: 'Modify the prompt template and regenerate the commit message',
839
- },
840
- {
841
- name: '🔄 Retry - Message Only',
842
- value: 'retryMessageOnly',
843
- description: 'Restart the function execution from generating the commit message',
844
- },
845
- {
846
- name: '🔄 Retry - Full',
847
- value: 'retryFull',
848
- description: 'Restart the function execution from the beginning, regenerating both the diff summary and commit message',
849
- },
850
- {
851
- name: '💣 Cancel',
852
- value: 'cancel',
853
- },
854
- ],
855
- });
941
+ logResult(result);
942
+ const reviewAnswer = await getUserReviewDecision();
856
943
  if (reviewAnswer === 'cancel') {
857
944
  process.exit(0);
858
945
  }
@@ -860,133 +947,138 @@ const generateCommitMessageAndReviewLoop = async (changes, options) => {
860
947
  options.openInEditor = true;
861
948
  }
862
949
  if (reviewAnswer === 'retryFull') {
863
- summary = '';
864
- commitMsg = '';
865
- promptTemplate = '';
950
+ context = '';
951
+ result = '';
952
+ options.prompt = '';
866
953
  continue;
867
954
  }
868
955
  if (reviewAnswer === 'retryMessageOnly') {
869
956
  modifyPrompt = false;
870
- commitMsg = '';
957
+ result = '';
871
958
  continue;
872
959
  }
873
960
  if (reviewAnswer === 'modifyPrompt') {
874
961
  modifyPrompt = true;
875
- commitMsg = '';
962
+ result = '';
876
963
  continue;
877
964
  }
878
965
  }
879
- if (options.openInEditor) {
880
- commitMsg = await editor({
881
- message: 'Edit the commit message',
882
- default: commitMsg,
883
- waitForUseInput: false,
884
- validate: (text) => {
885
- if (!text) {
886
- return 'Commit message cannot be empty';
887
- }
888
- return true;
889
- },
890
- });
891
- }
966
+ // if we're here, we're done.
967
+ result = await editResult(result, options);
892
968
  continueLoop = false;
893
969
  }
894
- return commitMsg;
970
+ return result;
971
+ }
972
+
973
+ const executeChain = async ({ llm, prompt, variables }) => {
974
+ if (!llm || !prompt || !variables) {
975
+ throw new Error('The input parameters "llm", "prompt", and "variables" are all required.');
976
+ }
977
+ const chain = new LLMChain({ llm, prompt });
978
+ let res;
979
+ try {
980
+ res = await chain.call(variables);
981
+ }
982
+ catch (error) {
983
+ if (error instanceof Error) {
984
+ throw new Error(`LLMChain call error: ${error.message}`);
985
+ }
986
+ }
987
+ if (!res) {
988
+ throw new Error('Empty response from LLMChain call');
989
+ }
990
+ if (res.error) {
991
+ throw new Error(`LLMChain response error: ${res.error}`);
992
+ }
993
+ return res.text.trim();
994
+ };
995
+
996
+ async function createCommit(commitMsg, git) {
997
+ return await git.commit(commitMsg);
998
+ }
999
+
1000
+ const logSuccess = () => {
1001
+ console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
895
1002
  };
896
- const handleResult = async (commit, { mode, git }) => {
1003
+
1004
+ const handleResult = async (result, { mode, git }) => {
897
1005
  // Handle resulting commit message
898
1006
  switch (mode) {
899
1007
  case 'interactive':
900
- await createCommit(commit, git);
1008
+ await createCommit(result, git);
901
1009
  logSuccess();
902
1010
  break;
903
1011
  case 'stdout':
904
1012
  default:
905
- process.stdout.write(commit, 'utf8');
1013
+ process.stdout.write(result, 'utf8');
906
1014
  break;
907
1015
  }
908
1016
  process.exit(0);
909
1017
  };
910
1018
 
911
- /**
912
- * Wrapper around GPT3NodeTokenizer to handle default export.
913
- *
914
- * @see https://github.com/botisan-ai/gpt3-tokenizer/issues/18
915
- *
916
- * @returns {GPT3NodeTokenizer} The GPT3NodeTokenizer instance.
917
- */
918
- const getTokenizer = () => {
919
- let tokenizer;
920
- // eslint-disable-next-line
921
- // @ts-ignore
922
- if (GPT3NodeTokenizer.default) {
923
- // eslint-disable-next-line
924
- // @ts-ignore
925
- tokenizer = new GPT3NodeTokenizer.default({ type: 'gpt3' });
926
- }
927
- else {
928
- tokenizer = new GPT3NodeTokenizer({ type: 'gpt3' });
929
- }
930
- return tokenizer;
931
- };
932
-
933
- class Logger {
934
- constructor(config) {
935
- this.config = config;
936
- this.spinner = null;
937
- }
938
- log(message, options = { color: 'blue' }) {
939
- let outputMessage = message;
940
- if (options.color) {
941
- outputMessage = chalk[options.color](outputMessage);
942
- }
943
- console.log(outputMessage);
944
- return this;
945
- }
946
- verbose(message, options = {}) {
947
- if (!this.config?.verbose) {
948
- return this;
949
- }
950
- this.log(message, options);
951
- return this;
952
- }
953
- startTimer() {
954
- this.timerStart = now();
955
- return this;
956
- }
957
- stopTimer(message, options = { color: 'yellow' }) {
958
- if (!this.config?.verbose || !this.timerStart) {
959
- return this;
960
- }
961
- const elapsedTime = prettyMilliseconds(now() - this.timerStart);
962
- let outputMessage = message
963
- ? `${message} (⏲ ${elapsedTime})`
964
- : `⏲ ${elapsedTime}`;
965
- if (options.color) {
966
- outputMessage = chalk[options.color](outputMessage);
967
- }
968
- console.log(outputMessage);
969
- return this;
1019
+ const tokenizer = getTokenizer();
1020
+ const git$1 = simpleGit();
1021
+ async function handler$1(argv) {
1022
+ const options = loadConfig(argv);
1023
+ const logger = new Logger(options);
1024
+ const key = getApiKeyForModel(options.model, options);
1025
+ if (!key) {
1026
+ logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
1027
+ process.exit(1);
970
1028
  }
971
- startSpinner(message, options = { color: 'green' }) {
972
- const spinnerMessage = options.color ? chalk[options.color](message) : message;
973
- this.spinner = ora(spinnerMessage).start();
974
- return this;
1029
+ const model = getModel(options.model, key, {
1030
+ temperature: 0.4,
1031
+ maxConcurrency: 10,
1032
+ });
1033
+ const INTERACTIVE = isInteractive(options);
1034
+ async function factory() {
1035
+ const changes = await getChanges({ git: git$1 });
1036
+ return changes.staged;
975
1037
  }
976
- stopSpinner(message = '', options = { mode: 'succeed', color: 'green' }) {
977
- const spinnerMessage = options?.color ? chalk[options.color](message) : message;
978
- this.spinner?.[options.mode || 'succeed'](spinnerMessage);
979
- this.spinner = null;
980
- return this;
1038
+ async function parser(changes) {
1039
+ return await fileChangeParser({
1040
+ changes,
1041
+ commit: '--staged',
1042
+ options: { tokenizer, git: git$1, model, logger },
1043
+ });
981
1044
  }
1045
+ const commitMsg = await generateAndReviewLoop({
1046
+ label: 'Commit Message',
1047
+ factory,
1048
+ parser,
1049
+ agent: async (context, options) => {
1050
+ return await executeChain({
1051
+ llm: model,
1052
+ prompt: getPrompt({
1053
+ template: options.prompt,
1054
+ variables: COMMIT_PROMPT.inputVariables,
1055
+ fallback: COMMIT_PROMPT,
1056
+ }),
1057
+ variables: { summary: context },
1058
+ });
1059
+ },
1060
+ noResult: async () => {
1061
+ await noResult({ git: git$1, logger });
1062
+ process.exit(0);
1063
+ },
1064
+ options: {
1065
+ ...options,
1066
+ prompt: options.prompt || COMMIT_PROMPT.template,
1067
+ logger,
1068
+ interactive: INTERACTIVE,
1069
+ },
1070
+ });
1071
+ const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
1072
+ handleResult(commitMsg, {
1073
+ mode: MODE,
1074
+ git: git$1,
1075
+ });
982
1076
  }
983
1077
 
984
- // const argv = loadArgv()
985
- const tokenizer = getTokenizer();
986
- const git = simpleGit();
987
- const command = ['commit', '$0'];
988
- const description = 'Generate a commit message based on the diff summary';
989
- const builder = {
1078
+ /**
1079
+ * Command line options via yargs
1080
+ */
1081
+ const options$1 = {
990
1082
  model: { type: 'string', description: 'LLM/Model-Name' },
991
1083
  openAIApiKey: {
992
1084
  type: 'string',
@@ -1032,10 +1124,56 @@ const builder = {
1032
1124
  description: 'Ignored extensions',
1033
1125
  },
1034
1126
  };
1127
+ const builder$1 = (yargs) => {
1128
+ return yargs.options(options$1);
1129
+ };
1130
+
1131
+ var commit = {
1132
+ command: 'commit',
1133
+ desc: 'Generate commit message',
1134
+ builder: builder$1,
1135
+ handler: handler$1,
1136
+ options: options$1,
1137
+ };
1138
+
1139
+ const template = `Write informative git changelog, in the imperative, based on a series of individual messages.
1140
+
1141
+ - Typically a hyphen or asterisk is used for the bullet
1142
+ - Summarize dependency updates
1143
+
1144
+ """{summary}"""
1145
+
1146
+ Changelog:`;
1147
+ const inputVariables = ['summary'];
1148
+ const CHANGELOG_PROMPT = new PromptTemplate({
1149
+ template,
1150
+ inputVariables,
1151
+ });
1152
+
1153
+ async function getCommitLogRange(from, to, { noMerges, git }) {
1154
+ try {
1155
+ const output = await git.raw([
1156
+ 'log',
1157
+ `${from}..${to}`,
1158
+ '--pretty=format:%s',
1159
+ // Include '--no-merges' here if you want to exclude merge commits.
1160
+ noMerges ? '--no-merges' : null,
1161
+ ].filter(Boolean)); // filter(Boolean) removes any null values from the array
1162
+ const messages = output.split('\n').filter(Boolean);
1163
+ return messages;
1164
+ }
1165
+ catch (error) {
1166
+ // If there's an error, handle it appropriately
1167
+ console.error('Error getting commit messages:', error);
1168
+ throw error;
1169
+ }
1170
+ }
1171
+
1172
+ const git = simpleGit();
1035
1173
  async function handler(argv) {
1036
1174
  const options = loadConfig(argv);
1037
1175
  const logger = new Logger(options);
1038
- const key = getModelAPIKey(options.model, options);
1176
+ const key = getApiKeyForModel(options.model, options);
1039
1177
  if (!key) {
1040
1178
  logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
1041
1179
  process.exit(1);
@@ -1045,47 +1183,128 @@ async function handler(argv) {
1045
1183
  maxConcurrency: 10,
1046
1184
  });
1047
1185
  const INTERACTIVE = isInteractive(options);
1048
- const { staged: changes } = await getChanges({ git });
1049
- const commitMsg = await generateCommitMessageAndReviewLoop(changes, {
1050
- logger,
1051
- model,
1052
- git,
1053
- tokenizer,
1054
- prompt: options.prompt,
1055
- interactive: INTERACTIVE,
1056
- openInEditor: options.openInEditor,
1186
+ const [from, to] = options.range?.split(':');
1187
+ if (!from || !to) {
1188
+ logger.log(`Invalid range provided. Expected format is <from>:<to>`, { color: 'red' });
1189
+ process.exit(1);
1190
+ }
1191
+ async function factory() {
1192
+ const messages = await getCommitLogRange(from, to, { git, noMerges: true });
1193
+ return messages;
1194
+ }
1195
+ async function parser(messages) {
1196
+ const result = messages.join('\n');
1197
+ return result;
1198
+ }
1199
+ const changelogMsg = await generateAndReviewLoop({
1200
+ label: 'Changelog',
1201
+ factory,
1202
+ parser,
1203
+ agent: async (context, options) => {
1204
+ const prompt = getPrompt({
1205
+ template: options.prompt,
1206
+ variables: CHANGELOG_PROMPT.inputVariables,
1207
+ fallback: CHANGELOG_PROMPT,
1208
+ });
1209
+ return await executeChain({
1210
+ llm: model,
1211
+ prompt,
1212
+ variables: { summary: context },
1213
+ });
1214
+ },
1215
+ noResult: async () => {
1216
+ await noResult({ git, logger });
1217
+ process.exit(0);
1218
+ },
1219
+ options: {
1220
+ ...options,
1221
+ prompt: options.prompt || CHANGELOG_PROMPT.template,
1222
+ logger,
1223
+ interactive: INTERACTIVE,
1224
+ },
1057
1225
  });
1058
- const MODE = (options.interactive && 'interactive') ||
1059
- (options.commit && 'interactive') ||
1060
- options?.mode ||
1061
- 'stdout';
1062
- handleResult(commitMsg, {
1226
+ const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
1227
+ handleResult(changelogMsg, {
1063
1228
  mode: MODE,
1064
1229
  git,
1065
1230
  });
1066
1231
  }
1067
1232
 
1068
- var commit = /*#__PURE__*/Object.freeze({
1069
- __proto__: null,
1070
- builder: builder,
1071
- command: command,
1072
- description: description,
1073
- handler: handler
1074
- });
1233
+ /**
1234
+ * Command line options via yargs
1235
+ */
1236
+ const options = {
1237
+ range: {
1238
+ type: 'string',
1239
+ alias: 'r',
1240
+ description: 'Commit range e.g `HEAD~2:HEAD`',
1241
+ demandOption: true,
1242
+ },
1243
+ model: { type: 'string', description: 'LLM/Model-Name' },
1244
+ openAIApiKey: {
1245
+ type: 'string',
1246
+ description: 'OpenAI API Key',
1247
+ conflicts: 'huggingFaceHubApiKey',
1248
+ },
1249
+ huggingFaceHubApiKey: {
1250
+ type: 'string',
1251
+ description: 'HuggingFace Hub API Key',
1252
+ conflicts: 'openAIApiKey',
1253
+ },
1254
+ tokenLimit: { type: 'number', description: 'Token limit' },
1255
+ prompt: {
1256
+ type: 'string',
1257
+ alias: 'p',
1258
+ description: 'Prompt for llm',
1259
+ },
1260
+ i: {
1261
+ type: 'boolean',
1262
+ alias: 'interactive',
1263
+ description: 'Toggle interactive mode',
1264
+ },
1265
+ e: {
1266
+ type: 'boolean',
1267
+ alias: 'edit',
1268
+ description: 'Open generated changelog message in editor before proceeding',
1269
+ },
1270
+ summarizePrompt: {
1271
+ type: 'string',
1272
+ description: 'Prompt for summarizing large files',
1273
+ },
1274
+ ignoredFiles: {
1275
+ type: 'array',
1276
+ description: 'Ignored files',
1277
+ },
1278
+ ignoredExtensions: {
1279
+ type: 'array',
1280
+ description: 'Ignored extensions',
1281
+ },
1282
+ };
1283
+ const builder = (yargs) => {
1284
+ return yargs.options(options);
1285
+ };
1286
+
1287
+ var changelog = {
1288
+ command: 'changelog',
1289
+ desc: 'Generate a changelog from a commit range',
1290
+ builder,
1291
+ handler,
1292
+ options,
1293
+ };
1075
1294
 
1076
1295
  yargs
1077
1296
  .scriptName('coco')
1078
- .commandDir('./commands', {
1079
- extensions: ['ts'],
1080
- })
1297
+ .usage('$0 <cmd> [args]')
1298
+ .command([commit.command, '$0'], commit.desc,
1299
+ // TODO: fix type on builder
1300
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1301
+ // @ts-ignore
1302
+ commit.builder, commit.handler)
1303
+ .command(changelog.command, changelog.desc,
1304
+ // TODO: fix type on builder
1305
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1306
+ // @ts-ignore
1307
+ changelog.builder, changelog.handler)
1081
1308
  .demandCommand()
1082
- .strict()
1083
- .option('h', { alias: 'help' })
1084
- .option('v', {
1085
- alias: 'verbose',
1086
- type: 'boolean',
1087
- description: 'Run with verbose logging',
1088
- }).argv;
1089
-
1090
- export { commit, loadConfig };
1309
+ .help().argv;
1091
1310
  //# sourceMappingURL=index.esm.mjs.map