@open-agent-toolkit/cli 0.1.13 → 0.1.15
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/assets/agents/oat-reviewer.md +54 -10
- package/assets/docs/workflows/projects/reviews.md +21 -2
- package/assets/docs/workflows/skills/index.md +2 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-project-review-provide-remote/SKILL.md +354 -0
- package/assets/skills/oat-project-review-receive/SKILL.md +7 -10
- package/assets/skills/oat-project-review-receive-remote/SKILL.md +4 -4
- package/assets/skills/oat-review-provide/SKILL.md +2 -2
- package/assets/skills/oat-review-provide/scripts/resolve-review-output.sh +16 -6
- package/assets/skills/oat-review-provide-remote/SKILL.md +273 -0
- package/assets/skills/oat-review-receive/SKILL.md +6 -6
- package/assets/skills/oat-review-receive-remote/SKILL.md +5 -5
- package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
- package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
- package/dist/commands/init/tools/shared/skill-manifest.js +2 -0
- package/dist/commands/project/new/index.d.ts +1 -0
- package/dist/commands/project/new/index.d.ts.map +1 -1
- package/dist/commands/project/new/index.js +23 -0
- package/dist/commands/project/new/scaffold.d.ts +17 -0
- package/dist/commands/project/new/scaffold.d.ts.map +1 -1
- package/dist/commands/project/new/scaffold.js +74 -0
- package/dist/review-remote/body-builder.d.ts +79 -0
- package/dist/review-remote/body-builder.d.ts.map +1 -0
- package/dist/review-remote/body-builder.js +103 -0
- package/dist/review-remote/capability-probe.d.ts +61 -0
- package/dist/review-remote/capability-probe.d.ts.map +1 -0
- package/dist/review-remote/capability-probe.js +87 -0
- package/dist/review-remote/line-mapper.d.ts +81 -0
- package/dist/review-remote/line-mapper.d.ts.map +1 -0
- package/dist/review-remote/line-mapper.js +165 -0
- package/dist/review-remote/marker-parser.d.ts +44 -0
- package/dist/review-remote/marker-parser.d.ts.map +1 -0
- package/dist/review-remote/marker-parser.js +97 -0
- package/dist/review-remote/narrowing.d.ts +81 -0
- package/dist/review-remote/narrowing.d.ts.map +1 -0
- package/dist/review-remote/narrowing.js +90 -0
- package/dist/review-remote/project-resolver.d.ts +46 -0
- package/dist/review-remote/project-resolver.d.ts.map +1 -0
- package/dist/review-remote/project-resolver.js +60 -0
- package/dist/review-remote/reviewer-dispatch.d.ts +108 -0
- package/dist/review-remote/reviewer-dispatch.d.ts.map +1 -0
- package/dist/review-remote/reviewer-dispatch.js +153 -0
- package/dist/review-remote/worktree.d.ts +62 -0
- package/dist/review-remote/worktree.d.ts.map +1 -0
- package/dist/review-remote/worktree.js +117 -0
- package/package.json +2 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline-comment line-mapping validator (see design.md → Error Handling →
|
|
3
|
+
* Inline-comment line mapping).
|
|
4
|
+
*
|
|
5
|
+
* GitHub's `POST /repos/:owner/:repo/pulls/:N/reviews` rejects inline comments
|
|
6
|
+
* at file:line positions not present in the PR diff. Before adding a finding to
|
|
7
|
+
* the `comments[]` payload, the caller classifies it against the parsed hunk
|
|
8
|
+
* ranges. Out-of-diff findings are NOT silently dropped or shifted — the caller
|
|
9
|
+
* downgrades them to a top-level "Findings outside the PR diff" subsection.
|
|
10
|
+
*
|
|
11
|
+
* Two parsers feed one classifier with a single shared {@link HunkRange} shape:
|
|
12
|
+
* - {@link parsePullFilesPatch} for the per-file `patch` field of
|
|
13
|
+
* `gh api /repos/.../pulls/<N>/files` (rich-context mode).
|
|
14
|
+
* - {@link parseUnifiedDiff} for `gh pr diff <N>` (diff-only fallback mode).
|
|
15
|
+
*/
|
|
16
|
+
/** Matches a unified-diff hunk header: `@@ -a,b +c,d @@`. */
|
|
17
|
+
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
18
|
+
function parseHunkHeader(line) {
|
|
19
|
+
const match = line.match(HUNK_HEADER_PATTERN);
|
|
20
|
+
if (!match) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const oldStart = Number(match[1]);
|
|
24
|
+
// A missing count means 1 per unified-diff convention.
|
|
25
|
+
const oldCount = match[2] === undefined ? 1 : Number(match[2]);
|
|
26
|
+
const newStart = Number(match[3]);
|
|
27
|
+
const newCount = match[4] === undefined ? 1 : Number(match[4]);
|
|
28
|
+
return { oldStart, oldCount, newStart, newCount };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse the `patch` field of a single `gh api .../pulls/<N>/files` entry into
|
|
32
|
+
* its hunk ranges. Returns `[]` for an empty patch (e.g., a binary file, whose
|
|
33
|
+
* entry has no `patch`).
|
|
34
|
+
*
|
|
35
|
+
* Caller contract for renamed files: a `gh api .../files` entry carries the
|
|
36
|
+
* pre-rename path in a sibling `previous_filename` field, NOT in `patch`. This
|
|
37
|
+
* parser only sees `patch`, so it cannot populate `HunkRange.previousFilename`
|
|
38
|
+
* in rich-context (gh api) mode. When a finding is reported against the
|
|
39
|
+
* pre-rename path, the caller must remap it to the entry's post-rename
|
|
40
|
+
* `filename` (using the JSON `previous_filename`) before calling
|
|
41
|
+
* `classifyFinding` — otherwise the finding is treated as out-of-diff. (The
|
|
42
|
+
* `gh pr diff` path handles this internally: `parseUnifiedDiff` records
|
|
43
|
+
* `previousFilename` from the `rename from` header.)
|
|
44
|
+
*/
|
|
45
|
+
export function parsePullFilesPatch(patch) {
|
|
46
|
+
if (!patch) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const ranges = [];
|
|
50
|
+
for (const line of patch.split('\n')) {
|
|
51
|
+
const hunk = parseHunkHeader(line);
|
|
52
|
+
if (hunk) {
|
|
53
|
+
ranges.push(hunk);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return ranges;
|
|
57
|
+
}
|
|
58
|
+
/** Strip a leading `a/` or `b/` diff prefix from a path token. */
|
|
59
|
+
function stripDiffPrefix(path) {
|
|
60
|
+
if (path.startsWith('a/') || path.startsWith('b/')) {
|
|
61
|
+
return path.slice(2);
|
|
62
|
+
}
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parse a full `gh pr diff <N>` unified diff into a map of post-image file path
|
|
67
|
+
* → hunk ranges. Renamed files are keyed under their post-rename path with
|
|
68
|
+
* `previousFilename` recorded; binary files map to an empty array.
|
|
69
|
+
*/
|
|
70
|
+
export function parseUnifiedDiff(diff) {
|
|
71
|
+
const byFile = {};
|
|
72
|
+
if (!diff) {
|
|
73
|
+
return byFile;
|
|
74
|
+
}
|
|
75
|
+
const lines = diff.split('\n');
|
|
76
|
+
let currentFile = null;
|
|
77
|
+
let previousFilename;
|
|
78
|
+
let renameFrom;
|
|
79
|
+
const ensureFile = (path) => {
|
|
80
|
+
if (!byFile[path]) {
|
|
81
|
+
byFile[path] = [];
|
|
82
|
+
}
|
|
83
|
+
return byFile[path];
|
|
84
|
+
};
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
if (line.startsWith('diff --git ')) {
|
|
87
|
+
// `diff --git a/<old> b/<new>` — default the file to the new path.
|
|
88
|
+
const parts = line.slice('diff --git '.length).trim().split(' ');
|
|
89
|
+
const newPath = parts.length >= 2 ? stripDiffPrefix(parts[parts.length - 1]) : null;
|
|
90
|
+
currentFile = newPath;
|
|
91
|
+
previousFilename = undefined;
|
|
92
|
+
renameFrom = undefined;
|
|
93
|
+
if (currentFile) {
|
|
94
|
+
ensureFile(currentFile);
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (line.startsWith('rename from ')) {
|
|
99
|
+
renameFrom = line.slice('rename from '.length).trim();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (line.startsWith('rename to ')) {
|
|
103
|
+
const renameTo = line.slice('rename to '.length).trim();
|
|
104
|
+
// Re-key under the post-rename path and drop the placeholder key.
|
|
105
|
+
if (currentFile && currentFile !== renameTo) {
|
|
106
|
+
delete byFile[currentFile];
|
|
107
|
+
}
|
|
108
|
+
currentFile = renameTo;
|
|
109
|
+
previousFilename = renameFrom;
|
|
110
|
+
ensureFile(currentFile);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (line.startsWith('+++ ')) {
|
|
114
|
+
const token = line.slice('+++ '.length).trim();
|
|
115
|
+
if (token !== '/dev/null') {
|
|
116
|
+
const path = stripDiffPrefix(token);
|
|
117
|
+
if (path && path !== currentFile) {
|
|
118
|
+
if (currentFile) {
|
|
119
|
+
delete byFile[currentFile];
|
|
120
|
+
}
|
|
121
|
+
currentFile = path;
|
|
122
|
+
ensureFile(currentFile);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (line.startsWith('Binary files ')) {
|
|
128
|
+
// Binary file: no hunks, keep the (already-ensured) empty array.
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const hunk = parseHunkHeader(line);
|
|
132
|
+
if (hunk && currentFile) {
|
|
133
|
+
if (previousFilename) {
|
|
134
|
+
hunk.previousFilename = previousFilename;
|
|
135
|
+
}
|
|
136
|
+
ensureFile(currentFile).push(hunk);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return byFile;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Classify a finding against a file's hunk ranges.
|
|
143
|
+
*
|
|
144
|
+
* A line inside any hunk's new-side range is `in-diff` on the `RIGHT` side
|
|
145
|
+
* (additions/context); when the finding is explicitly about removed code it is
|
|
146
|
+
* mapped to the `LEFT` side instead. A line outside every hunk — or a file with
|
|
147
|
+
* no ranges (binary) — is `out-of-diff`, carrying the original `file:line` so
|
|
148
|
+
* the caller can downgrade it without mutating the source finding.
|
|
149
|
+
*/
|
|
150
|
+
export function classifyFinding(finding, ranges) {
|
|
151
|
+
for (const range of ranges) {
|
|
152
|
+
if (finding.removed) {
|
|
153
|
+
const oldEnd = range.oldStart + range.oldCount - 1;
|
|
154
|
+
if (finding.line >= range.oldStart && finding.line <= oldEnd) {
|
|
155
|
+
return { status: 'in-diff', side: 'LEFT', line: finding.line };
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const newEnd = range.newStart + range.newCount - 1;
|
|
160
|
+
if (finding.line >= range.newStart && finding.line <= newEnd) {
|
|
161
|
+
return { status: 'in-diff', side: 'RIGHT', line: finding.line };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { status: 'out-of-diff', file: finding.file, line: finding.line };
|
|
165
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the posted-review-body marker block.
|
|
3
|
+
*
|
|
4
|
+
* The marker block is an HTML comment opened by `<!-- oat-review-metadata`
|
|
5
|
+
* carrying simple single-line YAML-ish scalars (see design.md → Data Models →
|
|
6
|
+
* Posted-review-body). GitHub renders the comment as nothing but preserves it
|
|
7
|
+
* across round-trips, so it is the durable routing channel between the
|
|
8
|
+
* provide-remote skills (writers) and the receive-remote skills (readers).
|
|
9
|
+
*
|
|
10
|
+
* This is intentionally a tolerant single-line scalar parser — NOT a full YAML
|
|
11
|
+
* parser. The schema is flat (no nested structures), so a line-oriented reader
|
|
12
|
+
* is sufficient and avoids a dependency the schema does not need.
|
|
13
|
+
*/
|
|
14
|
+
/** Token that opens the marker comment block. */
|
|
15
|
+
export declare const MARKER_BLOCK_OPEN = "oat-review-metadata";
|
|
16
|
+
export type ReviewInvocation = 'manual' | 'auto';
|
|
17
|
+
export interface MarkerBlock {
|
|
18
|
+
/** Always `true` for an OAT provide-remote review; the discriminator. */
|
|
19
|
+
oat_provide_remote: true;
|
|
20
|
+
/** Full 40-char hex SHA of the reviewed PR HEAD. */
|
|
21
|
+
oat_review_head_sha: string;
|
|
22
|
+
/** Scope token (`pNN`, `final`, …) or the `ad-hoc` sentinel. */
|
|
23
|
+
oat_review_scope: string;
|
|
24
|
+
/**
|
|
25
|
+
* Project path — present only on the project rail. Key existence (not a
|
|
26
|
+
* `null` value) discriminates project rail from ad-hoc rail.
|
|
27
|
+
*/
|
|
28
|
+
oat_project?: string;
|
|
29
|
+
/** How the review was invoked. Defaults to `manual` when omitted. */
|
|
30
|
+
oat_review_invocation: ReviewInvocation;
|
|
31
|
+
/** Forward-compat bag for unknown marker keys. Omitted when none seen. */
|
|
32
|
+
extras?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse the first OAT marker block out of a posted-review body.
|
|
36
|
+
*
|
|
37
|
+
* Returns `null` when the body is not an OAT provide-remote review — either
|
|
38
|
+
* because no marker block exists, because `oat_provide_remote` is not `true`,
|
|
39
|
+
* or because the recorded head SHA is not a valid 40-char hex SHA (a value the
|
|
40
|
+
* caller could not safely narrow against; returning null lets the caller fall
|
|
41
|
+
* back to full-scope review).
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseMarkerBlock(body: string): MarkerBlock | null;
|
|
44
|
+
//# sourceMappingURL=marker-parser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"marker-parser.d.ts","sourceRoot":"","sources":["../../src/review-remote/marker-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,wBAAwB,CAAC;AAavD,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEjD,MAAM,WAAW,WAAW;IAC1B,yEAAyE;IACzE,kBAAkB,EAAE,IAAI,CAAC;IACzB,oDAAoD;IACpD,mBAAmB,EAAE,MAAM,CAAC;IAC5B,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qEAAqE;IACrE,qBAAqB,EAAE,gBAAgB,CAAC;IACxC,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAUD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAqEjE"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the posted-review-body marker block.
|
|
3
|
+
*
|
|
4
|
+
* The marker block is an HTML comment opened by `<!-- oat-review-metadata`
|
|
5
|
+
* carrying simple single-line YAML-ish scalars (see design.md → Data Models →
|
|
6
|
+
* Posted-review-body). GitHub renders the comment as nothing but preserves it
|
|
7
|
+
* across round-trips, so it is the durable routing channel between the
|
|
8
|
+
* provide-remote skills (writers) and the receive-remote skills (readers).
|
|
9
|
+
*
|
|
10
|
+
* This is intentionally a tolerant single-line scalar parser — NOT a full YAML
|
|
11
|
+
* parser. The schema is flat (no nested structures), so a line-oriented reader
|
|
12
|
+
* is sufficient and avoids a dependency the schema does not need.
|
|
13
|
+
*/
|
|
14
|
+
/** Token that opens the marker comment block. */
|
|
15
|
+
export const MARKER_BLOCK_OPEN = 'oat-review-metadata';
|
|
16
|
+
/**
|
|
17
|
+
* Matches the first `<!-- oat-review-metadata ... -->` HTML comment block.
|
|
18
|
+
* Non-greedy body capture so a later comment in prose never wins.
|
|
19
|
+
*/
|
|
20
|
+
const MARKER_BLOCK_PATTERN = new RegExp(`<!--\\s*${MARKER_BLOCK_OPEN}\\s*([\\s\\S]*?)-->`);
|
|
21
|
+
/** A full 40-character lowercase/uppercase hex SHA. */
|
|
22
|
+
const FULL_SHA_PATTERN = /^[0-9a-fA-F]{40}$/;
|
|
23
|
+
const KNOWN_KEYS = new Set([
|
|
24
|
+
'oat_provide_remote',
|
|
25
|
+
'oat_review_head_sha',
|
|
26
|
+
'oat_review_scope',
|
|
27
|
+
'oat_project',
|
|
28
|
+
'oat_review_invocation',
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Parse the first OAT marker block out of a posted-review body.
|
|
32
|
+
*
|
|
33
|
+
* Returns `null` when the body is not an OAT provide-remote review — either
|
|
34
|
+
* because no marker block exists, because `oat_provide_remote` is not `true`,
|
|
35
|
+
* or because the recorded head SHA is not a valid 40-char hex SHA (a value the
|
|
36
|
+
* caller could not safely narrow against; returning null lets the caller fall
|
|
37
|
+
* back to full-scope review).
|
|
38
|
+
*/
|
|
39
|
+
export function parseMarkerBlock(body) {
|
|
40
|
+
if (!body) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const match = body.match(MARKER_BLOCK_PATTERN);
|
|
44
|
+
if (!match || match[1] === undefined) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const raw = {};
|
|
48
|
+
for (const line of match[1].split('\n')) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (trimmed === '') {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const sep = trimmed.indexOf(':');
|
|
54
|
+
if (sep === -1) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const key = trimmed.slice(0, sep).trim().toLowerCase();
|
|
58
|
+
const value = trimmed.slice(sep + 1).trim();
|
|
59
|
+
if (key === '') {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
raw[key] = value;
|
|
63
|
+
}
|
|
64
|
+
if (raw['oat_provide_remote'] !== 'true') {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const headSha = raw['oat_review_head_sha'];
|
|
68
|
+
if (!headSha || !FULL_SHA_PATTERN.test(headSha)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const scope = raw['oat_review_scope'];
|
|
72
|
+
if (!scope) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const invocationRaw = raw['oat_review_invocation'];
|
|
76
|
+
const oat_review_invocation = invocationRaw === 'auto' ? 'auto' : 'manual';
|
|
77
|
+
const block = {
|
|
78
|
+
oat_provide_remote: true,
|
|
79
|
+
oat_review_head_sha: headSha,
|
|
80
|
+
oat_review_scope: scope,
|
|
81
|
+
oat_review_invocation,
|
|
82
|
+
};
|
|
83
|
+
// Project key existence (not a null value) discriminates the rail.
|
|
84
|
+
if ('oat_project' in raw && raw['oat_project'] !== '') {
|
|
85
|
+
block.oat_project = raw['oat_project'];
|
|
86
|
+
}
|
|
87
|
+
const extras = {};
|
|
88
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
89
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
90
|
+
extras[key] = value;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(extras).length > 0) {
|
|
94
|
+
block.extras = extras;
|
|
95
|
+
}
|
|
96
|
+
return block;
|
|
97
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-review narrowing filter + stale-SHA guard (see design.md → Component
|
|
3
|
+
* Design + Error Handling → Stale prior-review SHA).
|
|
4
|
+
*
|
|
5
|
+
* Given the PR's prior provide-remote reviews and a `(rail, project, scope)`
|
|
6
|
+
* tuple, pick the most recent matching review and decide whether the current
|
|
7
|
+
* pass can narrow to `<prior_sha>..<HEAD>`. The guard runs existence + ancestry
|
|
8
|
+
* checks (via an injected {@link GitInvoker}) before declaring a narrowing
|
|
9
|
+
* range, so a rebased/force-pushed/shallow prior SHA never produces a
|
|
10
|
+
* misleading partial range.
|
|
11
|
+
*/
|
|
12
|
+
import type { ReviewInvocation } from './marker-parser.js';
|
|
13
|
+
export type ReviewRail = 'ad-hoc' | 'project';
|
|
14
|
+
/** A prior provide-remote review, distilled from its parsed marker block. */
|
|
15
|
+
export interface PriorReview {
|
|
16
|
+
/** Full 40-char SHA recorded by the prior review. */
|
|
17
|
+
headSha: string;
|
|
18
|
+
/** Scope token or the `ad-hoc` sentinel. */
|
|
19
|
+
scope: string;
|
|
20
|
+
/** Project path — present only for project-rail reviews. */
|
|
21
|
+
project?: string;
|
|
22
|
+
invocation: ReviewInvocation;
|
|
23
|
+
/** ISO-8601 review submission timestamp, used for descending sort. */
|
|
24
|
+
submittedAt: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Narrow git surface needed by the guard. Callers pass a worktree-bound
|
|
28
|
+
* implementation (rich-context mode) or a fetch-capable one (diff-only mode);
|
|
29
|
+
* tests pass stubs.
|
|
30
|
+
*/
|
|
31
|
+
export interface GitInvoker {
|
|
32
|
+
/** `git cat-file -e <sha>` — does the object exist locally? */
|
|
33
|
+
objectExists(sha: string): Promise<boolean>;
|
|
34
|
+
/** `git merge-base --is-ancestor <sha> <head>` — reachable from HEAD? */
|
|
35
|
+
isAncestor(sha: string, head: string): Promise<boolean>;
|
|
36
|
+
/** `git fetch origin <sha>:<ref>` — fetch a single ref (diff-only mode). */
|
|
37
|
+
fetchRef(sha: string): Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
export interface NarrowingInput {
|
|
40
|
+
reviews: PriorReview[];
|
|
41
|
+
rail: ReviewRail;
|
|
42
|
+
/** Resolved project path on the project rail; `null` on the ad-hoc rail. */
|
|
43
|
+
project: string | null;
|
|
44
|
+
/** Current scope token, or `ad-hoc`. */
|
|
45
|
+
scope: string;
|
|
46
|
+
/** Current PR HEAD SHA. */
|
|
47
|
+
headSha: string;
|
|
48
|
+
git: GitInvoker;
|
|
49
|
+
/** `--narrow` was passed explicitly: guard failure becomes a hard error. */
|
|
50
|
+
forceNarrow?: boolean;
|
|
51
|
+
/** `workflow.autoNarrowReReviewScope === true`: never prompt. */
|
|
52
|
+
autoNarrow?: boolean;
|
|
53
|
+
/** Diff-only mode (no ephemeral worktree): fetch the single ref first. */
|
|
54
|
+
diffOnly?: boolean;
|
|
55
|
+
}
|
|
56
|
+
export type FullScopeReason = 'no-prior-review' | 'stale-sha';
|
|
57
|
+
export interface NarrowRangeResult {
|
|
58
|
+
kind: 'narrow-range';
|
|
59
|
+
priorSha: string;
|
|
60
|
+
headSha: string;
|
|
61
|
+
/** Whether the caller still owes the user a confirm prompt. */
|
|
62
|
+
prompted: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface FullScopeFallbackResult {
|
|
65
|
+
kind: 'full-scope-fallback';
|
|
66
|
+
reason: FullScopeReason;
|
|
67
|
+
/** The stale SHA that triggered the fallback, when applicable. */
|
|
68
|
+
priorSha?: string;
|
|
69
|
+
prompted: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface HardErrorResult {
|
|
72
|
+
kind: 'hard-error';
|
|
73
|
+
reason: 'stale-sha';
|
|
74
|
+
priorSha: string;
|
|
75
|
+
}
|
|
76
|
+
export type NarrowingResult = NarrowRangeResult | FullScopeFallbackResult | HardErrorResult;
|
|
77
|
+
/**
|
|
78
|
+
* Pick the narrowing target for the current re-review pass.
|
|
79
|
+
*/
|
|
80
|
+
export declare function pickNarrowingTarget(input: NarrowingInput): Promise<NarrowingResult>;
|
|
81
|
+
//# sourceMappingURL=narrowing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"narrowing.d.ts","sourceRoot":"","sources":["../../src/review-remote/narrowing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAExD,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE9C,6EAA6E;AAC7E,MAAM,WAAW,WAAW;IAC1B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,sEAAsE;IACtE,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,+DAA+D;IAC/D,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,yEAAyE;IACzE,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACxD,4EAA4E;IAC5E,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,IAAI,EAAE,UAAU,CAAC;IACjB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,wCAAwC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,UAAU,CAAC;IAChB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,iEAAiE;IACjE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,MAAM,eAAe,GAAG,iBAAiB,GAAG,WAAW,CAAC;AAE9D,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,cAAc,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,MAAM,EAAE,eAAe,CAAC;IACxB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IACnB,MAAM,EAAE,WAAW,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,eAAe,GACvB,iBAAiB,GACjB,uBAAuB,GACvB,eAAe,CAAC;AAiDpB;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,KAAK,EAAE,cAAc,GACpB,OAAO,CAAC,eAAe,CAAC,CAuC1B"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-review narrowing filter + stale-SHA guard (see design.md → Component
|
|
3
|
+
* Design + Error Handling → Stale prior-review SHA).
|
|
4
|
+
*
|
|
5
|
+
* Given the PR's prior provide-remote reviews and a `(rail, project, scope)`
|
|
6
|
+
* tuple, pick the most recent matching review and decide whether the current
|
|
7
|
+
* pass can narrow to `<prior_sha>..<HEAD>`. The guard runs existence + ancestry
|
|
8
|
+
* checks (via an injected {@link GitInvoker}) before declaring a narrowing
|
|
9
|
+
* range, so a rebased/force-pushed/shallow prior SHA never produces a
|
|
10
|
+
* misleading partial range.
|
|
11
|
+
*/
|
|
12
|
+
/** Does a prior review match the current `(rail, project, scope)` tuple? */
|
|
13
|
+
function matchesTuple(review, input) {
|
|
14
|
+
if (input.rail === 'ad-hoc') {
|
|
15
|
+
// Ad-hoc: scope sentinel must match AND there must be no project key.
|
|
16
|
+
return review.scope === 'ad-hoc' && review.project === undefined;
|
|
17
|
+
}
|
|
18
|
+
// Project rail: same project AND same scope.
|
|
19
|
+
return (review.project !== undefined &&
|
|
20
|
+
review.project === input.project &&
|
|
21
|
+
review.scope === input.scope);
|
|
22
|
+
}
|
|
23
|
+
function mostRecentMatch(input) {
|
|
24
|
+
const matches = input.reviews.filter((r) => matchesTuple(r, input));
|
|
25
|
+
if (matches.length === 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Descending by submission time — newest matching review wins.
|
|
29
|
+
matches.sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
|
|
30
|
+
return matches[0];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Run the two-step stale-SHA guard. In diff-only mode, attempt a single-ref
|
|
34
|
+
* fetch before re-checking existence. Returns true only when the prior SHA both
|
|
35
|
+
* exists locally and is an ancestor of the current PR HEAD.
|
|
36
|
+
*/
|
|
37
|
+
async function guardPasses(priorSha, input) {
|
|
38
|
+
let exists = await input.git.objectExists(priorSha);
|
|
39
|
+
if (!exists && input.diffOnly) {
|
|
40
|
+
const fetched = await input.git.fetchRef(priorSha);
|
|
41
|
+
if (!fetched) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
exists = await input.git.objectExists(priorSha);
|
|
45
|
+
}
|
|
46
|
+
if (!exists) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return input.git.isAncestor(priorSha, input.headSha);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pick the narrowing target for the current re-review pass.
|
|
53
|
+
*/
|
|
54
|
+
export async function pickNarrowingTarget(input) {
|
|
55
|
+
const prior = mostRecentMatch(input);
|
|
56
|
+
if (!prior) {
|
|
57
|
+
return {
|
|
58
|
+
kind: 'full-scope-fallback',
|
|
59
|
+
reason: 'no-prior-review',
|
|
60
|
+
prompted: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const passed = await guardPasses(prior.headSha, input);
|
|
64
|
+
if (passed) {
|
|
65
|
+
return {
|
|
66
|
+
kind: 'narrow-range',
|
|
67
|
+
priorSha: prior.headSha,
|
|
68
|
+
headSha: input.headSha,
|
|
69
|
+
// Auto-narrow skips the prompt; otherwise the caller still confirms.
|
|
70
|
+
prompted: !input.autoNarrow,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Guard failed. The user explicitly asked to narrow → hard error.
|
|
74
|
+
if (input.forceNarrow) {
|
|
75
|
+
return {
|
|
76
|
+
kind: 'hard-error',
|
|
77
|
+
reason: 'stale-sha',
|
|
78
|
+
priorSha: prior.headSha,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Otherwise fall back to full scope. Auto-narrow surfaces this as the
|
|
82
|
+
// auto-fallback notice (no prompt); manual mode does not prompt here either —
|
|
83
|
+
// the fallback is informational.
|
|
84
|
+
return {
|
|
85
|
+
kind: 'full-scope-fallback',
|
|
86
|
+
reason: 'stale-sha',
|
|
87
|
+
priorSha: prior.headSha,
|
|
88
|
+
prompted: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project resolution helper for the project-rail provide-remote skill
|
|
3
|
+
* (see design.md → Component Design → `oat-project-review-provide-remote`).
|
|
4
|
+
*
|
|
5
|
+
* Locates the OAT project on machine B by scanning the PR diff for
|
|
6
|
+
* `state.md` files two levels deep under `.oat/projects/` (scope/project). An
|
|
7
|
+
* explicit `--project <path>` override takes precedence over the scan and is
|
|
8
|
+
* validated to resolve to a directory containing `state.md`.
|
|
9
|
+
*
|
|
10
|
+
* The result is a discriminated union mirroring {@link NarrowingResult}'s
|
|
11
|
+
* pattern so callers branch on `kind`.
|
|
12
|
+
*/
|
|
13
|
+
export interface ResolvedProject {
|
|
14
|
+
kind: 'resolved';
|
|
15
|
+
/** Project directory path (no trailing slash, no `state.md` suffix). */
|
|
16
|
+
projectPath: string;
|
|
17
|
+
}
|
|
18
|
+
export interface AmbiguousProject {
|
|
19
|
+
kind: 'ambiguous';
|
|
20
|
+
/** Sorted, de-duplicated candidate project directory paths. */
|
|
21
|
+
candidates: string[];
|
|
22
|
+
}
|
|
23
|
+
export interface ProjectNotFound {
|
|
24
|
+
kind: 'not-found';
|
|
25
|
+
}
|
|
26
|
+
export interface InvalidOverride {
|
|
27
|
+
kind: 'invalid-override';
|
|
28
|
+
overridePath: string;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
export type ResolveResult = ResolvedProject | AmbiguousProject | ProjectNotFound | InvalidOverride;
|
|
32
|
+
export interface ResolveOptions {
|
|
33
|
+
/** Explicit `--project <path>` override; takes precedence over diff scan. */
|
|
34
|
+
overridePath?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Existence probe for `state.md`. Injected so tests avoid the real
|
|
37
|
+
* filesystem. Receives a candidate `state.md` path.
|
|
38
|
+
*/
|
|
39
|
+
pathExists?: (path: string) => boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the target OAT project from a PR's changed-file list, honoring an
|
|
43
|
+
* explicit override.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveProject(diffFiles: string[], options?: ResolveOptions): ResolveResult;
|
|
46
|
+
//# sourceMappingURL=project-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"project-resolver.d.ts","sourceRoot":"","sources":["../../src/review-remote/project-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,UAAU,CAAC;IACjB,wEAAwE;IACxE,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,WAAW,CAAC;IAClB,+DAA+D;IAC/D,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,kBAAkB,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,aAAa,GACrB,eAAe,GACf,gBAAgB,GAChB,eAAe,GACf,eAAe,CAAC;AAEpB,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;CACxC;AAaD;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EAAE,EACnB,OAAO,GAAE,cAAmB,GAC3B,aAAa,CAgCf"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project resolution helper for the project-rail provide-remote skill
|
|
3
|
+
* (see design.md → Component Design → `oat-project-review-provide-remote`).
|
|
4
|
+
*
|
|
5
|
+
* Locates the OAT project on machine B by scanning the PR diff for
|
|
6
|
+
* `state.md` files two levels deep under `.oat/projects/` (scope/project). An
|
|
7
|
+
* explicit `--project <path>` override takes precedence over the scan and is
|
|
8
|
+
* validated to resolve to a directory containing `state.md`.
|
|
9
|
+
*
|
|
10
|
+
* The result is a discriminated union mirroring {@link NarrowingResult}'s
|
|
11
|
+
* pattern so callers branch on `kind`.
|
|
12
|
+
*/
|
|
13
|
+
/** Matches a two-level `.oat/projects/<scope>/<project>/state.md` path. */
|
|
14
|
+
const STATE_MD_PATTERN = /^\.oat\/projects\/([^/]+)\/([^/]+)\/state\.md$/;
|
|
15
|
+
/** Strip a trailing slash and a trailing `state.md` segment from a path. */
|
|
16
|
+
function normalizeProjectDir(path) {
|
|
17
|
+
let dir = path.replace(/\/+$/, '');
|
|
18
|
+
if (dir.endsWith('/state.md')) {
|
|
19
|
+
dir = dir.slice(0, -'/state.md'.length);
|
|
20
|
+
}
|
|
21
|
+
else if (dir === 'state.md') {
|
|
22
|
+
dir = '';
|
|
23
|
+
}
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the target OAT project from a PR's changed-file list, honoring an
|
|
28
|
+
* explicit override.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveProject(diffFiles, options = {}) {
|
|
31
|
+
// 1. Explicit override wins, but must point at a real project (has state.md).
|
|
32
|
+
if (options.overridePath !== undefined && options.overridePath !== '') {
|
|
33
|
+
const projectDir = normalizeProjectDir(options.overridePath);
|
|
34
|
+
const stateMdPath = `${projectDir}/state.md`;
|
|
35
|
+
const exists = options.pathExists?.(stateMdPath) ?? false;
|
|
36
|
+
if (!exists) {
|
|
37
|
+
return {
|
|
38
|
+
kind: 'invalid-override',
|
|
39
|
+
overridePath: options.overridePath,
|
|
40
|
+
message: `--project path "${options.overridePath}" does not resolve to a directory containing state.md.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'resolved', projectPath: projectDir };
|
|
44
|
+
}
|
|
45
|
+
// 2. Diff scan for `.oat/projects/<scope>/<project>/state.md`.
|
|
46
|
+
const candidates = new Set();
|
|
47
|
+
for (const file of diffFiles) {
|
|
48
|
+
const match = file.match(STATE_MD_PATTERN);
|
|
49
|
+
if (match) {
|
|
50
|
+
candidates.add(`.oat/projects/${match[1]}/${match[2]}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (candidates.size === 0) {
|
|
54
|
+
return { kind: 'not-found' };
|
|
55
|
+
}
|
|
56
|
+
if (candidates.size > 1) {
|
|
57
|
+
return { kind: 'ambiguous', candidates: [...candidates].sort() };
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'resolved', projectPath: [...candidates][0] };
|
|
60
|
+
}
|