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