kodevu 0.1.11 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodevu",
3
- "version": "0.1.11",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "Poll SVN revisions or Git commits, send each change diff to a reviewer CLI, and write configurable review reports.",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "node src/index.js",
16
- "check": "node --check src/index.js && node --check src/config.js && node --check src/review-runner.js && node --check src/svn-client.js && node --check src/git-client.js && node --check src/vcs-client.js && node --check src/shell.js"
16
+ "check": "node --check src/index.js && node --check src/config.js && node --check src/review-runner.js && node --check src/svn-client.js && node --check src/git-client.js && node --check src/vcs-client.js && node --check src/shell.js && node --check src/progress-ui.js"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=20"
@@ -0,0 +1,206 @@
1
+ import readline from "node:readline";
2
+
3
+ const SPINNER_FRAMES = ["|", "/", "-", "\\"];
4
+ const DEFAULT_BAR_WIDTH = 24;
5
+
6
+ function clampProgress(value) {
7
+ if (!Number.isFinite(value)) {
8
+ return 0;
9
+ }
10
+
11
+ return Math.max(0, Math.min(1, value));
12
+ }
13
+
14
+ function buildBar(progress, width) {
15
+ const safeProgress = clampProgress(progress);
16
+ const filled = Math.max(Math.round(safeProgress * width), safeProgress > 0 ? 1 : 0);
17
+
18
+ if (filled >= width) {
19
+ return `[${"=".repeat(width)}]`;
20
+ }
21
+
22
+ if (filled === 0) {
23
+ return `[${" ".repeat(width)}]`;
24
+ }
25
+
26
+ return `[${"=".repeat(Math.max(filled - 1, 0))}>${" ".repeat(width - filled)}]`;
27
+ }
28
+
29
+ class ProgressItem {
30
+ constructor(display, label, options = {}) {
31
+ this.display = display;
32
+ this.label = label;
33
+ this.barWidth = options.barWidth || DEFAULT_BAR_WIDTH;
34
+ this.progress = 0;
35
+ this.stage = "";
36
+ this.active = false;
37
+ this.lastFallbackLine = "";
38
+ }
39
+
40
+ start(stage = "starting") {
41
+ this.stage = stage;
42
+ this.active = true;
43
+
44
+ if (!this.display.enabled) {
45
+ this.writeFallback(`... ${this.label}: ${stage}`);
46
+ return;
47
+ }
48
+
49
+ this.display.start();
50
+ this.display.render();
51
+ }
52
+
53
+ update(progress, stage) {
54
+ this.progress = clampProgress(progress);
55
+
56
+ if (stage) {
57
+ this.stage = stage;
58
+ }
59
+
60
+ if (!this.display.enabled) {
61
+ this.writeFallback(`... ${this.label}: ${this.stage}`);
62
+ return;
63
+ }
64
+
65
+ this.display.render();
66
+ }
67
+
68
+ log(message) {
69
+ this.display.log(message);
70
+ }
71
+
72
+ succeed(message) {
73
+ this.finish("[done]", 1, message || `${this.label} complete`);
74
+ }
75
+
76
+ fail(message) {
77
+ this.finish("[fail]", this.progress, message || `${this.label} failed`);
78
+ }
79
+
80
+ finish(prefix, progress, message) {
81
+ this.progress = clampProgress(progress);
82
+ this.active = false;
83
+ this.display.stopIfIdle();
84
+ this.display.writeStaticLine(
85
+ `${prefix} ${this.label} ${buildBar(this.progress, this.barWidth)} ${Math.round(this.progress * 100)
86
+ .toString()
87
+ .padStart(3, " ")}% ${message}`
88
+ );
89
+ }
90
+
91
+ renderLine(frameIndex) {
92
+ const spinner = SPINNER_FRAMES[frameIndex];
93
+ const bar = buildBar(this.progress, this.barWidth);
94
+ const pct = `${Math.round(this.progress * 100)}`.padStart(3, " ");
95
+ return `${spinner} ${this.label} ${bar} ${pct}% ${this.stage}`;
96
+ }
97
+
98
+ writeFallback(line) {
99
+ if (line === this.lastFallbackLine) {
100
+ return;
101
+ }
102
+
103
+ this.lastFallbackLine = line;
104
+ this.display.stream.write(`${line}\n`);
105
+ }
106
+ }
107
+
108
+ export class ProgressDisplay {
109
+ constructor(options = {}) {
110
+ this.stream = options.stream || process.stdout;
111
+ this.enabled = Boolean(this.stream.isTTY);
112
+ this.frameIndex = 0;
113
+ this.timer = null;
114
+ this.items = [];
115
+ this.renderedLineCount = 0;
116
+ }
117
+
118
+ createItem(label, options = {}) {
119
+ const item = new ProgressItem(this, label, options);
120
+ this.items.push(item);
121
+ return item;
122
+ }
123
+
124
+ start() {
125
+ if (!this.enabled || this.timer) {
126
+ return;
127
+ }
128
+
129
+ this.timer = setInterval(() => {
130
+ this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
131
+ this.render();
132
+ }, 120);
133
+ }
134
+
135
+ log(message) {
136
+ if (!this.enabled) {
137
+ this.stream.write(`${message}\n`);
138
+ return;
139
+ }
140
+
141
+ this.clearRender();
142
+ this.stream.write(`${message}\n`);
143
+ this.render();
144
+ }
145
+
146
+ writeStaticLine(message) {
147
+ if (!this.enabled) {
148
+ this.stream.write(`${message}\n`);
149
+ return;
150
+ }
151
+
152
+ this.clearRender();
153
+ this.stream.write(`${message}\n`);
154
+ this.render();
155
+ }
156
+
157
+ render() {
158
+ if (!this.enabled) {
159
+ return;
160
+ }
161
+
162
+ const activeItems = this.items.filter((item) => item.active);
163
+ this.clearRender();
164
+
165
+ if (activeItems.length === 0) {
166
+ this.renderedLineCount = 0;
167
+ return;
168
+ }
169
+
170
+ const lines = activeItems.map((item) => item.renderLine(this.frameIndex));
171
+ this.stream.write(lines.join("\n"));
172
+ this.renderedLineCount = lines.length;
173
+ }
174
+
175
+ clearRender() {
176
+ if (!this.enabled || this.renderedLineCount === 0) {
177
+ return;
178
+ }
179
+
180
+ readline.moveCursor(this.stream, 0, -Math.max(this.renderedLineCount - 1, 0));
181
+
182
+ for (let index = 0; index < this.renderedLineCount; index += 1) {
183
+ readline.clearLine(this.stream, 0);
184
+ readline.cursorTo(this.stream, 0);
185
+
186
+ if (index < this.renderedLineCount - 1) {
187
+ readline.moveCursor(this.stream, 0, 1);
188
+ }
189
+ }
190
+
191
+ readline.moveCursor(this.stream, 0, -Math.max(this.renderedLineCount - 1, 0));
192
+ readline.cursorTo(this.stream, 0);
193
+ this.renderedLineCount = 0;
194
+ }
195
+
196
+ stopIfIdle() {
197
+ if (this.items.some((item) => item.active)) {
198
+ return;
199
+ }
200
+
201
+ if (this.timer) {
202
+ clearInterval(this.timer);
203
+ this.timer = null;
204
+ }
205
+ }
206
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { ProgressDisplay } from "./progress-ui.js";
4
5
  import { runCommand } from "./shell.js";
5
6
  import { resolveRepositoryContext } from "./vcs-client.js";
6
7
 
@@ -414,32 +415,22 @@ async function runReviewerPrompt(config, backend, targetInfo, details, diffText)
414
415
  }
