review-workspace 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/round.js +71 -0
- package/dist/cli.js +19 -1
- package/dist/questions/questions-log.js +51 -5
- package/dist/server/create-server.js +26 -3
- package/package.json +1 -1
- package/ui-dist/_app/index-CIfDjuhH.js +118 -0
- package/ui-dist/_app/index-DlZHsLuA.css +1 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/_app/index-BxFQbyAz.js +0 -118
- package/ui-dist/_app/index-CegXaDjN.css +0 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* A stable cross-round identity for one MR: repo slug + number, e.g.
|
|
5
|
+
* "founda/application/xds-viewer-ui!367".
|
|
6
|
+
*/
|
|
7
|
+
export function mrKeyFor(comparison) {
|
|
8
|
+
if (!comparison.repository || !comparison.number) {
|
|
9
|
+
throw new Error('comparison.repository and comparison.number are required to compute an mrKey');
|
|
10
|
+
}
|
|
11
|
+
return `${comparison.repository}!${comparison.number}`;
|
|
12
|
+
}
|
|
13
|
+
function readReviewDocument(bundlePath) {
|
|
14
|
+
return JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8'));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Reads a bundle directory name shaped like `<name>-r{N}` and splits it into
|
|
18
|
+
* the chain's base name and that bundle's round number. A name with no
|
|
19
|
+
* `-r{N}` suffix is round 1.
|
|
20
|
+
*/
|
|
21
|
+
function parseBundleDirName(dirName) {
|
|
22
|
+
const match = dirName.match(/^(.+)-r(\d+)$/);
|
|
23
|
+
if (!match)
|
|
24
|
+
return { baseName: dirName, round: 1 };
|
|
25
|
+
return { baseName: match[1], round: Number(match[2]) };
|
|
26
|
+
}
|
|
27
|
+
/** The sibling bundle path a new round for `bundlePath` would scaffold into. */
|
|
28
|
+
export function nextRoundBundlePath(bundlePath) {
|
|
29
|
+
const { baseName, round } = parseBundleDirName(basename(bundlePath));
|
|
30
|
+
return join(dirname(bundlePath), `${baseName}-r${round + 1}`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detects, purely from what's on disk plus a supplied live head, whether a
|
|
34
|
+
* bundle's recorded Comparison has fallen behind its MR — the "new round"
|
|
35
|
+
* case alongside New/Existing/Improve bundle determination.
|
|
36
|
+
*/
|
|
37
|
+
export function checkRound(bundlePath, liveHead) {
|
|
38
|
+
const { comparison } = readReviewDocument(bundlePath);
|
|
39
|
+
return { needsNewRound: comparison.head !== liveHead, recordedHead: comparison.head, liveHead };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Scaffolds a new sibling round bundle for an MR whose live head has moved
|
|
43
|
+
* past what `bundlePath` recorded. Never mutates `bundlePath` (ADR 0002) —
|
|
44
|
+
* only creates the new `<name>-r{N}` directory alongside it, with its own
|
|
45
|
+
* Comparison, Unified Patch, and a `chain.json` linking it back.
|
|
46
|
+
*/
|
|
47
|
+
export function scaffoldNextRound(bundlePath, liveHead, options) {
|
|
48
|
+
const document = readReviewDocument(bundlePath);
|
|
49
|
+
if (document.comparison.head === liveHead) {
|
|
50
|
+
throw new Error(`Live head ${liveHead} matches the bundle's recorded head; no new round is needed`);
|
|
51
|
+
}
|
|
52
|
+
const { round: currentRound } = parseBundleDirName(basename(bundlePath));
|
|
53
|
+
const newBundlePath = nextRoundBundlePath(bundlePath);
|
|
54
|
+
if (existsSync(newBundlePath)) {
|
|
55
|
+
throw new Error(`Round bundle already exists at ${newBundlePath}`);
|
|
56
|
+
}
|
|
57
|
+
const chain = {
|
|
58
|
+
mrKey: mrKeyFor(document.comparison),
|
|
59
|
+
round: currentRound + 1,
|
|
60
|
+
previousBundle: relative(newBundlePath, bundlePath),
|
|
61
|
+
previousHead: document.comparison.head,
|
|
62
|
+
};
|
|
63
|
+
mkdirSync(newBundlePath, { recursive: true });
|
|
64
|
+
writeFileSync(join(newBundlePath, 'changes.diff'), options.patch);
|
|
65
|
+
writeFileSync(join(newBundlePath, 'review.json'), JSON.stringify({
|
|
66
|
+
schemaVersion: document.schemaVersion,
|
|
67
|
+
comparison: { ...document.comparison, head: liveHead },
|
|
68
|
+
}, null, 2));
|
|
69
|
+
writeFileSync(join(newBundlePath, 'chain.json'), JSON.stringify(chain, null, 2));
|
|
70
|
+
return { bundlePath: newBundlePath, chain };
|
|
71
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { validateBundle } from "./bundle/validate-bundle.js";
|
|
3
4
|
import { publishBundle } from "./bundle/publish.js";
|
|
5
|
+
import { checkRound, scaffoldNextRound } from "./bundle/round.js";
|
|
4
6
|
import { startReviewServer } from "./server/create-server.js";
|
|
5
7
|
const [, , command, bundlePath, ...rest] = process.argv;
|
|
6
8
|
function fail(message) {
|
|
@@ -12,7 +14,7 @@ function flagValue(name) {
|
|
|
12
14
|
return index === -1 ? undefined : rest[index + 1];
|
|
13
15
|
}
|
|
14
16
|
if (!command || !bundlePath) {
|
|
15
|
-
fail('Usage: review-workspace <open|publish|serve> <bundlePath> [--port N]');
|
|
17
|
+
fail('Usage: review-workspace <open|publish|serve|round> <bundlePath> [--port N] [--live-head SHA --patch FILE]');
|
|
16
18
|
}
|
|
17
19
|
if (command === 'open') {
|
|
18
20
|
const result = validateBundle(bundlePath);
|
|
@@ -31,6 +33,22 @@ else if (command === 'publish') {
|
|
|
31
33
|
}
|
|
32
34
|
console.log('Published.');
|
|
33
35
|
}
|
|
36
|
+
else if (command === 'round') {
|
|
37
|
+
const liveHead = flagValue('--live-head');
|
|
38
|
+
const patchPath = flagValue('--patch');
|
|
39
|
+
if (!liveHead || !patchPath) {
|
|
40
|
+
fail('Usage: review-workspace round <bundlePath> --live-head <sha> --patch <patchFile>');
|
|
41
|
+
}
|
|
42
|
+
const { needsNewRound } = checkRound(bundlePath, liveHead);
|
|
43
|
+
if (!needsNewRound) {
|
|
44
|
+
console.log(JSON.stringify({ needsNewRound: false }));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const patch = readFileSync(patchPath, 'utf-8');
|
|
48
|
+
const result = scaffoldNextRound(bundlePath, liveHead, { patch });
|
|
49
|
+
console.log(JSON.stringify({ needsNewRound: true, ...result }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
34
52
|
else if (command === 'serve') {
|
|
35
53
|
const port = Number(flagValue('--port') ?? 4317);
|
|
36
54
|
const handle = await startReviewServer(bundlePath, { port });
|
|
@@ -20,18 +20,26 @@ function readLog(bundlePath) {
|
|
|
20
20
|
function appendEntry(bundlePath, entry) {
|
|
21
21
|
appendFileSync(logPath(bundlePath), JSON.stringify(entry) + '\n');
|
|
22
22
|
}
|
|
23
|
-
/** Raises a new, immutable
|
|
24
|
-
export function raiseQuestion(bundlePath, body, target) {
|
|
23
|
+
/** Raises a new, immutable Comment. Never mutates or removes any existing log entry. */
|
|
24
|
+
export function raiseQuestion(bundlePath, body, target, kind = 'question') {
|
|
25
25
|
const entry = {
|
|
26
26
|
type: 'raised',
|
|
27
27
|
id: randomUUID(),
|
|
28
28
|
createdAt: new Date().toISOString(),
|
|
29
29
|
body,
|
|
30
30
|
target,
|
|
31
|
-
kind
|
|
31
|
+
kind,
|
|
32
32
|
};
|
|
33
33
|
appendEntry(bundlePath, entry);
|
|
34
|
-
return {
|
|
34
|
+
return {
|
|
35
|
+
id: entry.id,
|
|
36
|
+
createdAt: entry.createdAt,
|
|
37
|
+
body: entry.body,
|
|
38
|
+
target: entry.target,
|
|
39
|
+
kind,
|
|
40
|
+
status: 'open',
|
|
41
|
+
resolved: false,
|
|
42
|
+
};
|
|
35
43
|
}
|
|
36
44
|
/**
|
|
37
45
|
* Withdraws an existing question Comment and raises its replacement in one call —
|
|
@@ -64,9 +72,39 @@ export function withdrawAndReplaceQuestion(bundlePath, questionId, replacementBo
|
|
|
64
72
|
target: replacement.target,
|
|
65
73
|
kind: 'question',
|
|
66
74
|
status: 'open',
|
|
75
|
+
resolved: false,
|
|
67
76
|
},
|
|
68
77
|
};
|
|
69
78
|
}
|
|
79
|
+
function isOpenChangeRequest(comment) {
|
|
80
|
+
return comment.kind === 'change-request' && comment.status === 'open' && !comment.resolved;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Marks an open change-request Comment resolved. This is a human-only, reviewer-triggered
|
|
84
|
+
* action recorded in the same append-only log — nothing a Generator writes (the
|
|
85
|
+
* Review Document, `review.next.json`) can ever produce this entry.
|
|
86
|
+
*
|
|
87
|
+
* Validates before writing: a Comment that doesn't exist, or isn't currently an open
|
|
88
|
+
* change-request, is rejected without appending anything to the log.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveComment(bundlePath, commentId) {
|
|
91
|
+
const comment = readQuestions(bundlePath).find((c) => c.id === commentId);
|
|
92
|
+
if (!comment)
|
|
93
|
+
return { outcome: 'not-found' };
|
|
94
|
+
if (!isOpenChangeRequest(comment))
|
|
95
|
+
return { outcome: 'not-resolvable', comment };
|
|
96
|
+
const entry = {
|
|
97
|
+
type: 'resolved',
|
|
98
|
+
id: randomUUID(),
|
|
99
|
+
createdAt: new Date().toISOString(),
|
|
100
|
+
commentId,
|
|
101
|
+
};
|
|
102
|
+
appendEntry(bundlePath, entry);
|
|
103
|
+
return {
|
|
104
|
+
outcome: 'resolved',
|
|
105
|
+
comment: { ...comment, resolved: true, resolvedAt: entry.createdAt },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
70
108
|
/** Reduces the append-only log into the current set of Comments. */
|
|
71
109
|
export function readQuestions(bundlePath) {
|
|
72
110
|
const comments = new Map();
|
|
@@ -79,15 +117,23 @@ export function readQuestions(bundlePath) {
|
|
|
79
117
|
target: entry.target,
|
|
80
118
|
kind: entry.kind ?? 'question',
|
|
81
119
|
status: 'open',
|
|
120
|
+
resolved: false,
|
|
82
121
|
});
|
|
83
122
|
}
|
|
84
|
-
else {
|
|
123
|
+
else if (entry.type === 'withdrawn') {
|
|
85
124
|
const comment = comments.get(entry.questionId);
|
|
86
125
|
if (comment) {
|
|
87
126
|
comment.status = 'withdrawn';
|
|
88
127
|
comment.supersededBy = entry.replacementId;
|
|
89
128
|
}
|
|
90
129
|
}
|
|
130
|
+
else {
|
|
131
|
+
const comment = comments.get(entry.commentId);
|
|
132
|
+
if (comment && comment.kind === 'change-request') {
|
|
133
|
+
comment.resolved = true;
|
|
134
|
+
comment.resolvedAt = entry.createdAt;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
91
137
|
}
|
|
92
138
|
return [...comments.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
93
139
|
}
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { validateBundle } from "../bundle/validate-bundle.js";
|
|
6
6
|
import { publishBundle } from "../bundle/publish.js";
|
|
7
7
|
import { watchBundle } from "./watch-bundle.js";
|
|
8
|
-
import { raiseQuestion, readQuestions, withdrawAndReplaceQuestion } from "../questions/questions-log.js";
|
|
8
|
+
import { raiseQuestion, readQuestions, resolveComment, withdrawAndReplaceQuestion } from "../questions/questions-log.js";
|
|
9
9
|
import { readReviewState, writeReviewState } from "../review-state/review-state.js";
|
|
10
10
|
import { render } from "../renderer/render.js";
|
|
11
11
|
import { buildReport } from "../export/report.js";
|
|
@@ -15,6 +15,8 @@ export function generateWriteToken() {
|
|
|
15
15
|
}
|
|
16
16
|
const WRITE_TOKEN_HEADER = 'x-write-token';
|
|
17
17
|
const WITHDRAW_PATH = /^\/questions\/([^/]+)\/withdraw$/;
|
|
18
|
+
const RESOLVE_PATH = /^\/questions\/([^/]+)\/resolve$/;
|
|
19
|
+
const COMMENT_KINDS = ['question', 'change-request'];
|
|
18
20
|
const ASSET_CONTENT_TYPES = {
|
|
19
21
|
'.png': 'image/png',
|
|
20
22
|
'.jpg': 'image/jpeg',
|
|
@@ -144,12 +146,16 @@ export function startReviewServer(bundlePath, opts = {}) {
|
|
|
144
146
|
if (req.method === 'POST' && url.pathname === '/questions') {
|
|
145
147
|
readJsonBody(req)
|
|
146
148
|
.then((body) => {
|
|
147
|
-
const { body: text, target } = body;
|
|
149
|
+
const { body: text, target, kind } = body;
|
|
148
150
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
149
151
|
respond(400, { error: 'body is required' });
|
|
150
152
|
return;
|
|
151
153
|
}
|
|
152
|
-
|
|
154
|
+
if (kind !== undefined && !COMMENT_KINDS.includes(kind)) {
|
|
155
|
+
respond(400, { error: `kind must be one of ${COMMENT_KINDS.join(', ')}` });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
respond(201, raiseQuestion(bundlePath, text, target, kind ?? 'question'));
|
|
153
159
|
})
|
|
154
160
|
.catch(() => respond(400, { error: 'invalid JSON body' }));
|
|
155
161
|
return;
|
|
@@ -168,6 +174,23 @@ export function startReviewServer(bundlePath, opts = {}) {
|
|
|
168
174
|
.catch(() => respond(400, { error: 'invalid JSON body' }));
|
|
169
175
|
return;
|
|
170
176
|
}
|
|
177
|
+
// Human-only action: a Reviewer marking a change-request Comment resolved. This
|
|
178
|
+
// never reads from or writes to `review.next.json`/the Generator-owned Review
|
|
179
|
+
// Document — the Generator has no path to setting `resolved` (see ADR 0002).
|
|
180
|
+
const resolveMatch = RESOLVE_PATH.exec(url.pathname);
|
|
181
|
+
if (req.method === 'POST' && resolveMatch) {
|
|
182
|
+
const result = resolveComment(bundlePath, resolveMatch[1]);
|
|
183
|
+
if (result.outcome === 'not-found') {
|
|
184
|
+
respond(404, { error: 'no such Comment' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (result.outcome === 'not-resolvable') {
|
|
188
|
+
respond(400, { error: 'Comment is not an open change-request' });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
respond(200, result.comment);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
171
194
|
if (req.method === 'PUT' && url.pathname === '/state') {
|
|
172
195
|
readJsonBody(req)
|
|
173
196
|
.then((body) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "review-workspace",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "A local, offline decision surface for reviewing a code Comparison — evidence-linked map of intent, behavior, risk, and observable effects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Salih Sayer",
|