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