git-coco 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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]);
356
+ const template$1 = `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$1 = ['text'];
363
+ const SUMMARIZE_PROMPT = new prompts.PromptTemplate({
364
+ template: template$1,
365
+ inputVariables: inputVariables$1,
366
+ });
367
+
368
+ const parseDefaultFileDiff = async (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]);
546
373
  };
547
- const parseRenamedFileDiff = async (nodeFile, git, logger) => {
374
+ const parseRenamedFileDiff = async (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)
@@ -569,22 +409,22 @@ const parseRenamedFileDiff = async (nodeFile, git, logger) => {
569
409
  }
570
410
  return result;
571
411
  };
572
- const getDiff = async (nodeFile, { git, logger, }) => {
412
+ const getDiff = async (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,34 +444,103 @@ 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);
621
- }
622
- catch (error) {
623
- if (error instanceof Error) {
624
- throw new Error(`LLMChain call error: ${error.message}`);
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' });
468
+ }
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 = `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 = ['summary'];
540
+ const COMMIT_PROMPT = new prompts.PromptTemplate({
541
+ template,
542
+ inputVariables,
543
+ });
635
544
 
636
545
  const getStatus = (file, location = 'index') => {
637
546
  if ('index' in file && 'working_dir' in file) {
@@ -687,6 +596,180 @@ const getSummaryText = (file, change) => {
687
596
  return `${status}: ${filePath}`;
688
597
  };
689
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
+ prompt: COMMIT_PROMPT.template,
738
+ summarizePrompt: SUMMARIZE_PROMPT.template,
739
+ temperature: 0.4,
740
+ mode: 'stdout',
741
+ ignoredFiles: ['package-lock.json'],
742
+ ignoredExtensions: ['.map', '.lock'],
743
+ };
744
+ /**
745
+ * Load application config
746
+ *
747
+ * Merge config from multiple sources.
748
+ *
749
+ * \* Order of precedence:
750
+ * \* 1. Command line flags
751
+ * \* 2. Environment variables
752
+ * \* 3. Project config
753
+ * \* 4. Git config
754
+ * \* 5. XDG config
755
+ * \* 6. .gitignore
756
+ * \* 7. .ignore
757
+ * \* 8. Default config
758
+ *
759
+ * @returns {Config} application config
760
+ **/
761
+ function loadConfig(argv = {}) {
762
+ // Default config
763
+ let config = DEFAULT_CONFIG;
764
+ config = loadGitignore(config);
765
+ config = loadIgnore(config);
766
+ config = loadXDGConfig(config);
767
+ config = loadGitConfig(config);
768
+ config = loadProjectConfig(config);
769
+ config = loadEnvConfig(config);
770
+ return { ...config, ...argv };
771
+ }
772
+
690
773
  const config = loadConfig();
691
774
  const DEFAULT_IGNORED_FILES = config?.ignoredFiles?.length ? config.ignoredFiles : [];
692
775
  const DEFAULT_IGNORED_EXTENSIONS = config?.ignoredExtensions?.length ? config.ignoredExtensions : [];
@@ -743,7 +826,7 @@ async function getChanges({ git, options }) {
743
826
  };
744
827
  }
745
828
 
