review-workspace 1.3.0 → 1.5.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/publish-with-handoff.js +31 -0
- package/dist/bundle/round.js +7 -0
- package/dist/cli.js +6 -2
- package/dist/handoff/ask-repo-path.js +17 -0
- package/dist/handoff/build-handoff-markdown.js +110 -0
- package/dist/handoff/ensure-gitignore.js +24 -0
- package/dist/handoff/export-handoff.js +28 -0
- package/dist/handoff/repo-path-cache.js +28 -0
- package/dist/questions/questions-log.js +51 -5
- package/dist/server/create-server.js +32 -6
- 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,31 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { exportHandoff } from "../handoff/export-handoff.js";
|
|
4
|
+
import { readQuestions } from "../questions/questions-log.js";
|
|
5
|
+
import { DEFAULT_LIMITS } from "../security/limits.js";
|
|
6
|
+
import { publishBundle } from "./publish.js";
|
|
7
|
+
import { readChain } from "./round.js";
|
|
8
|
+
/**
|
|
9
|
+
* Publishes a bundle and, only once the publish itself succeeds, exports its
|
|
10
|
+
* open change-request Comments as a hand-off file into the target repo's
|
|
11
|
+
* working tree. The round number comes from `chain.json` when present
|
|
12
|
+
* (round N>1), otherwise round 1 — independent of chaining, same as the
|
|
13
|
+
* hand-off export itself.
|
|
14
|
+
*/
|
|
15
|
+
export async function publishBundleAndExportHandoff(bundlePath, options) {
|
|
16
|
+
const result = publishBundle(bundlePath, options.limits ?? DEFAULT_LIMITS);
|
|
17
|
+
if (!result.ok)
|
|
18
|
+
return result;
|
|
19
|
+
const document = JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8'));
|
|
20
|
+
const round = readChain(bundlePath)?.round ?? 1;
|
|
21
|
+
const handoff = await exportHandoff({
|
|
22
|
+
bundlePath,
|
|
23
|
+
comparison: document.comparison,
|
|
24
|
+
round,
|
|
25
|
+
comments: readQuestions(bundlePath),
|
|
26
|
+
annotations: document.annotations ?? [],
|
|
27
|
+
ask: options.ask,
|
|
28
|
+
repoPathCachePath: options.repoPathCachePath,
|
|
29
|
+
});
|
|
30
|
+
return { ...result, handoff };
|
|
31
|
+
}
|
package/dist/bundle/round.js
CHANGED
|
@@ -13,6 +13,13 @@ export function mrKeyFor(comparison) {
|
|
|
13
13
|
function readReviewDocument(bundlePath) {
|
|
14
14
|
return JSON.parse(readFileSync(join(bundlePath, 'review.json'), 'utf-8'));
|
|
15
15
|
}
|
|
16
|
+
/** Reads a bundle's `chain.json`, if it has one — absent for every round-1 bundle. */
|
|
17
|
+
export function readChain(bundlePath) {
|
|
18
|
+
const path = join(bundlePath, 'chain.json');
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return undefined;
|
|
21
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
22
|
+
}
|
|
16
23
|
/**
|
|
17
24
|
* Reads a bundle directory name shaped like `<name>-r{N}` and splits it into
|
|
18
25
|
* the chain's base name and that bundle's round number. A name with no
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { validateBundle } from "./bundle/validate-bundle.js";
|
|
4
|
-
import {
|
|
4
|
+
import { publishBundleAndExportHandoff } from "./bundle/publish-with-handoff.js";
|
|
5
5
|
import { checkRound, scaffoldNextRound } from "./bundle/round.js";
|
|
6
|
+
import { askForRepoPathOnStdin } from "./handoff/ask-repo-path.js";
|
|
6
7
|
import { startReviewServer } from "./server/create-server.js";
|
|
7
8
|
const [, , command, bundlePath, ...rest] = process.argv;
|
|
8
9
|
function fail(message) {
|
|
@@ -27,11 +28,14 @@ if (command === 'open') {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
else if (command === 'publish') {
|
|
30
|
-
const result =
|
|
31
|
+
const result = await publishBundleAndExportHandoff(bundlePath, { ask: askForRepoPathOnStdin });
|
|
31
32
|
if (!result.ok) {
|
|
32
33
|
fail(`Publish rejected: ${result.blockingReason} — ${result.message}`);
|
|
33
34
|
}
|
|
34
35
|
console.log('Published.');
|
|
36
|
+
if (result.handoff?.written) {
|
|
37
|
+
console.log(`Hand-off file written: ${result.handoff.filePath}`);
|
|
38
|
+
}
|
|
35
39
|
}
|
|
36
40
|
else if (command === 'round') {
|
|
37
41
|
const liveHead = flagValue('--live-head');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { stdin, stdout } from 'node:process';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
/**
|
|
4
|
+
* Real interactive `AskForRepoPath` implementation for CLI/server use —
|
|
5
|
+
* prompts on stdin/stdout. Kept separate from `repo-path-cache.ts` so that
|
|
6
|
+
* module stays free of any actual I/O and easy to unit test with a stub `ask`.
|
|
7
|
+
*/
|
|
8
|
+
export async function askForRepoPathOnStdin(repoSlug) {
|
|
9
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
10
|
+
try {
|
|
11
|
+
const answer = await rl.question(`Local checkout path for ${repoSlug}: `);
|
|
12
|
+
return answer.trim();
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
rl.close();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
const GENERAL_GROUP = '(general — no Target)';
|
|
3
|
+
function targetPath(target) {
|
|
4
|
+
return target.path;
|
|
5
|
+
}
|
|
6
|
+
function targetKey(target) {
|
|
7
|
+
switch (target.type) {
|
|
8
|
+
case 'file':
|
|
9
|
+
return `file:${target.path}`;
|
|
10
|
+
case 'binary':
|
|
11
|
+
return `binary:${target.path}`;
|
|
12
|
+
case 'hunk':
|
|
13
|
+
return `hunk:${target.path}:${target.hunkIndex}`;
|
|
14
|
+
case 'line':
|
|
15
|
+
return `line:${target.path}:${target.side}:${target.line}`;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// Same-type-same-Target only: a file-level comment does not pick up
|
|
19
|
+
// hunk-level Annotations on that file (or vice versa). Cross-granularity
|
|
20
|
+
// matching is a coarser question ("does this Annotation touch material this
|
|
21
|
+
// comment also concerns?") that issue #10 doesn't ask for — it wants the
|
|
22
|
+
// Annotation the comment was raised against, i.e. the same Target.
|
|
23
|
+
function targetsEqual(a, b) {
|
|
24
|
+
return targetKey(a) === targetKey(b);
|
|
25
|
+
}
|
|
26
|
+
function describeTarget(target) {
|
|
27
|
+
switch (target.type) {
|
|
28
|
+
case 'file':
|
|
29
|
+
return `File: ${target.path}`;
|
|
30
|
+
case 'hunk':
|
|
31
|
+
return `Hunk #${target.hunkIndex} in ${target.path}`;
|
|
32
|
+
case 'line':
|
|
33
|
+
return `Line ${target.line} (${target.side}) in ${target.path}`;
|
|
34
|
+
case 'binary':
|
|
35
|
+
return `Binary change: ${target.path}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function openChangeRequests(comments) {
|
|
39
|
+
return comments.filter((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
|
|
40
|
+
}
|
|
41
|
+
function groupByFile(comments) {
|
|
42
|
+
const byFile = new Map();
|
|
43
|
+
for (const comment of comments) {
|
|
44
|
+
const key = comment.target ? targetPath(comment.target) : GENERAL_GROUP;
|
|
45
|
+
const group = byFile.get(key) ?? [];
|
|
46
|
+
group.push(comment);
|
|
47
|
+
byFile.set(key, group);
|
|
48
|
+
}
|
|
49
|
+
return byFile;
|
|
50
|
+
}
|
|
51
|
+
function orderedFileKeys(byFile) {
|
|
52
|
+
return [...byFile.keys()].sort((a, b) => {
|
|
53
|
+
if (a === GENERAL_GROUP)
|
|
54
|
+
return 1;
|
|
55
|
+
if (b === GENERAL_GROUP)
|
|
56
|
+
return -1;
|
|
57
|
+
return a.localeCompare(b);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function metadataBlock(input) {
|
|
61
|
+
return [
|
|
62
|
+
'<!--',
|
|
63
|
+
'review-workspace-handoff',
|
|
64
|
+
`bundlePath: ${resolve(input.bundlePath)}`,
|
|
65
|
+
`repository: ${input.comparison.repository ?? ''}`,
|
|
66
|
+
`mrNumber: ${input.comparison.number ?? ''}`,
|
|
67
|
+
`round: ${input.round}`,
|
|
68
|
+
'-->',
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds the hand-off Markdown a separate implementer-side skill parses.
|
|
73
|
+
* Content is raw: every open change-request Comment verbatim, grouped by
|
|
74
|
+
* file, with its exact Target and any Annotation raised against that same
|
|
75
|
+
* Target for context. This exact shape is a parsed contract — golden-tested,
|
|
76
|
+
* do not reformat without updating that consumer too.
|
|
77
|
+
*/
|
|
78
|
+
export function buildHandoffMarkdown(input) {
|
|
79
|
+
const comments = openChangeRequests(input.comments);
|
|
80
|
+
const lines = [`# Change requests — round ${input.round}`, '', ...metadataBlock(input), ''];
|
|
81
|
+
if (comments.length === 0) {
|
|
82
|
+
lines.push('_No open change-request comments._');
|
|
83
|
+
return lines.join('\n') + '\n';
|
|
84
|
+
}
|
|
85
|
+
const byFile = groupByFile(comments);
|
|
86
|
+
for (const file of orderedFileKeys(byFile)) {
|
|
87
|
+
lines.push(`## ${file}`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
const fileComments = [...(byFile.get(file) ?? [])].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
90
|
+
for (const comment of fileComments) {
|
|
91
|
+
lines.push(comment.target ? `### ${describeTarget(comment.target)}` : '### (no Target)');
|
|
92
|
+
if (comment.target?.type === 'line') {
|
|
93
|
+
lines.push(`> ${comment.target.expectedText}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
lines.push(comment.body);
|
|
97
|
+
lines.push('');
|
|
98
|
+
const relatedAnnotations = comment.target
|
|
99
|
+
? input.annotations.filter((a) => targetsEqual(a.target, comment.target))
|
|
100
|
+
: [];
|
|
101
|
+
for (const annotation of relatedAnnotations) {
|
|
102
|
+
lines.push(`**Annotation (${annotation.kind ?? 'note'}):** ${annotation.summary}`);
|
|
103
|
+
lines.push('');
|
|
104
|
+
}
|
|
105
|
+
lines.push('---');
|
|
106
|
+
lines.push('');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines.join('\n').replace(/\n{2,}$/, '\n');
|
|
110
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const ENTRY = '.review-feedback/';
|
|
4
|
+
const COVERING_PATTERNS = new Set(['.review-feedback/', '.review-feedback', '.review-feedback/**', '.review-feedback/*']);
|
|
5
|
+
function lineCoversHandoffDir(line) {
|
|
6
|
+
return COVERING_PATTERNS.has(line.trim());
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Ensures the target repo's `.gitignore` excludes `.review-feedback/` —
|
|
10
|
+
* creating the file if it doesn't exist, appending the entry if the file
|
|
11
|
+
* exists but neither an exact nor a broader matching entry already covers it.
|
|
12
|
+
*/
|
|
13
|
+
export function ensureGitignoreExcludesHandoff(repoPath) {
|
|
14
|
+
const gitignorePath = join(repoPath, '.gitignore');
|
|
15
|
+
if (!existsSync(gitignorePath)) {
|
|
16
|
+
writeFileSync(gitignorePath, `${ENTRY}\n`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
20
|
+
if (content.split('\n').some(lineCoversHandoffDir))
|
|
21
|
+
return;
|
|
22
|
+
const withTrailingNewline = content.endsWith('\n') ? content : `${content}\n`;
|
|
23
|
+
writeFileSync(gitignorePath, `${withTrailingNewline}${ENTRY}\n`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { buildHandoffMarkdown } from "./build-handoff-markdown.js";
|
|
4
|
+
import { ensureGitignoreExcludesHandoff } from "./ensure-gitignore.js";
|
|
5
|
+
import { DEFAULT_REPO_PATH_CACHE_PATH, resolveRepoPath } from "./repo-path-cache.js";
|
|
6
|
+
/**
|
|
7
|
+
* Exports open change-request Comments as a self-describing hand-off
|
|
8
|
+
* Markdown file into the target repo's local working tree, and ensures
|
|
9
|
+
* `.review-feedback/` is gitignored there. A no-op when there are no open
|
|
10
|
+
* change-requests — this is what makes it safe to call after every publish.
|
|
11
|
+
*/
|
|
12
|
+
export async function exportHandoff(options) {
|
|
13
|
+
const { bundlePath, comparison, round, comments, annotations, ask, repoPathCachePath } = options;
|
|
14
|
+
const hasOpenChangeRequest = comments.some((c) => c.kind === 'change-request' && c.status === 'open' && !c.resolved);
|
|
15
|
+
if (!hasOpenChangeRequest)
|
|
16
|
+
return { written: false };
|
|
17
|
+
if (!comparison.repository) {
|
|
18
|
+
throw new Error('comparison.repository is required to export a hand-off file');
|
|
19
|
+
}
|
|
20
|
+
const repoPath = await resolveRepoPath(comparison.repository, ask, repoPathCachePath ?? DEFAULT_REPO_PATH_CACHE_PATH);
|
|
21
|
+
const markdown = buildHandoffMarkdown({ bundlePath, comparison, round, comments, annotations });
|
|
22
|
+
const feedbackDir = join(repoPath, '.review-feedback');
|
|
23
|
+
mkdirSync(feedbackDir, { recursive: true });
|
|
24
|
+
const filePath = join(feedbackDir, `${comparison.number}-round${round}.md`);
|
|
25
|
+
writeFileSync(filePath, markdown);
|
|
26
|
+
ensureGitignoreExcludesHandoff(repoPath);
|
|
27
|
+
return { written: true, filePath, repoPath };
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export const DEFAULT_REPO_PATH_CACHE_PATH = join(homedir(), '.claude', 'review-workspace-repo-paths.json');
|
|
5
|
+
function readCache(cachePath) {
|
|
6
|
+
if (!existsSync(cachePath))
|
|
7
|
+
return {};
|
|
8
|
+
return JSON.parse(readFileSync(cachePath, 'utf-8'));
|
|
9
|
+
}
|
|
10
|
+
function writeCache(cachePath, cache) {
|
|
11
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
12
|
+
writeFileSync(cachePath, JSON.stringify(cache, null, 2) + '\n');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolves a target repo's local checkout path for `repoSlug`. Reuses a
|
|
16
|
+
* cached answer as long as it still exists on disk; asks (via `ask`) and
|
|
17
|
+
* caches the answer otherwise — this covers both "never asked before" and
|
|
18
|
+
* "the cached checkout was moved or deleted" in the same branch.
|
|
19
|
+
*/
|
|
20
|
+
export async function resolveRepoPath(repoSlug, ask, cachePath = DEFAULT_REPO_PATH_CACHE_PATH) {
|
|
21
|
+
const cache = readCache(cachePath);
|
|
22
|
+
const cached = cache[repoSlug];
|
|
23
|
+
if (cached && existsSync(cached))
|
|
24
|
+
return cached;
|
|
25
|
+
const answer = await ask(repoSlug);
|
|
26
|
+
writeCache(cachePath, { ...cache, [repoSlug]: answer });
|
|
27
|
+
return answer;
|
|
28
|
+
}
|
|
@@ -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
|
}
|
|
@@ -3,9 +3,10 @@ import { randomBytes } from 'node:crypto';
|
|
|
3
3
|
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { validateBundle } from "../bundle/validate-bundle.js";
|
|
6
|
-
import {
|
|
6
|
+
import { publishBundleAndExportHandoff } from "../bundle/publish-with-handoff.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
|
+
import { askForRepoPathOnStdin } from "../handoff/ask-repo-path.js";
|
|
9
10
|
import { readReviewState, writeReviewState } from "../review-state/review-state.js";
|
|
10
11
|
import { render } from "../renderer/render.js";
|
|
11
12
|
import { buildReport } from "../export/report.js";
|
|
@@ -15,6 +16,8 @@ export function generateWriteToken() {
|
|
|
15
16
|
}
|
|
16
17
|
const WRITE_TOKEN_HEADER = 'x-write-token';
|
|
17
18
|
const WITHDRAW_PATH = /^\/questions\/([^/]+)\/withdraw$/;
|
|
19
|
+
const RESOLVE_PATH = /^\/questions\/([^/]+)\/resolve$/;
|
|
20
|
+
const COMMENT_KINDS = ['question', 'change-request'];
|
|
18
21
|
const ASSET_CONTENT_TYPES = {
|
|
19
22
|
'.png': 'image/png',
|
|
20
23
|
'.jpg': 'image/jpeg',
|
|
@@ -69,6 +72,7 @@ function readJsonBody(req) {
|
|
|
69
72
|
}
|
|
70
73
|
export function startReviewServer(bundlePath, opts = {}) {
|
|
71
74
|
const writeToken = opts.writeToken ?? generateWriteToken();
|
|
75
|
+
const askForRepoPath = opts.askForRepoPath ?? askForRepoPathOnStdin;
|
|
72
76
|
// The served document is cached and only refreshed by the watcher below, so a
|
|
73
77
|
// publish must actually trigger re-validation for /document to ever change —
|
|
74
78
|
// this is what makes file watching load-bearing rather than a no-op wrapper
|
|
@@ -137,19 +141,24 @@ export function startReviewServer(bundlePath, opts = {}) {
|
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
if (req.method === 'POST' && url.pathname === '/publish') {
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
publishBundleAndExportHandoff(bundlePath, { ask: askForRepoPath, repoPathCachePath: opts.repoPathCachePath })
|
|
145
|
+
.then((result) => respond(result.ok ? 200 : 422, result))
|
|
146
|
+
.catch((err) => respond(500, { error: err instanceof Error ? err.message : 'publish failed' }));
|
|
142
147
|
return;
|
|
143
148
|
}
|
|
144
149
|
if (req.method === 'POST' && url.pathname === '/questions') {
|
|
145
150
|
readJsonBody(req)
|
|
146
151
|
.then((body) => {
|
|
147
|
-
const { body: text, target } = body;
|
|
152
|
+
const { body: text, target, kind } = body;
|
|
148
153
|
if (typeof text !== 'string' || text.trim() === '') {
|
|
149
154
|
respond(400, { error: 'body is required' });
|
|
150
155
|
return;
|
|
151
156
|
}
|
|
152
|
-
|
|
157
|
+
if (kind !== undefined && !COMMENT_KINDS.includes(kind)) {
|
|
158
|
+
respond(400, { error: `kind must be one of ${COMMENT_KINDS.join(', ')}` });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
respond(201, raiseQuestion(bundlePath, text, target, kind ?? 'question'));
|
|
153
162
|
})
|
|
154
163
|
.catch(() => respond(400, { error: 'invalid JSON body' }));
|
|
155
164
|
return;
|
|
@@ -168,6 +177,23 @@ export function startReviewServer(bundlePath, opts = {}) {
|
|
|
168
177
|
.catch(() => respond(400, { error: 'invalid JSON body' }));
|
|
169
178
|
return;
|
|
170
179
|
}
|
|
180
|
+
// Human-only action: a Reviewer marking a change-request Comment resolved. This
|
|
181
|
+
// never reads from or writes to `review.next.json`/the Generator-owned Review
|
|
182
|
+
// Document — the Generator has no path to setting `resolved` (see ADR 0002).
|
|
183
|
+
const resolveMatch = RESOLVE_PATH.exec(url.pathname);
|
|
184
|
+
if (req.method === 'POST' && resolveMatch) {
|
|
185
|
+
const result = resolveComment(bundlePath, resolveMatch[1]);
|
|
186
|
+
if (result.outcome === 'not-found') {
|
|
187
|
+
respond(404, { error: 'no such Comment' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (result.outcome === 'not-resolvable') {
|
|
191
|
+
respond(400, { error: 'Comment is not an open change-request' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
respond(200, result.comment);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
171
197
|
if (req.method === 'PUT' && url.pathname === '/state') {
|
|
172
198
|
readJsonBody(req)
|
|
173
199
|
.then((body) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "review-workspace",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|