pr-shepherd 0.40.0 → 0.41.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/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -2
- package/bin/api.d.mts +1 -1
- package/bin/api.mjs +10 -26
- package/bin/journal/extract.d.mts +16 -0
- package/bin/journal/extract.mjs +71 -0
- package/bin/journal/index.d.mts +1 -0
- package/bin/journal/index.mjs +1 -0
- package/bin/journal/markdown-line.d.mts +2 -0
- package/bin/journal/markdown-line.mjs +4 -2
- package/bin/journal/reconcile.d.mts +1 -0
- package/bin/journal/reconcile.mjs +11 -8
- package/bin/mcp/server.mjs +15 -4
- package/bin/pr-reference.d.mts +7 -0
- package/bin/pr-reference.mjs +36 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +2 -2
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Full reference: [docs/README.md](docs/README.md). Feature matrix: [docs/features
|
|
|
25
25
|
|
|
26
26
|
`pr-shepherd` moves deterministic PR orchestration into a local MCP server, with a CLI for shells and CI. Both interfaces fetch the same GitHub state, emit raw-enough context, and return a numbered plan for the calling agent to follow.
|
|
27
27
|
|
|
28
|
-
The MCP server exposes three tools: `iterate`, `apply`, and `build_suggestion_patch`. `apply` accepts ordered review mutations, file-view mutations, and journal entries. The shipped skills are thin dispatchers for those tools.
|
|
28
|
+
The MCP server exposes three tools: `iterate`, `apply`, and `build_suggestion_patch`. `apply` accepts ordered review mutations, file-view mutations, and journal entries. Direct MCP calls require a repository-qualified `pr`: a GitHub PR URL or `owner/repo#N`, matching the repository where the server started. The CLI and programmatic API also retain bare-number and current-branch PR discovery. The shipped skills are thin dispatchers for those tools.
|
|
29
29
|
|
|
30
30
|
Each tick returns exactly one action:
|
|
31
31
|
|
|
@@ -115,7 +115,7 @@ Grok:
|
|
|
115
115
|
/pr-shepherd 42
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
MCP clients call `iterate` once per tick, then use `apply` for review/file/journal mutations and `build_suggestion_patch` for an anchored suggestion. `iterate` returns the same structured action data as the CLI, including its review mutation arguments. The client owns recurrence, so this works consistently in Codex, Claude Code, Grok, and any other stdio MCP client.
|
|
118
|
+
MCP clients call `iterate` once per tick, then use `apply` for review/file/journal mutations and `build_suggestion_patch` for an anchored suggestion. Every direct MCP call supplies the same repository-qualified PR reference. `iterate` returns the same structured action data as the CLI, including its review mutation arguments. The client owns recurrence, so this works consistently in Codex, Claude Code, Grok, and any other stdio MCP client.
|
|
119
119
|
|
|
120
120
|
The CLI remains useful for shell workflows. Its canonical polling form is:
|
|
121
121
|
|
|
@@ -133,6 +133,24 @@ pr-shepherd iterate 42 # single tick
|
|
|
133
133
|
|
|
134
134
|
Use `apply` with ordered operations to reply/resolve/minimize/dismiss review items, mark changed files viewed, or append an idempotent Shepherd Journal item. Use `build_suggestion_patch` to turn one review suggestion into a validated patch and commit metadata; it never changes the worktree or git history.
|
|
135
135
|
|
|
136
|
+
### Extract Shepherd Journal Entries
|
|
137
|
+
|
|
138
|
+
The pure `pr-shepherd/journal` entry point can extract one validated journal without GitHub access:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { extractShepherdJournal } from "pr-shepherd/journal";
|
|
142
|
+
|
|
143
|
+
const result = extractShepherdJournal(prBody);
|
|
144
|
+
if (!result.ok) throw new Error(result.error);
|
|
145
|
+
|
|
146
|
+
for (const entry of result.journal?.entries ?? []) console.log(entry);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The result identifies canonical `details` versus historical `legacy` H2 journals and returns each
|
|
150
|
+
complete Markdown list item with LF line endings. It fails closed for malformed or ambiguous
|
|
151
|
+
containers and ignores journal-shaped examples hidden in Markdown constructs. The full journal API,
|
|
152
|
+
including append and reconciliation helpers, is documented in [docs/api.md](docs/api.md).
|
|
153
|
+
|
|
136
154
|
### Clean Local State
|
|
137
155
|
|
|
138
156
|
`pr-shepherd` stores seen markers, fix-attempt counters, stall fingerprints, ready-delay markers, and logs under `$PR_SHEPHERD_STATE_DIR` (default `$TMPDIR/pr-shepherd-state`).
|
package/bin/api.d.mts
CHANGED
|
@@ -6,7 +6,7 @@ export interface CreatePrShepherdOptions {
|
|
|
6
6
|
/** Working directory used for git, config, and classification-rule lookups. */
|
|
7
7
|
cwd?: string;
|
|
8
8
|
}
|
|
9
|
-
/** A positive PR number
|
|
9
|
+
/** A positive PR number, GitHub pull-request URL, or owner/repo#number reference. */
|
|
10
10
|
export type PrReference = number | string;
|
|
11
11
|
export type IterateInput = Omit<IterateCommandOptions, "format" | "prNumber"> & {
|
|
12
12
|
pr?: PrReference;
|
package/bin/api.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { runMarkFilesAsViewed, } from "./commands/mark-files-as-viewed.mjs";
|
|
|
8
8
|
import { runResolveMutate } from "./commands/resolve-mutate.mjs";
|
|
9
9
|
import { runWithExecutionCwd } from "./execution-context.mjs";
|
|
10
10
|
import { getRepoInfo } from "./github/client.mjs";
|
|
11
|
+
import { parsePrReference } from "./pr-reference.mjs";
|
|
11
12
|
/** Raised before any API mutation when an input cannot be validated. */
|
|
12
13
|
export class PrShepherdValidationError extends Error {
|
|
13
14
|
constructor(message) {
|
|
@@ -108,7 +109,7 @@ function validateApplyInput(input) {
|
|
|
108
109
|
}
|
|
109
110
|
for (const operation of input.operations)
|
|
110
111
|
validateOperation(operation);
|
|
111
|
-
|
|
112
|
+
validatePrReference(input.pr);
|
|
112
113
|
}
|
|
113
114
|
function validateOperation(operation) {
|
|
114
115
|
if (!operation || typeof operation !== "object") {
|
|
@@ -192,38 +193,21 @@ function validateSuggestionPatchInput(input) {
|
|
|
192
193
|
if (input.description !== undefined && typeof input.description !== "string") {
|
|
193
194
|
throw new PrShepherdValidationError("buildSuggestionPatch.description must be a string");
|
|
194
195
|
}
|
|
195
|
-
|
|
196
|
+
validatePrReference(input.pr);
|
|
196
197
|
}
|
|
197
|
-
function
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (typeof pr === "string") {
|
|
203
|
-
try {
|
|
204
|
-
const url = new URL(pr);
|
|
205
|
-
const parts = url.pathname.split("/").filter(Boolean);
|
|
206
|
-
if ((url.protocol === "https:" || url.protocol === "http:") &&
|
|
207
|
-
(url.hostname === "github.com" || url.hostname === "www.github.com") &&
|
|
208
|
-
parts.length === 4 &&
|
|
209
|
-
parts[2] === "pull" &&
|
|
210
|
-
/^[1-9][0-9]*$/.test(parts[3])) {
|
|
211
|
-
return { number: Number(parts[3]), repository: `${parts[0]}/${parts[1]}` };
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
// Construct the uniform public validation error below.
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
throw new PrShepherdValidationError("pr must be a positive number or a GitHub pull-request URL");
|
|
198
|
+
function validatePrReference(pr) {
|
|
199
|
+
const parsed = parsePrReference(pr);
|
|
200
|
+
if (parsed !== null)
|
|
201
|
+
return parsed;
|
|
202
|
+
throw new PrShepherdValidationError("pr must be a positive number, a GitHub pull-request URL, or owner/repo#number");
|
|
219
203
|
}
|
|
220
204
|
async function resolvePrReference(pr) {
|
|
221
|
-
const parsed =
|
|
205
|
+
const parsed = validatePrReference(pr);
|
|
222
206
|
if (parsed.repository !== undefined) {
|
|
223
207
|
const repo = await getRepoInfo();
|
|
224
208
|
const currentRepository = `${repo.owner}/${repo.name}`;
|
|
225
209
|
if (parsed.repository.toLowerCase() !== currentRepository.toLowerCase()) {
|
|
226
|
-
throw new PrShepherdValidationError(`PR
|
|
210
|
+
throw new PrShepherdValidationError(`PR reference repository ${parsed.repository} does not match the configured repository ${currentRepository}`);
|
|
227
211
|
}
|
|
228
212
|
}
|
|
229
213
|
return parsed.number;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Result of extracting the single visible structural Shepherd Journal from Markdown. */
|
|
2
|
+
export type ShepherdJournalExtraction = {
|
|
3
|
+
journal: null;
|
|
4
|
+
ok: true;
|
|
5
|
+
} | {
|
|
6
|
+
journal: {
|
|
7
|
+
entries: string[];
|
|
8
|
+
format: "details" | "legacy";
|
|
9
|
+
};
|
|
10
|
+
ok: true;
|
|
11
|
+
} | {
|
|
12
|
+
error: string;
|
|
13
|
+
ok: false;
|
|
14
|
+
};
|
|
15
|
+
/** Extract ordered journal entries while failing closed on malformed or ambiguous content. */
|
|
16
|
+
export declare function extractShepherdJournal(body: string): ShepherdJournalExtraction;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { parseShepherdJournalEntries, scanShepherdJournal } from "./reconcile.mjs";
|
|
2
|
+
import { isMarkdownBlockStart, scanMarkdownLines } from "./markdown-line.mjs";
|
|
3
|
+
import { stripMarkdownContainer } from "./markdown-container.mjs";
|
|
4
|
+
import { isIndentedCode } from "./markdown-structure.mjs";
|
|
5
|
+
function hasUnrecognizedJournalContent(lines) {
|
|
6
|
+
const syntax = scanMarkdownLines(lines);
|
|
7
|
+
let foundEntry = false;
|
|
8
|
+
let lazyContinuation = false;
|
|
9
|
+
let nestedBlock = false;
|
|
10
|
+
for (const [index, line] of lines.entries()) {
|
|
11
|
+
const scanned = syntax[index];
|
|
12
|
+
if (scanned.visiblePrefix.startsWith("- ") && /^- \S/.test(line)) {
|
|
13
|
+
foundEntry = true;
|
|
14
|
+
lazyContinuation = true;
|
|
15
|
+
nestedBlock = false;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (line.trim() === "") {
|
|
19
|
+
if (foundEntry)
|
|
20
|
+
lazyContinuation = false;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!foundEntry)
|
|
24
|
+
return true;
|
|
25
|
+
const content = stripMarkdownContainer(line, [{ kind: "list", width: 2 }]);
|
|
26
|
+
if (content === null) {
|
|
27
|
+
if (!lazyContinuation || isMarkdownBlockStart(line))
|
|
28
|
+
return true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (scanned.ignored || isMarkdownBlockStart(content) || isIndentedCode(content)) {
|
|
32
|
+
lazyContinuation = false;
|
|
33
|
+
nestedBlock = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (nestedBlock && /^[ \t]/.test(content)) {
|
|
37
|
+
lazyContinuation = false;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
lazyContinuation = true;
|
|
41
|
+
nestedBlock = false;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
/** Extract ordered journal entries while failing closed on malformed or ambiguous content. */
|
|
46
|
+
export function extractShepherdJournal(body) {
|
|
47
|
+
const lines = body.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
|
|
48
|
+
const journal = scanShepherdJournal(lines);
|
|
49
|
+
if (journal === "error") {
|
|
50
|
+
return {
|
|
51
|
+
error: "malformed, duplicate, or ambiguous Shepherd Journal container",
|
|
52
|
+
ok: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!journal)
|
|
56
|
+
return { journal: null, ok: true };
|
|
57
|
+
const content = lines.slice(journal.contentStart, journal.contentEnd);
|
|
58
|
+
if (hasUnrecognizedJournalContent(content)) {
|
|
59
|
+
return {
|
|
60
|
+
error: "Shepherd Journal content uses an unrecognized entry format",
|
|
61
|
+
ok: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
journal: {
|
|
66
|
+
entries: parseShepherdJournalEntries(content).map((entry) => entry.join("\n")),
|
|
67
|
+
format: journal.format,
|
|
68
|
+
},
|
|
69
|
+
ok: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/bin/journal/index.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
/** GitHub-free Shepherd Journal helpers for programmatic PR-body reconciliation. */
|
|
2
2
|
export { appendJournalItem, validateJournalItem, type AppendResult } from "./append.mts";
|
|
3
|
+
export { extractShepherdJournal, type ShepherdJournalExtraction } from "./extract.mts";
|
|
3
4
|
export { reconcileShepherdJournal, type ShepherdJournalReconcileResult } from "./reconcile.mts";
|
package/bin/journal/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
/** GitHub-free Shepherd Journal helpers for programmatic PR-body reconciliation. */
|
|
2
2
|
export { appendJournalItem, validateJournalItem } from "./append.mjs";
|
|
3
|
+
export { extractShepherdJournal } from "./extract.mjs";
|
|
3
4
|
export { reconcileShepherdJournal } from "./reconcile.mjs";
|
|
@@ -3,6 +3,8 @@ type MarkdownLine = {
|
|
|
3
3
|
nested: boolean;
|
|
4
4
|
visiblePrefix: string;
|
|
5
5
|
};
|
|
6
|
+
/** Return whether a Markdown line starts a block that interrupts inline content. */
|
|
7
|
+
export declare function isMarkdownBlockStart(line: string): boolean;
|
|
6
8
|
export declare const scanMarkdownLines: (lines: string[]) => MarkdownLine[];
|
|
7
9
|
export declare const isSafeMarkdownInsertionPoint: (lines: string[]) => boolean;
|
|
8
10
|
export {};
|
|
@@ -2,18 +2,20 @@ import { fenceStart, resolveMarkdownContainer, stripMarkdownContainer, } from ".
|
|
|
2
2
|
import { inQuotedHtmlAttribute, rawHtmlEnd, rawHtmlStart, } from "./markdown-html.mjs";
|
|
3
3
|
import { backtickRuns, nextBacktickRun, nextCodeOpener } from "./markdown-backticks.mjs";
|
|
4
4
|
import { isIndentedCode, structuralDetailsStart } from "./markdown-structure.mjs";
|
|
5
|
-
|
|
5
|
+
/** Return whether a Markdown line starts a block that interrupts inline content. */
|
|
6
|
+
export function isMarkdownBlockStart(line) {
|
|
6
7
|
return (line.trim() === "" ||
|
|
7
8
|
/^ {0,3}(?:#{1,6}(?:[ \t]+|$)|>)/.test(line) ||
|
|
8
9
|
/^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/.test(line) ||
|
|
9
10
|
/^ {0,3}(?:(?:\*[ \t]*){3,}|-+[ \t]*|(?:_[ \t]*){3,}|=+[ \t]*)$/.test(line) ||
|
|
11
|
+
/^ {0,3}<!--/.test(line) ||
|
|
10
12
|
fenceStart(line) !== null ||
|
|
11
13
|
rawHtmlStart(line) !== null ||
|
|
12
14
|
structuralDetailsStart(line) !== null);
|
|
13
15
|
}
|
|
14
16
|
function hasCloser(lines, lineIndex, offset, length) {
|
|
15
17
|
for (let i = lineIndex; i < lines.length; i++) {
|
|
16
|
-
if (i > lineIndex &&
|
|
18
|
+
if (i > lineIndex && isMarkdownBlockStart(lines[i]))
|
|
17
19
|
return false;
|
|
18
20
|
if (backtickRuns(lines[i]).some((run) => run.length === length && (i > lineIndex || run.index > offset)))
|
|
19
21
|
return true;
|
|
@@ -13,5 +13,6 @@ export type ShepherdJournalBounds = {
|
|
|
13
13
|
start: number;
|
|
14
14
|
};
|
|
15
15
|
export declare function scanShepherdJournal(lines: string[]): ShepherdJournalBounds | null | "error";
|
|
16
|
+
export declare function parseShepherdJournalEntries(lines: string[]): string[][];
|
|
16
17
|
export declare function containsJournalEntry(lines: string[], item: string): boolean;
|
|
17
18
|
export declare function reconcileShepherdJournal(suppliedBody: string, liveBody: string): ShepherdJournalReconcileResult;
|
|
@@ -61,7 +61,10 @@ export function scanShepherdJournal(lines) {
|
|
|
61
61
|
return "error";
|
|
62
62
|
}
|
|
63
63
|
if (JOURNAL_SUMMARY.test(visible.trimStart())) {
|
|
64
|
-
if (
|
|
64
|
+
if (detailsDepth !== 1 ||
|
|
65
|
+
lines[i] !== SUMMARY ||
|
|
66
|
+
lines[i - 1] !== OPEN ||
|
|
67
|
+
lines[i + 1] !== "")
|
|
65
68
|
return "error";
|
|
66
69
|
const end = close(lines, syntax, i + 2);
|
|
67
70
|
if (end === null)
|
|
@@ -78,7 +81,7 @@ export function scanShepherdJournal(lines) {
|
|
|
78
81
|
continue;
|
|
79
82
|
}
|
|
80
83
|
if (LEGACY.test(visible)) {
|
|
81
|
-
if (legacy)
|
|
84
|
+
if (detailsDepth !== 0 || legacy)
|
|
82
85
|
return "error";
|
|
83
86
|
legacy = {
|
|
84
87
|
contentEnd: lines.length,
|
|
@@ -108,7 +111,7 @@ function trim(lines) {
|
|
|
108
111
|
b--;
|
|
109
112
|
return lines.slice(a, b);
|
|
110
113
|
}
|
|
111
|
-
function
|
|
114
|
+
export function parseShepherdJournalEntries(lines) {
|
|
112
115
|
const result = [];
|
|
113
116
|
const syntax = scanMarkdownLines(lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)));
|
|
114
117
|
let current = null;
|
|
@@ -125,7 +128,7 @@ function entries(lines) {
|
|
|
125
128
|
result.push(trim(current));
|
|
126
129
|
return result;
|
|
127
130
|
}
|
|
128
|
-
function
|
|
131
|
+
function hasUnrecognizedLeadingJournalContent(lines) {
|
|
129
132
|
const syntax = scanMarkdownLines(lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)));
|
|
130
133
|
const firstEntry = syntax.findIndex((line) => line.visiblePrefix.startsWith("- "));
|
|
131
134
|
if (firstEntry === -1)
|
|
@@ -137,7 +140,7 @@ function contains(a, b) {
|
|
|
137
140
|
}
|
|
138
141
|
export function containsJournalEntry(lines, item) {
|
|
139
142
|
const target = item.split("\n");
|
|
140
|
-
return
|
|
143
|
+
return parseShepherdJournalEntries(lines).some((entry) => contains(target, entry));
|
|
141
144
|
}
|
|
142
145
|
function fail(reason) {
|
|
143
146
|
return {
|
|
@@ -169,11 +172,11 @@ export function reconcileShepherdJournal(suppliedBody, liveBody) {
|
|
|
169
172
|
ok: true,
|
|
170
173
|
};
|
|
171
174
|
}
|
|
172
|
-
const liveEntries = entries(liveLines.slice(live.contentStart, live.contentEnd));
|
|
173
175
|
const liveJournalLines = liveLines.slice(live.contentStart, live.contentEnd);
|
|
174
|
-
|
|
176
|
+
const liveEntries = parseShepherdJournalEntries(liveJournalLines);
|
|
177
|
+
if (!liveEntries.length || hasUnrecognizedLeadingJournalContent(liveJournalLines))
|
|
175
178
|
return fail("live Shepherd Journal content uses an unrecognized entry format");
|
|
176
|
-
const target =
|
|
179
|
+
const target = parseShepherdJournalEntries(suppliedLines.slice(supplied.contentStart, supplied.contentEnd));
|
|
177
180
|
for (const entry of liveEntries) {
|
|
178
181
|
const match = target.findIndex((candidate) => contains(entry, candidate));
|
|
179
182
|
if (match === -1)
|
package/bin/mcp/server.mjs
CHANGED
|
@@ -3,10 +3,15 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from "../api.mjs";
|
|
6
|
+
import { isRepositoryQualifiedPrReference } from "../pr-reference.mjs";
|
|
6
7
|
import { formatJournalResult } from "../cli/journal-formatter.mjs";
|
|
7
8
|
import { formatCommitSuggestionResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, } from "../cli/formatters.mjs";
|
|
8
9
|
import { errorToExitCode, EXIT } from "../exit-codes.mjs";
|
|
9
|
-
const
|
|
10
|
+
const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
|
|
11
|
+
const pr = z
|
|
12
|
+
.string()
|
|
13
|
+
.refine(isRepositoryQualifiedPrReference, { message: QUALIFIED_PR_ERROR })
|
|
14
|
+
.describe("GitHub pull-request URL or owner/repo#number");
|
|
10
15
|
const ids = z.array(z.string().min(1)).optional();
|
|
11
16
|
const iterateInputSchema = z.object({
|
|
12
17
|
pr,
|
|
@@ -65,7 +70,7 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
65
70
|
idempotentHint: false,
|
|
66
71
|
openWorldHint: true,
|
|
67
72
|
},
|
|
68
|
-
}, async (input) => runTool(() => shepherd.iterate(input), formatIterateResult));
|
|
73
|
+
}, async (input) => runTool(() => shepherd.iterate(requireRepositoryQualifiedPr(input)), formatIterateResult));
|
|
69
74
|
server.registerTool("apply", {
|
|
70
75
|
description: "Apply ordered review, file-view, and journal operations after prevalidation.",
|
|
71
76
|
inputSchema: applyInputSchema,
|
|
@@ -75,7 +80,7 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
75
80
|
idempotentHint: false,
|
|
76
81
|
openWorldHint: true,
|
|
77
82
|
},
|
|
78
|
-
}, async (input) => runTool(() => shepherd.apply(input), formatApplyResult));
|
|
83
|
+
}, async (input) => runTool(() => shepherd.apply(requireRepositoryQualifiedPr(input)), formatApplyResult));
|
|
79
84
|
server.registerTool("build_suggestion_patch", {
|
|
80
85
|
description: "Build, but never apply, a patch from an eligible review suggestion.",
|
|
81
86
|
inputSchema: suggestionPatchInputSchema,
|
|
@@ -85,9 +90,15 @@ export function createPrShepherdMcpServer(options = {}) {
|
|
|
85
90
|
idempotentHint: true,
|
|
86
91
|
openWorldHint: true,
|
|
87
92
|
},
|
|
88
|
-
}, async (input) => runTool(() => shepherd.buildSuggestionPatch(input), formatCommitSuggestionResult));
|
|
93
|
+
}, async (input) => runTool(() => shepherd.buildSuggestionPatch(requireRepositoryQualifiedPr(input)), formatCommitSuggestionResult));
|
|
89
94
|
return server;
|
|
90
95
|
}
|
|
96
|
+
function requireRepositoryQualifiedPr(input) {
|
|
97
|
+
if (!isRepositoryQualifiedPrReference(input.pr)) {
|
|
98
|
+
throw new PrShepherdValidationError(QUALIFIED_PR_ERROR);
|
|
99
|
+
}
|
|
100
|
+
return input;
|
|
101
|
+
}
|
|
91
102
|
function readPackageVersion() {
|
|
92
103
|
const packageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
93
104
|
return packageJson.version;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ParsedPrReference {
|
|
2
|
+
number?: number;
|
|
3
|
+
repository?: string;
|
|
4
|
+
}
|
|
5
|
+
/** Parses API PR references without performing repository or GitHub I/O. */
|
|
6
|
+
export declare function parsePrReference(pr: number | string | undefined): ParsedPrReference | null;
|
|
7
|
+
export declare function isRepositoryQualifiedPrReference(pr: unknown): pr is string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Parses API PR references without performing repository or GitHub I/O. */
|
|
2
|
+
export function parsePrReference(pr) {
|
|
3
|
+
if (pr === undefined)
|
|
4
|
+
return {};
|
|
5
|
+
if (typeof pr === "number" && Number.isInteger(pr) && pr > 0)
|
|
6
|
+
return { number: pr };
|
|
7
|
+
if (typeof pr !== "string")
|
|
8
|
+
return null;
|
|
9
|
+
const shorthand = /^([^/#\s]+)\/([^/#\s]+)#([1-9][0-9]*)$/.exec(pr);
|
|
10
|
+
if (shorthand) {
|
|
11
|
+
return {
|
|
12
|
+
number: Number(shorthand[3]),
|
|
13
|
+
repository: `${shorthand[1]}/${shorthand[2]}`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(pr);
|
|
18
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
19
|
+
if ((url.protocol === "https:" || url.protocol === "http:") &&
|
|
20
|
+
(url.hostname === "github.com" || url.hostname === "www.github.com") &&
|
|
21
|
+
parts.length === 4 &&
|
|
22
|
+
parts[2] === "pull" &&
|
|
23
|
+
/^[1-9][0-9]*$/.test(parts[3])) {
|
|
24
|
+
return { number: Number(parts[3]), repository: `${parts[0]}/${parts[1]}` };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Return the uniform invalid result below.
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function isRepositoryQualifiedPrReference(pr) {
|
|
33
|
+
if (typeof pr !== "string")
|
|
34
|
+
return false;
|
|
35
|
+
return parsePrReference(pr)?.repository !== undefined;
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -12,6 +12,6 @@ Thin dispatcher for marking PR files viewed. Use the MCP server when it is avail
|
|
|
12
12
|
|
|
13
13
|
## Arguments: $ARGUMENTS
|
|
14
14
|
|
|
15
|
-
1. Parse an optional PR number or GitHub PR URL. Treat standalone `tests` as `--tests`; preserve explicit paths and `--match <regex>` selectors.
|
|
15
|
+
1. Parse an optional PR number, repository-qualified `owner/repo#N`, or GitHub PR URL. Treat standalone `tests` as `--tests`; preserve explicit paths and `--match <regex>` selectors.
|
|
16
16
|
|
|
17
|
-
2. If the `apply` MCP tool is available, call
|
|
17
|
+
2. If the `apply` MCP tool is available, first obtain a repository-qualified reference: use a supplied GitHub PR URL or `owner/repo#N` unchanged; for a bare number, run `gh pr view <number> --json url --jq .url`; when omitted, run `gh pr view --json url --jq .url`. If that does not produce one qualified PR reference, stop and report that MCP cannot safely determine the PR. Otherwise call `apply` with that qualified reference and one `mark_files_viewed` operation, then print the full result. If MCP is unavailable and the parsed PR is repository-qualified, run `gh repo view --json nameWithOwner --jq .nameWithOwner` and verify that repository matches the reference case-insensitively. Stop on a mismatch or failed lookup; the CLI does not validate the URL repository. Convert a verified `owner/repo#N` to `https://github.com/owner/repo/pull/N`, then run `pr-shepherd apply files` with the parsed PR and selectors and print the full result.
|
|
@@ -12,11 +12,11 @@ Thin dispatcher for iterating a PR. Poll with the CLI; use MCP `iterate` only wh
|
|
|
12
12
|
|
|
13
13
|
## Arguments: $ARGUMENTS
|
|
14
14
|
|
|
15
|
-
1. Parse an optional PR number or GitHub PR URL from `$ARGUMENTS`; otherwise let pr-shepherd infer the current branch PR.
|
|
15
|
+
1. Parse an optional PR number, repository-qualified `owner/repo#N`, or GitHub PR URL from `$ARGUMENTS`; otherwise let pr-shepherd infer the current branch PR.
|
|
16
16
|
|
|
17
|
-
2.
|
|
17
|
+
2. Before passing a supplied GitHub PR URL or `owner/repo#N` to the CLI, run `gh repo view --json nameWithOwner --jq .nameWithOwner` and verify that repository matches the reference case-insensitively. Stop on a mismatch or failed lookup; the CLI does not validate the URL repository. Convert a verified `owner/repo#N` to `https://github.com/owner/repo/pull/N`, then run the poll command `pr-shepherd` with the optional PR argument and print its full result. Do not run `pr-shepherd iterate`. If the CLI is unavailable and the `iterate` MCP tool is available, first obtain a repository-qualified reference: use a supplied GitHub PR URL or `owner/repo#N` unchanged; for a bare number, run `gh pr view <number> --json url --jq .url`; when omitted, run `gh pr view --json url --jq .url`. If that does not produce one qualified PR reference, stop and report that MCP cannot safely determine the PR. Otherwise call `iterate` with that qualified reference and print its full result.
|
|
18
18
|
|
|
19
|
-
3. Print the full result and follow every returned `## Instructions` step exactly. For CLI output, run each printed mutation command when instructed. For MCP output, use MCP `apply` and `build_suggestion_patch
|
|
19
|
+
3. Print the full result and follow every returned `## Instructions` step exactly. For CLI output, run each printed mutation command when instructed. For MCP output, use MCP `apply` and `build_suggestion_patch` with the same qualified PR reference; do not run a shell `pr-shepherd apply` command.
|
|
20
20
|
|
|
21
21
|
4. After completing the returned instructions, repeat step 2 unless the action is `[CANCEL]` or `[ESCALATE]`, the instructions require a human handoff, or the human directs you to stop.
|
|
22
22
|
|