746
- const noResult = async ({ git, logger }) => {
829
+ async function noResult({ git, logger }) {
747
830
  const { staged, unstaged, untracked } = await getChanges({ git });
748
831
  const hasStaged = staged && staged.length > 0;
749
832
  const hasUnstaged = unstaged && unstaged.length > 0;
@@ -770,65 +853,103 @@ const noResult = async ({ git, logger }) => {
770
853
  else {
771
854
  logger.log('No repo changes detected. 👀', { color: 'blue' });
772
855
  }
773
- };
774
-
775
- async function createCommit(commitMsg, git) {
776
- return await git.commit(commitMsg);
777
856
  }
778
857
 
779
- const SEPERATOR = chalk.blue('----------------');
780
858
  const isInteractive = (argv) => {
781
859
  return argv?.mode === 'interactive' || argv.interactive;
782
860
  };
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
861
+ const SEPERATOR = chalk.blue('----------------');
862
+
863
+ function logResult(result) {
864
+ console.log(`\n${chalk.bgBlue(chalk.bold('Proposed Commit:'))}\n${SEPERATOR}\n${result}\n${SEPERATOR}\n`);
865
+ }
866
+
867
+ async function editResult(result, options) {
868
+ if (options.openInEditor) {
869
+ return await prompts$1.editor({
870
+ message: 'Edit the commit message',
871
+ default: result,
872
+ waitForUseInput: false,
873
+ validate: (text) => (text ? true : 'Commit message cannot be empty'),
874
+ });
875
+ }
876
+ return result;
877
+ }
878
+
879
+ async function getUserReviewDecision() {
880
+ return await prompts$1.select({
881
+ message: 'Would you like to make any changes to the commit message?',
882
+ choices: [
883
+ {
884
+ name: '✨ Looks good!',
885
+ value: 'approve',
886
+ description: 'Commit staged changes with generated commit message',
887
+ },
888
+ {
889
+ name: '📝 Edit',
890
+ value: 'edit',
891
+ description: 'Edit the commit message before proceeding',
892
+ },
893
+ {
894
+ name: '🪶 Modify Prompt',
895
+ value: 'modifyPrompt',
896
+ description: 'Modify the prompt template and regenerate the commit message',
897
+ },
898
+ {
899
+ name: '🔄 Retry - Message Only',
900
+ value: 'retryMessageOnly',
901
+ description: 'Restart the function execution from generating the commit message',
902
+ },
903
+ {
904
+ name: '🔄 Retry - Full',
905
+ value: 'retryFull',
906
+ description: 'Restart the function execution from the beginning, regenerating both the diff summary and commit message',
907
+ },
908
+ {
909
+ name: '💣 Cancel',
910
+ value: 'cancel',
911
+ },
912
+ ],
913
+ });
914
+ }
915
+
916
+ async function editPrompt(options) {
917
+ return await prompts$1.editor({
918
+ message: 'Edit the prompt',
919
+ default: options.prompt?.length ? options.prompt : COMMIT_PROMPT.template,
920
+ waitForUseInput: false,
921
+ validate: (text) => validatePromptTemplate(text, COMMIT_PROMPT.inputVariables),
922
+ });
923
+ }
924
+
925
+ async function generateAndReviewLoop({ factory, parser, noResult, agent, options, }) {
926
+ const { logger } = options;
796
927
  let continueLoop = true;
928
+ let modifyPrompt = false;
929
+ let context = '';
930
+ let result = '';
931
+ const changes = await factory();
932
+ // if we don't have any changes, bail.
933
+ if (!changes || !changes.length) {
934
+ await noResult(options);
935
+ }
797
936
  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 });
937
+ if (!context.length) {
938
+ context = await parser(changes, result, options);
803
939
  }
804
- // Handle empty summary
805
- if (!summary.length) {
806
- await noResult({ git, logger });
807
- process.exit(0);
940
+ // if we still don't have a context, bail.
941
+ if (!context.length) {
942
+ await noResult(options);
808
943
  }
809
- // Prompt user for commit template prompt, if necessary
810
944
  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
- });
945
+ options.prompt = await editPrompt(options);
817
946
  }
818
- logger.startTimer().startSpinner(`Generating Commit Message\n`, {
947
+ logger.startTimer().startSpinner(`Generating Message\n`, {
819
948
  color: 'blue',
820
949
  });
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.', {
950
+ result = await agent(context, options);
951
+ if (!result) {
952
+ logger.stopSpinner('💀 Agent failed to generate message.', {
832
953
  mode: 'fail',
833
954
  color: 'red',
834
955
  });
@@ -841,41 +962,8 @@ const generateCommitMessageAndReviewLoop = async (changes, options) => {
841
962
  })
842
963
  .stopTimer();
843
964
  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
- });
965
+ logResult(result);
966
+ const reviewAnswer = await getUserReviewDecision();
879
967
  if (reviewAnswer === 'cancel') {
880
968
  process.exit(0);
881
969
  }
@@ -883,133 +971,136 @@ const generateCommitMessageAndReviewLoop = async (changes, options) => {
883
971
  options.openInEditor = true;
884
972
  }
885
973
  if (reviewAnswer === 'retryFull') {
886
- summary = '';
887
- commitMsg = '';
888
- promptTemplate = '';
974
+ context = '';
975
+ result = '';
976
+ options.prompt = '';
889
977
  continue;
890
978
  }
891
979
  if (reviewAnswer === 'retryMessageOnly') {
892
980
  modifyPrompt = false;
893
- commitMsg = '';
981
+ result = '';
894
982
  continue;
895
983
  }
896
984
  if (reviewAnswer === 'modifyPrompt') {
897
985
  modifyPrompt = true;
898
- commitMsg = '';
986
+ result = '';
899
987
  continue;
900
988
  }
901
989
  }
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
- }
990
+ // if we're here, we're done.
991
+ result = await editResult(result, options);
915
992
  continueLoop = false;
916
993
  }
917
- return commitMsg;
994
+ return result;
995
+ }
996
+
997
+ const executeChain = async ({ llm, prompt, variables }) => {
998
+ if (!llm || !prompt || !variables) {
999
+ throw new Error('The input parameters "llm", "prompt", and "variables" are all required.');
1000
+ }
1001
+ const chain = new chains.LLMChain({ llm, prompt });
1002
+ let res;
1003
+ try {
1004
+ res = await chain.call(variables);
1005
+ }
1006
+ catch (error) {
1007
+ if (error instanceof Error) {
1008
+ throw new Error(`LLMChain call error: ${error.message}`);
1009
+ }
1010
+ }
1011
+ if (!res) {
1012
+ throw new Error('Empty response from LLMChain call');
1013
+ }
1014
+ if (res.error) {
1015
+ throw new Error(`LLMChain response error: ${res.error}`);
1016
+ }
1017
+ return res.text.trim();
918
1018
  };
