@open-agent-toolkit/cli 0.1.12 → 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.
Files changed (45) hide show
  1. package/assets/agents/oat-reviewer.md +54 -10
  2. package/assets/docs/workflows/projects/reviews.md +21 -2
  3. package/assets/docs/workflows/skills/index.md +2 -0
  4. package/assets/public-package-versions.json +4 -4
  5. package/assets/skills/oat-project-review-provide-remote/SKILL.md +354 -0
  6. package/assets/skills/oat-project-review-receive/SKILL.md +7 -10
  7. package/assets/skills/oat-project-review-receive-remote/SKILL.md +4 -4
  8. package/assets/skills/oat-review-provide-remote/SKILL.md +273 -0
  9. package/assets/skills/oat-review-receive/SKILL.md +6 -6
  10. package/assets/skills/oat-review-receive-remote/SKILL.md +5 -5
  11. package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
  12. package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
  13. package/dist/commands/init/tools/shared/skill-manifest.js +2 -0
  14. package/dist/config/json.d.ts +2 -0
  15. package/dist/config/json.d.ts.map +1 -0
  16. package/dist/config/json.js +29 -0
  17. package/dist/config/oat-config.d.ts.map +1 -1
  18. package/dist/config/oat-config.js +4 -3
  19. package/dist/config/sync-config.d.ts.map +1 -1
  20. package/dist/config/sync-config.js +2 -1
  21. package/dist/review-remote/body-builder.d.ts +79 -0
  22. package/dist/review-remote/body-builder.d.ts.map +1 -0
  23. package/dist/review-remote/body-builder.js +103 -0
  24. package/dist/review-remote/capability-probe.d.ts +61 -0
  25. package/dist/review-remote/capability-probe.d.ts.map +1 -0
  26. package/dist/review-remote/capability-probe.js +87 -0
  27. package/dist/review-remote/line-mapper.d.ts +81 -0
  28. package/dist/review-remote/line-mapper.d.ts.map +1 -0
  29. package/dist/review-remote/line-mapper.js +165 -0
  30. package/dist/review-remote/marker-parser.d.ts +44 -0
  31. package/dist/review-remote/marker-parser.d.ts.map +1 -0
  32. package/dist/review-remote/marker-parser.js +97 -0
  33. package/dist/review-remote/narrowing.d.ts +81 -0
  34. package/dist/review-remote/narrowing.d.ts.map +1 -0
  35. package/dist/review-remote/narrowing.js +90 -0
  36. package/dist/review-remote/project-resolver.d.ts +46 -0
  37. package/dist/review-remote/project-resolver.d.ts.map +1 -0
  38. package/dist/review-remote/project-resolver.js +60 -0
  39. package/dist/review-remote/reviewer-dispatch.d.ts +108 -0
  40. package/dist/review-remote/reviewer-dispatch.d.ts.map +1 -0
  41. package/dist/review-remote/reviewer-dispatch.js +153 -0
  42. package/dist/review-remote/worktree.d.ts +62 -0
  43. package/dist/review-remote/worktree.d.ts.map +1 -0
  44. package/dist/review-remote/worktree.js +117 -0
  45. package/package.json +3 -2
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Builder for the posted-review-body (see design.md → Data Models →
3
+ * Posted-review-body) and the verdict mapper that decides the GitHub review
4
+ * `event`.
5
+ *
6
+ * The body is the durable handoff to `*-receive-remote`: a leading
7
+ * HTML-comment marker block (parsed back by {@link parseMarkerBlock}) followed
8
+ * by human-readable prose (summary, severity counts, optional minor-fix nudge,
9
+ * optional verification commands).
10
+ */
11
+ import { MARKER_BLOCK_OPEN } from './marker-parser.js';
12
+ /**
13
+ * Map a finding set to the GitHub review verdict: `REQUEST_CHANGES` when any
14
+ * critical or important finding is present, otherwise `COMMENT` (including the
15
+ * zero-findings clean-review case). Never auto-`APPROVE`.
16
+ */
17
+ export function mapVerdict(findings) {
18
+ const hasBlocking = findings.some((f) => f.severity === 'critical' || f.severity === 'important');
19
+ return hasBlocking ? 'REQUEST_CHANGES' : 'COMMENT';
20
+ }
21
+ function countSeverities(findings) {
22
+ const counts = {
23
+ critical: 0,
24
+ important: 0,
25
+ medium: 0,
26
+ minor: 0,
27
+ };
28
+ for (const f of findings) {
29
+ counts[f.severity] += 1;
30
+ }
31
+ return counts;
32
+ }
33
+ /**
34
+ * Emit the leading marker block. Mirrors the parser's expected single-line
35
+ * scalar shape so {@link parseMarkerBlock} round-trips a built body cleanly.
36
+ * `oat_project` is emitted only when present (key-omitted on the ad-hoc rail).
37
+ */
38
+ function buildMarkerBlock(input) {
39
+ const lines = [
40
+ 'oat_provide_remote: true',
41
+ `oat_review_head_sha: ${input.headSha}`,
42
+ `oat_review_scope: ${input.scope}`,
43
+ ];
44
+ if (input.project !== undefined && input.project !== '') {
45
+ lines.push(`oat_project: ${input.project}`);
46
+ }
47
+ lines.push(`oat_review_invocation: ${input.invocation}`);
48
+ return `<!-- ${MARKER_BLOCK_OPEN}\n${lines.join('\n')}\n-->`;
49
+ }
50
+ /**
51
+ * Render the "Findings outside the PR diff" subsection (design.md → Error
52
+ * Handling → Inline-comment line mapping). Each entry shows its original
53
+ * `file:line` reference followed by the preserved finding body. A `null` line
54
+ * renders the bare file path (file-scoped finding). Returns `null` when there
55
+ * are no out-of-diff findings so no empty heading is emitted.
56
+ */
57
+ function buildOutOfDiffSection(findings) {
58
+ if (!findings || findings.length === 0) {
59
+ return null;
60
+ }
61
+ const entries = findings.map((f) => {
62
+ const reference = f.line === null ? f.file : `${f.file}:${f.line}`;
63
+ const heading = f.title ? `${reference} — ${f.title}` : reference;
64
+ return `- **${heading}**\n\n ${f.body}`;
65
+ });
66
+ return ['## Findings outside the PR diff', '', ...entries].join('\n');
67
+ }
68
+ const MINOR_FIX_NUDGE = 'Minor findings are included inline. We recommend fixing minors during ' +
69
+ 'this cycle rather than tracking them as backlog items — they are usually ' +
70
+ 'faster to just resolve than to manage.';
71
+ /**
72
+ * Build the posted-review body and compute its verdict.
73
+ */
74
+ export function buildReviewBody(input) {
75
+ const counts = countSeverities(input.findings);
76
+ const verdict = mapVerdict(input.findings);
77
+ const sections = [
78
+ buildMarkerBlock(input),
79
+ `## Summary\n\n${input.summary}`,
80
+ [
81
+ '## Severity Counts',
82
+ '',
83
+ `- Critical: ${counts.critical}`,
84
+ `- Important: ${counts.important}`,
85
+ `- Medium: ${counts.medium}`,
86
+ `- Minor: ${counts.minor}`,
87
+ ].join('\n'),
88
+ ];
89
+ const outOfDiffSection = buildOutOfDiffSection(input.outOfDiffFindings);
90
+ if (outOfDiffSection !== null) {
91
+ sections.push(outOfDiffSection);
92
+ }
93
+ if (counts.minor > 0) {
94
+ sections.push(`## Notes\n\n${MINOR_FIX_NUDGE}`);
95
+ }
96
+ if (input.verificationCommands && input.verificationCommands.length > 0) {
97
+ const commands = input.verificationCommands
98
+ .map((cmd) => `- \`${cmd}\``)
99
+ .join('\n');
100
+ sections.push(`## Verification\n\n${commands}`);
101
+ }
102
+ return { body: sections.join('\n\n'), verdict };
103
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Capability probe for the optional `agent-reviews` posting flow (see design.md
3
+ * → Error Handling → Capability probe).
4
+ *
5
+ * The provide-remote skills prefer `agent-reviews` for tooling symmetry IF it
6
+ * exposes a "post / submit a full PR review" flow. The probe runs a
7
+ * non-mutating `agent-reviews --help` once, parses the help text for such a
8
+ * flag, and caches the result for the run. `gh api` is always the safe
9
+ * fallback — the probe never fails the skill.
10
+ *
11
+ * Empirical current-state finding (probed 2026-05-29, `agent-reviews@1.0.2`):
12
+ * the CLI exposes list/detail/watch commands plus `--reply <id>` for replying
13
+ * to existing comments, but NO command or flag that posts a full PR review. So
14
+ * the probe returns `not-supported` today and the skill posts via `gh api`.
15
+ * The probe is forward-compatible: when `agent-reviews` gains a posting flow,
16
+ * this parser recognizes it without a code change to the skill.
17
+ */
18
+ /** Outcome of running the `agent-reviews --help` probe command. */
19
+ export interface HelpProbeResult {
20
+ /** Whether the probe command ran to completion (exit 0). */
21
+ ok: boolean;
22
+ /** Captured stdout (the help text), empty when the command errored. */
23
+ stdout: string;
24
+ /** Captured stderr, when the command errored. */
25
+ stderr?: string;
26
+ }
27
+ /**
28
+ * Injectable invoker that runs `agent-reviews --help` (or equivalent). The
29
+ * skill provides a real `npx agent-reviews --help` runner; tests pass a stub.
30
+ * It MUST resolve (never reject) — a failed invocation is reported via
31
+ * `ok: false` so the probe can map it to `unknown`.
32
+ */
33
+ export type HelpProbe = () => Promise<HelpProbeResult>;
34
+ export type PostingSupport = 'supported' | 'not-supported' | 'unknown';
35
+ export interface CapabilityResult {
36
+ /**
37
+ * Whether `agent-reviews` exposes a review-posting flow:
38
+ * - `supported`: a posting flag was found (`flag` is set).
39
+ * - `not-supported`: the probe ran but no posting flag exists.
40
+ * - `unknown`: the probe could not run; caller falls back to `gh api`.
41
+ */
42
+ posting: PostingSupport;
43
+ /** The discovered posting flag (e.g., `--post-review`), when supported. */
44
+ flag?: string;
45
+ }
46
+ /**
47
+ * Per-run cache. Pass the same object across calls within a single skill run so
48
+ * the help command is invoked at most once. Omit it to force a fresh probe.
49
+ */
50
+ export interface ProbeCache {
51
+ result?: CapabilityResult;
52
+ }
53
+ /**
54
+ * Probe whether `agent-reviews` supports posting a full PR review.
55
+ *
56
+ * @param probe injectable `agent-reviews --help` runner.
57
+ * @param cache optional per-run cache; when provided, the probe runs at most
58
+ * once and subsequent calls return the cached result.
59
+ */
60
+ export declare function probeAgentReviewsPosting(probe: HelpProbe, cache?: ProbeCache): Promise<CapabilityResult>;
61
+ //# sourceMappingURL=capability-probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capability-probe.d.ts","sourceRoot":"","sources":["../../src/review-remote/capability-probe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,mEAAmE;AACnE,MAAM,WAAW,eAAe;IAC9B,4DAA4D;IAC5D,EAAE,EAAE,OAAO,CAAC;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,eAAe,CAAC,CAAC;AAEvD,MAAM,MAAM,cAAc,GAAG,WAAW,GAAG,eAAe,GAAG,SAAS,CAAC;AAEvE,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,OAAO,EAAE,cAAc,CAAC;IACxB,2EAA2E;IAC3E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,gBAAgB,CAAC;CAC3B;AA4CD;;;;;;GAMG;AACH,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,SAAS,EAChB,KAAK,CAAC,EAAE,UAAU,GACjB,OAAO,CAAC,gBAAgB,CAAC,CA2B3B"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Capability probe for the optional `agent-reviews` posting flow (see design.md
3
+ * → Error Handling → Capability probe).
4
+ *
5
+ * The provide-remote skills prefer `agent-reviews` for tooling symmetry IF it
6
+ * exposes a "post / submit a full PR review" flow. The probe runs a
7
+ * non-mutating `agent-reviews --help` once, parses the help text for such a
8
+ * flag, and caches the result for the run. `gh api` is always the safe
9
+ * fallback — the probe never fails the skill.
10
+ *
11
+ * Empirical current-state finding (probed 2026-05-29, `agent-reviews@1.0.2`):
12
+ * the CLI exposes list/detail/watch commands plus `--reply <id>` for replying
13
+ * to existing comments, but NO command or flag that posts a full PR review. So
14
+ * the probe returns `not-supported` today and the skill posts via `gh api`.
15
+ * The probe is forward-compatible: when `agent-reviews` gains a posting flow,
16
+ * this parser recognizes it without a code change to the skill.
17
+ */
18
+ /**
19
+ * Candidate flag tokens that, if present in the help text, indicate a
20
+ * full-review posting flow. Reply-to-comment flags (`--reply`) are deliberately
21
+ * excluded — replying to an existing comment is not posting a review.
22
+ *
23
+ * The intent-specific tokens (`--post-review`, `--submit-review`) come first;
24
+ * the generic `--review`/`--post` are kept as broad fallbacks. The generic
25
+ * tokens carry a small forward-compat false-positive risk: a future
26
+ * `agent-reviews` could add `--review` with an unrelated meaning (e.g.
27
+ * "show a review"), which the probe would report as `supported`. That risk is
28
+ * accepted here because `gh api` remains the safe posting path even when the
29
+ * probe mis-detects an `agent-reviews` posting flow — a false positive degrades
30
+ * to the same fallback as `unknown`, it never drops or corrupts a review.
31
+ */
32
+ const POSTING_FLAG_CANDIDATES = [
33
+ '--post-review',
34
+ '--submit-review',
35
+ '--review',
36
+ '--post',
37
+ ];
38
+ /** Extract the first posting flag present in the help text, or `undefined`. */
39
+ function findPostingFlag(helpText) {
40
+ for (const flag of POSTING_FLAG_CANDIDATES) {
41
+ // Word-boundary-ish match: the flag followed by whitespace, `=`, or EOL, so
42
+ // `--post` does not spuriously match `--post-review` (and vice versa) and a
43
+ // substring of an unrelated token never matches.
44
+ const pattern = new RegExp(`(?:^|\\s)${escapeRegExp(flag)}(?=$|[\\s=])`, 'm');
45
+ if (pattern.test(helpText)) {
46
+ return flag;
47
+ }
48
+ }
49
+ return undefined;
50
+ }
51
+ function escapeRegExp(value) {
52
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
+ }
54
+ /**
55
+ * Probe whether `agent-reviews` supports posting a full PR review.
56
+ *
57
+ * @param probe injectable `agent-reviews --help` runner.
58
+ * @param cache optional per-run cache; when provided, the probe runs at most
59
+ * once and subsequent calls return the cached result.
60
+ */
61
+ export async function probeAgentReviewsPosting(probe, cache) {
62
+ if (cache?.result !== undefined) {
63
+ return cache.result;
64
+ }
65
+ let outcome;
66
+ try {
67
+ outcome = await probe();
68
+ }
69
+ catch {
70
+ // A rejecting probe is treated the same as an errored one: unknown.
71
+ outcome = { ok: false, stdout: '' };
72
+ }
73
+ let result;
74
+ if (!outcome.ok) {
75
+ result = { posting: 'unknown' };
76
+ }
77
+ else {
78
+ const flag = findPostingFlag(outcome.stdout);
79
+ result = flag
80
+ ? { posting: 'supported', flag }
81
+ : { posting: 'not-supported' };
82
+ }
83
+ if (cache) {
84
+ cache.result = result;
85
+ }
86
+ return result;
87
+ }
@@ -0,0 +1,81 @@
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
+ /** A single diff hunk's old- and new-side ranges. Shared by both parsers. */
17
+ export interface HunkRange {
18
+ /** 1-based start line on the old (LEFT) side. */
19
+ oldStart: number;
20
+ /** Line count on the old side. */
21
+ oldCount: number;
22
+ /** 1-based start line on the new (RIGHT) side. */
23
+ newStart: number;
24
+ /** Line count on the new side. */
25
+ newCount: number;
26
+ /** Pre-rename path, when the owning file was renamed. */
27
+ previousFilename?: string;
28
+ }
29
+ /** A finding location to classify against a file's hunk ranges. */
30
+ export interface FindingLocation {
31
+ file: string;
32
+ line: number;
33
+ /** Set when the finding is explicitly about removed code (LEFT side). */
34
+ removed?: boolean;
35
+ }
36
+ export interface InDiffClassification {
37
+ status: 'in-diff';
38
+ side: 'RIGHT' | 'LEFT';
39
+ line: number;
40
+ }
41
+ export interface OutOfDiffClassification {
42
+ status: 'out-of-diff';
43
+ /** Original file reference, carried through for the downgrade subsection. */
44
+ file: string;
45
+ /** Original line reference, carried through for the downgrade subsection. */
46
+ line: number;
47
+ }
48
+ export type FindingClassification = InDiffClassification | OutOfDiffClassification;
49
+ /**
50
+ * Parse the `patch` field of a single `gh api .../pulls/<N>/files` entry into
51
+ * its hunk ranges. Returns `[]` for an empty patch (e.g., a binary file, whose
52
+ * entry has no `patch`).
53
+ *
54
+ * Caller contract for renamed files: a `gh api .../files` entry carries the
55
+ * pre-rename path in a sibling `previous_filename` field, NOT in `patch`. This
56
+ * parser only sees `patch`, so it cannot populate `HunkRange.previousFilename`
57
+ * in rich-context (gh api) mode. When a finding is reported against the
58
+ * pre-rename path, the caller must remap it to the entry's post-rename
59
+ * `filename` (using the JSON `previous_filename`) before calling
60
+ * `classifyFinding` — otherwise the finding is treated as out-of-diff. (The
61
+ * `gh pr diff` path handles this internally: `parseUnifiedDiff` records
62
+ * `previousFilename` from the `rename from` header.)
63
+ */
64
+ export declare function parsePullFilesPatch(patch: string): HunkRange[];
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 declare function parseUnifiedDiff(diff: string): Record<string, HunkRange[]>;
71
+ /**
72
+ * Classify a finding against a file's hunk ranges.
73
+ *
74
+ * A line inside any hunk's new-side range is `in-diff` on the `RIGHT` side
75
+ * (additions/context); when the finding is explicitly about removed code it is
76
+ * mapped to the `LEFT` side instead. A line outside every hunk — or a file with
77
+ * no ranges (binary) — is `out-of-diff`, carrying the original `file:line` so
78
+ * the caller can downgrade it without mutating the source finding.
79
+ */
80
+ export declare function classifyFinding(finding: FindingLocation, ranges: HunkRange[]): FindingClassification;
81
+ //# sourceMappingURL=line-mapper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"line-mapper.d.ts","sourceRoot":"","sources":["../../src/review-remote/line-mapper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,6EAA6E;AAC7E,MAAM,WAAW,SAAS;IACxB,iDAAiD;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,mEAAmE;AACnE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,SAAS,CAAC;IAClB,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,aAAa,CAAC;IACtB,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;IACb,6EAA6E;IAC7E,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,MAAM,qBAAqB,GAC7B,oBAAoB,GACpB,uBAAuB,CAAC;AAkB5B;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,CAY9D;AAUD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC,CA+E1E;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,SAAS,EAAE,GAClB,qBAAqB,CAevB"}
@@ -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
+ }