415
416
 
416
417
  function readLastReviewedId(state, backend, targetInfo) {
417
- if (state.vcs && state.vcs !== backend.kind) {
418
- return null;
419
- }
420
-
421
- if (state.targetKey && state.targetKey !== targetInfo.stateKey) {
422
- return null;
423
- }
424
-
425
418
  return backend.fromStateValue(state);
426
419
  }
427
420
 
428
421
  function buildStateSnapshot(backend, targetInfo, changeId) {
429
- const state = {
430
- vcs: backend.kind,
431
- targetKey: targetInfo.stateKey,
422
+ return {
432
423
  lastReviewedId: backend.toStateValue(changeId),
433
424
  updatedAt: new Date().toISOString()
434
425
  };
435
-
436
- return backend.extendState(state, changeId);
437
426
  }
438
427
 
439
- async function reviewChange(config, backend, targetInfo, changeId) {
428
+ async function reviewChange(config, backend, targetInfo, changeId, progress) {
429
+ progress?.update(0.05, "loading change details");
440
430
  const details = await backend.getChangeDetails(config, targetInfo, changeId);
441
431
 
442
432
  if (details.changedPaths.length === 0) {
433
+ progress?.update(0.7, "writing skipped report");
443
434
  const skippedReport = [
444
435
  `# ${backend.displayName} Review Report: ${details.displayId}`,
445
436
  "",
@@ -471,6 +462,7 @@ async function reviewChange(config, backend, targetInfo, changeId) {
471
462
  };
472
463
  }
473
464
 
465
+ progress?.update(0.2, "loading diff");
474
466
  const diffText = await backend.getChangeDiff(config, targetInfo, changeId);
475
467
  const reviewersToTry = [config.reviewer, ...(config.fallbackReviewers || [])];
476
468
 
@@ -482,6 +474,7 @@ async function reviewChange(config, backend, targetInfo, changeId) {
482
474
  for (const reviewerName of reviewersToTry) {
483
475
  currentReviewerConfig = { ...config, reviewer: reviewerName };
484
476
  debugLog(config, `Trying reviewer: ${reviewerName}`);
477
+ progress?.update(0.45, `running reviewer ${reviewerName}`);
485
478
 
486
479
  const res = await runReviewerPrompt(
487
480
  currentReviewerConfig,
@@ -499,10 +492,11 @@ async function reviewChange(config, backend, targetInfo, changeId) {
499
492
  }
500
493
 
501
494
  if (reviewerName !== reviewersToTry[reviewersToTry.length - 1]) {
502
- console.log(`${reviewer.displayName} failed for ${details.displayId}; trying next reviewer...`);
495
+ progress?.log(`${reviewer.displayName} failed for ${details.displayId}; trying next reviewer...`);
503
496
  }
504
497
  }
505
498
 
499
+ progress?.update(0.82, "writing report");
506
500
  const report = buildReport(currentReviewerConfig, backend, targetInfo, details, diffPayloads, reviewer, reviewerResult);
507
501
  const outputFile = path.join(config.outputDir, backend.getReportFileName(changeId));
508
502
  const jsonOutputFile = outputFile.replace(/\.md$/i, ".json");
@@ -538,6 +532,15 @@ function formatChangeList(backend, changeIds) {
538
532
  return changeIds.map((changeId) => backend.formatChangeId(changeId)).join(", ");
539
533
  }
540
534
 
535
+ function updateOverallProgress(progress, completedCount, totalCount, currentFraction, stage) {
536
+ if (!progress || totalCount <= 0) {
537
+ return;
538
+ }
539
+
540
+ const overallFraction = (completedCount + currentFraction) / totalCount;
541
+ progress.update(overallFraction, `${completedCount}/${totalCount} completed${stage ? ` | ${stage}` : ""}`);
542
+ }
543
+
541
544
  export async function runReviewCycle(config) {
542
545
  await ensureDir(config.outputDir);
543
546
 
@@ -588,21 +591,52 @@ export async function runReviewCycle(config) {
588
591
  }
589
592
 
590
593
  console.log(`Reviewing ${backend.displayName} ${backend.changeName}s ${formatChangeList(backend, changeIdsToReview)}`);
594
+ const progressDisplay = new ProgressDisplay();
595
+ const overallProgress = progressDisplay.createItem(
596
+ `${backend.displayName} ${backend.changeName} batch`,
597
+ { barWidth: 30 }
598
+ );
599
+ overallProgress.start("0/" + changeIdsToReview.length + " completed");
591
600
 
592
- for (const changeId of changeIdsToReview) {
601
+ for (const [index, changeId] of changeIdsToReview.entries()) {
593
602
  debugLog(config, `Starting review for ${backend.formatChangeId(changeId)}.`);
594
- const result = await reviewChange(config, backend, targetInfo, changeId);
603
+ const displayId = backend.formatChangeId(changeId);
604
+ const progress = progressDisplay.createItem(
605
+ `${backend.displayName} ${backend.changeName} ${displayId} (${index + 1}/${changeIdsToReview.length})`
606
+ );
607
+ progress.start("queued");
608
+ updateOverallProgress(overallProgress, index, changeIdsToReview.length, 0, `starting ${displayId}`);
609
+
610
+ const syncOverallProgress = (fraction, stage) => {
611
+ progress.update(fraction, stage);
612
+ updateOverallProgress(overallProgress, index, changeIdsToReview.length, fraction, `${displayId} | ${stage}`);
613
+ };
614
+
615
+ let result;
616
+
617
+ try {
618
+ result = await reviewChange(config, backend, targetInfo, changeId, { update: syncOverallProgress, log: (message) => progress.log(message) });
619
+ syncOverallProgress(0.94, "saving checkpoint");
620
+ } catch (error) {
621
+ progress.fail(`failed at ${displayId}`);
622
+ overallProgress.fail(`failed at ${displayId} (${index}/${changeIdsToReview.length} completed)`);
623
+ throw error;
624
+ }
625
+
595
626
  const outputLabels = [
596
627
  result.outputFile ? `md: ${result.outputFile}` : null,
597
628
  result.jsonOutputFile ? `json: ${result.jsonOutputFile}` : null
598
629
  ].filter(Boolean);
599
- console.log(`Reviewed ${backend.formatChangeId(changeId)}: ${outputLabels.join(" | ") || "(no report file generated)"}`);
600
630
  const nextProjectState = buildStateSnapshot(backend, targetInfo, changeId);
601
631
  await saveState(config.stateFilePath, updateProjectState(stateFile, targetInfo, nextProjectState));
602
632
  stateFile.projects[targetInfo.stateKey] = nextProjectState;
603
633
  debugLog(config, `Saved checkpoint for ${backend.formatChangeId(changeId)} to ${config.stateFilePath}.`);
634
+ progress.succeed(`reviewed ${displayId}: ${outputLabels.join(" | ") || "(no report file generated)"}`);
635
+ updateOverallProgress(overallProgress, index + 1, changeIdsToReview.length, 0, `finished ${displayId}`);
604
636
  }
605
637
 
638
+ overallProgress.succeed(`completed ${changeIdsToReview.length}/${changeIdsToReview.length}`);
639
+
606
640
  const remainingChanges = await backend.getPendingChangeIds(
607
641
  config,
608
642
  targetInfo,
package/src/vcs-client.js CHANGED
@@ -67,21 +67,8 @@ function createSvnBackend() {
67
67
  return Number(revision);
68
68
  },
69
69
  fromStateValue(state) {
70
- if (Number.isInteger(state.lastReviewedId)) {
71
- return state.lastReviewedId;
72
- }
73
-
74
- if (Number.isInteger(state.lastReviewedRevision)) {
75
- return state.lastReviewedRevision;
76
- }
77
-
78
- return null;
79
- },
80
- extendState(state, revision) {
81
- return {
82
- ...state,
83
- lastReviewedRevision: Number(revision)
84
- };
70
+ const id = state.lastReviewedId;
71
+ return Number.isInteger(id) ? id : null;
85
72
  }
86
73
  };
87
74
  }
@@ -137,21 +124,8 @@ function createGitBackend() {
137
124
  return String(commitHash);
138
125
  },
139
126
  fromStateValue(state) {
140
- if (typeof state.lastReviewedId === "string" && state.lastReviewedId) {
141
- return state.lastReviewedId;
142
- }
143
-
144
- if (typeof state.lastReviewedCommit === "string" && state.lastReviewedCommit) {
145
- return state.lastReviewedCommit;
146
- }
147
-
148
- return null;
149
- },
150
- extendState(state, commitHash) {
151
- return {
152
- ...state,
153
- lastReviewedCommit: String(commitHash)
154
- };
127
+ const id = state.lastReviewedId;
128
+ return typeof id === "string" && id ? id : null;
155
129
  }
156
130
  };
157
131
  }