919
- const handleResult = async (commit, { mode, git }) => {
1019
+
1020
+ async function createCommit(commitMsg, git) {
1021
+ return await git.commit(commitMsg);
1022
+ }
1023
+
1024
+ const logSuccess = () => {
1025
+ console.log(chalk.green(chalk.bold('\nAll set! 🦾🤖')));
1026
+ };
1027
+
1028
+ const handleResult = async (result, { mode, git }) => {
920
1029
  // Handle resulting commit message
921
1030
  switch (mode) {
922
1031
  case 'interactive':
923
- await createCommit(commit, git);
1032
+ await createCommit(result, git);
924
1033
  logSuccess();
925
1034
  break;
926
1035
  case 'stdout':
927
1036
  default:
928
- process.stdout.write(commit, 'utf8');
1037
+ process.stdout.write(result, 'utf8');
929
1038
  break;
930
1039
  }
931
1040
  process.exit(0);
932
1041
  };
933
1042
 
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;
1043
+ const tokenizer = getTokenizer();
1044
+ const git = simpleGit.simpleGit();
1045
+ async function handler(argv) {
1046
+ const options = loadConfig(argv);
1047
+ const logger = new Logger(options);
1048
+ const key = getApiKeyForModel(options.model, options);
1049
+ if (!key) {
1050
+ logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
1051
+ process.exit(1);
993
1052
  }
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;
1053
+ const model = getModel(options.model, key, {
1054
+ temperature: 0.4,
1055
+ maxConcurrency: 10,
1056
+ });
1057
+ const INTERACTIVE = isInteractive(options);
1058
+ async function factory() {
1059
+ const changes = await getChanges({ git });
1060
+ return changes.staged;
998
1061
  }
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;
1062
+ async function parser(changes) {
1063
+ return await fileChangeParser({
1064
+ changes,
1065
+ commit: '--staged',
1066
+ options: { tokenizer, git, model, logger },
1067
+ });
1004
1068
  }
1069
+ const commitMsg = await generateAndReviewLoop({
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, logger });
1085
+ process.exit(0);
1086
+ },
1087
+ options: {
1088
+ ...options,
1089
+ logger,
1090
+ interactive: INTERACTIVE,
1091
+ },
1092
+ });
1093
+ const MODE = (INTERACTIVE && 'interactive') || (options.commit && 'interactive') || options?.mode || 'stdout';
1094
+ handleResult(commitMsg, {
1095
+ mode: MODE,
1096
+ git,
1097
+ });
1005
1098
  }
1006
1099
 
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 = {
1100
+ /**
1101
+ * Command line options via yargs
1102
+ */
1103
+ const options = {
1013
1104
  model: { type: 'string', description: 'LLM/Model-Name' },
1014
1105
  openAIApiKey: {
1015
1106
  type: 'string',
@@ -1055,60 +1146,20 @@ const builder = {
1055
1146
  description: 'Ignored extensions',
1056
1147
  },
1057
1148
  };
1058
- async function handler(argv) {
1059
- const options = loadConfig(argv);
1060
- const logger = new Logger(options);
1061
- const key = getModelAPIKey(options.model, options);
1062
- if (!key) {
1063
- logger.log(`No API Key found. 🗝️🚪`, { color: 'red' });
1064
- process.exit(1);
1065
- }
1066
- const model = getModel(options.model, key, {
1067
- temperature: 0.4,
1068
- maxConcurrency: 10,
1069
- });
1070
- 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,
1080
- });
1081
- const MODE = (options.interactive && 'interactive') ||
1082
- (options.commit && 'interactive') ||
1083
- options?.mode ||
1084
- 'stdout';
1085
- handleResult(commitMsg, {
1086
- mode: MODE,
1087
- git,
1088
- });
1089
- }
1090
-
1091
- var commit = /*#__PURE__*/Object.freeze({
1092
- __proto__: null,
1093
- builder: builder,
1094
- command: command,
1095
- description: description,
1096
- handler: handler
1097
- });
1149
+ const builder = (yargs) => {
1150
+ return yargs.options(options);
1151
+ };
1098
1152
 
1099
- yargs
1100
- .scriptName('coco')
1101
- .commandDir('./commands', {
1102
- extensions: ['ts'],
1103
- })
1104
- .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;
1153
+ var commit = {
1154
+ command: 'commit',
1155
+ desc: 'Generate commit message',
1156
+ builder,
1157
+ handler,
1158
+ options,
1159
+ };
1112
1160
 
1113
- exports.commit = commit;
1114
- exports.loadConfig = loadConfig;
1161
+ yargs.scriptName('coco').usage('$0 <cmd> [args]').command([commit.command, '$0'], commit.desc,
1162
+ // TODO: fix type on builder
1163
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
1164
+ // @ts-ignore
1165
+ commit.builder, commit.handler).argv;