pr-shepherd 0.5.2 → 0.7.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 +85 -25
- package/bin/checks/triage.mjs +35 -20
- package/bin/cli.mjs +96 -14
- package/bin/commands/check.mjs +26 -9
- package/bin/commands/iterate.mjs +365 -38
- package/bin/commands/resolve.mjs +4 -1
- package/bin/config/load.mjs +5 -0
- package/bin/config.json +2 -5
- package/bin/github/batch.mjs +36 -8
- package/bin/github/client.mjs +39 -110
- package/bin/github/gql/batch-pr.gql +23 -2
- package/bin/github/gql/mark-pr-ready.gql +7 -0
- package/bin/github/gql/pr-number-by-branch.gql +9 -0
- package/bin/github/http.mjs +145 -0
- package/bin/github/queries.mjs +4 -0
- package/package.json +1 -1
- package/plugin/skills/monitor/SKILL.md +24 -67
- package/plugin/skills/resolve/SKILL.md +27 -12
package/bin/github/client.mjs
CHANGED
|
@@ -1,63 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* High-level GitHub client — wraps http.mts for application-level concerns.
|
|
3
|
+
*
|
|
4
|
+
* All GitHub I/O goes through native fetch (via http.mts); the `gh` CLI is no
|
|
5
|
+
* longer used for GitHub API calls, but may be invoked as an auth-token
|
|
6
|
+
* fallback via `gh auth token` when neither GH_TOKEN nor GITHUB_TOKEN is set.
|
|
3
7
|
*/
|
|
4
8
|
import { execFile as execFileCb } from "node:child_process";
|
|
5
9
|
import { promisify } from "node:util";
|
|
6
|
-
import {
|
|
10
|
+
import { graphql as httpGraphql, rest } from "./http.mjs";
|
|
11
|
+
import { PR_NUMBER_BY_BRANCH_QUERY } from "./queries.mjs";
|
|
7
12
|
const execFile = promisify(execFileCb);
|
|
8
13
|
// ---------------------------------------------------------------------------
|
|
9
|
-
// GraphQL
|
|
14
|
+
// GraphQL — thin re-exports so callers don't need to import http.mts directly
|
|
10
15
|
// ---------------------------------------------------------------------------
|
|
16
|
+
export { graphql, graphqlWithRateLimit } from "./http.mjs";
|
|
11
17
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* @param query The full GraphQL query/mutation string.
|
|
15
|
-
* @param vars Key-value pairs forwarded as `-f key=value` or `-F key=value`.
|
|
16
|
-
* Numeric values are passed with `-F`; everything else with `-f`.
|
|
18
|
+
* Returns the current repo's owner and name by parsing `git remote get-url origin`.
|
|
19
|
+
* Handles https://, git@, and ssh:// remote URL formats.
|
|
17
20
|
*/
|
|
18
|
-
export async function graphql(query, vars = {}) {
|
|
19
|
-
const args = buildGraphqlArgs(query, vars);
|
|
20
|
-
const raw = await runGh(args);
|
|
21
|
-
const parsed = JSON.parse(raw);
|
|
22
|
-
if (parsed.errors?.length) {
|
|
23
|
-
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
24
|
-
throw new Error(`GitHub GraphQL error: ${messages}`);
|
|
25
|
-
}
|
|
26
|
-
return { data: parsed.data };
|
|
27
|
-
}
|
|
28
|
-
/** Like {@link graphql} but also returns the `x-ratelimit-remaining` header. */
|
|
29
|
-
export async function graphqlWithRateLimit(query, vars = {}) {
|
|
30
|
-
// --include must come after 'api' (it's a flag for `gh api`, not for `gh`).
|
|
31
|
-
const [api, ...extraArgs] = buildGraphqlArgs(query, vars);
|
|
32
|
-
const args = [api, "--include", ...extraArgs];
|
|
33
|
-
const raw = await runGh(args);
|
|
34
|
-
// `gh api -i` prepends HTTP headers before the JSON body.
|
|
35
|
-
// Handle both CRLF (\r\n\r\n) and LF-only (\n\n) header separators.
|
|
36
|
-
const crlfEnd = raw.indexOf("\r\n\r\n");
|
|
37
|
-
const lfEnd = raw.indexOf("\n\n");
|
|
38
|
-
const headerEnd = crlfEnd >= 0 ? crlfEnd : lfEnd;
|
|
39
|
-
const headerSection = headerEnd >= 0 ? raw.slice(0, headerEnd) : "";
|
|
40
|
-
const body = headerEnd >= 0 ? raw.slice(headerEnd + (crlfEnd >= 0 ? 4 : 2)) : raw;
|
|
41
|
-
const remaining = parseHeaderNumber(headerSection, "x-ratelimit-remaining");
|
|
42
|
-
const limit = parseHeaderNumber(headerSection, "x-ratelimit-limit");
|
|
43
|
-
const resetAt = parseHeaderNumber(headerSection, "x-ratelimit-reset");
|
|
44
|
-
const parsed = JSON.parse(body);
|
|
45
|
-
if (parsed.errors?.length) {
|
|
46
|
-
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
47
|
-
throw new Error(`GitHub GraphQL error: ${messages}`);
|
|
48
|
-
}
|
|
49
|
-
return {
|
|
50
|
-
data: parsed.data,
|
|
51
|
-
rateLimit: remaining !== null && limit !== null && resetAt !== null
|
|
52
|
-
? { remaining, limit, resetAt }
|
|
53
|
-
: undefined,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
/** Returns the current repo's owner and name from `gh repo view`. */
|
|
57
21
|
export async function getRepoInfo() {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
return
|
|
22
|
+
const { stdout } = await execFile("git", ["remote", "get-url", "origin"]);
|
|
23
|
+
const url = stdout.trim();
|
|
24
|
+
return parseRemoteUrl(url);
|
|
61
25
|
}
|
|
62
26
|
/**
|
|
63
27
|
* Derives the PR number for the current HEAD branch.
|
|
@@ -66,87 +30,52 @@ export async function getRepoInfo() {
|
|
|
66
30
|
export async function getCurrentPrNumber() {
|
|
67
31
|
try {
|
|
68
32
|
const branch = await getCurrentBranch();
|
|
69
|
-
// In detached HEAD state git returns "HEAD" — no branch name to look up.
|
|
70
33
|
if (branch === "HEAD")
|
|
71
34
|
return null;
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"--head",
|
|
76
|
-
branch,
|
|
77
|
-
"--json",
|
|
78
|
-
"number",
|
|
79
|
-
"--jq",
|
|
80
|
-
".[0].number",
|
|
81
|
-
]);
|
|
82
|
-
const trimmed = raw.trim();
|
|
83
|
-
if (!trimmed || trimmed === "null")
|
|
84
|
-
return null;
|
|
85
|
-
return parseInt(trimmed, 10);
|
|
35
|
+
const repo = await getRepoInfo();
|
|
36
|
+
const result = await httpGraphql(PR_NUMBER_BY_BRANCH_QUERY, { owner: repo.owner, repo: repo.name, branch });
|
|
37
|
+
return result.data.repository?.pullRequests.nodes[0]?.number ?? null;
|
|
86
38
|
}
|
|
87
39
|
catch {
|
|
88
40
|
return null;
|
|
89
41
|
}
|
|
90
42
|
}
|
|
91
|
-
async function getCurrentBranch() {
|
|
92
|
-
// Use git directly for branch name — gh doesn't expose it.
|
|
93
|
-
const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
94
|
-
return stdout.trim();
|
|
95
|
-
}
|
|
96
43
|
/** Returns the `headRefOid` (commit SHA) of the given PR as reported by GitHub. */
|
|
97
44
|
export async function getPrHeadSha(pr, owner, name) {
|
|
98
|
-
const
|
|
99
|
-
return
|
|
45
|
+
const data = await rest("GET", `/repos/${owner}/${name}/pulls/${pr}`);
|
|
46
|
+
return data.head.sha;
|
|
100
47
|
}
|
|
101
48
|
/**
|
|
102
|
-
* Fetches `mergeable` and `mergeStateStatus` via the REST API
|
|
49
|
+
* Fetches `mergeable` and `mergeStateStatus` via the REST API.
|
|
103
50
|
*
|
|
104
51
|
* Used as a fallback when the GraphQL API returns `UNKNOWN` for these fields —
|
|
105
52
|
* a known GitHub quirk where GraphQL lags behind the REST layer.
|
|
106
53
|
*/
|
|
107
54
|
export async function getMergeableState(pr, owner, repo) {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"--repo",
|
|
113
|
-
`${owner}/${repo}`,
|
|
114
|
-
"--json",
|
|
115
|
-
"mergeable,mergeStateStatus",
|
|
116
|
-
]);
|
|
117
|
-
const parsed = JSON.parse(raw);
|
|
118
|
-
return parsed;
|
|
55
|
+
const data = await rest("GET", `/repos/${owner}/${repo}/pulls/${pr}`);
|
|
56
|
+
const mergeable = data.mergeable === true ? "MERGEABLE" : data.mergeable === false ? "CONFLICTING" : "UNKNOWN";
|
|
57
|
+
const mergeStateStatus = data.mergeable_state.toUpperCase();
|
|
58
|
+
return { mergeable, mergeStateStatus };
|
|
119
59
|
}
|
|
120
60
|
// ---------------------------------------------------------------------------
|
|
121
61
|
// Internal helpers
|
|
122
62
|
// ---------------------------------------------------------------------------
|
|
123
|
-
function
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
if (typeof v === "number" || typeof v === "boolean") {
|
|
127
|
-
args.push("-F", `${k}=${String(v)}`);
|
|
128
|
-
}
|
|
129
|
-
else {
|
|
130
|
-
args.push("-f", `${k}=${v}`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return args;
|
|
63
|
+
async function getCurrentBranch() {
|
|
64
|
+
const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
65
|
+
return stdout.trim();
|
|
134
66
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
67
|
+
function parseRemoteUrl(url) {
|
|
68
|
+
// Strip trailing .git and trailing slash
|
|
69
|
+
const stripped = url.replace(/\.git$/, "").replace(/\/$/, "");
|
|
70
|
+
// ssh: git@host:owner/repo
|
|
71
|
+
const sshMatch = /^git@[^:]+:([^/]+)\/(.+)$/.exec(stripped);
|
|
72
|
+
if (sshMatch) {
|
|
73
|
+
return { owner: sshMatch[1], name: sshMatch[2] };
|
|
141
74
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
75
|
+
// https or ssh://: https://host/owner/repo or ssh://git@host/owner/repo
|
|
76
|
+
const httpsMatch = /^(?:https?|ssh):\/\/[^/]+\/([^/]+)\/(.+)$/.exec(stripped);
|
|
77
|
+
if (httpsMatch) {
|
|
78
|
+
return { owner: httpsMatch[1], name: httpsMatch[2] };
|
|
146
79
|
}
|
|
147
|
-
}
|
|
148
|
-
function parseHeaderNumber(headers, name) {
|
|
149
|
-
const re = new RegExp(`^${name}:\\s*(\\d+)`, "im");
|
|
150
|
-
const m = re.exec(headers);
|
|
151
|
-
return m ? parseInt(m[1], 10) : null;
|
|
80
|
+
throw new Error(`Cannot parse GitHub remote URL: ${url}`);
|
|
152
81
|
}
|
|
@@ -5,10 +5,12 @@ query BatchPr(
|
|
|
5
5
|
$threadsCursor: String
|
|
6
6
|
$checksCursor: String
|
|
7
7
|
$commentsCursor: String
|
|
8
|
-
$
|
|
8
|
+
$changesRequestedCursor: String
|
|
9
|
+
$reviewSummariesCursor: String
|
|
9
10
|
) {
|
|
10
11
|
repository(owner: $owner, name: $repo) {
|
|
11
12
|
pullRequest(number: $pr) {
|
|
13
|
+
id
|
|
12
14
|
number
|
|
13
15
|
state
|
|
14
16
|
isDraft
|
|
@@ -16,6 +18,7 @@ query BatchPr(
|
|
|
16
18
|
mergeStateStatus
|
|
17
19
|
reviewDecision
|
|
18
20
|
headRefOid
|
|
21
|
+
baseRefName
|
|
19
22
|
# Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
|
|
20
23
|
reviewRequests(last: 50) {
|
|
21
24
|
nodes {
|
|
@@ -80,7 +83,11 @@ query BatchPr(
|
|
|
80
83
|
createdAt
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
|
-
reviews(
|
|
86
|
+
changesRequestedReviews: reviews(
|
|
87
|
+
states: CHANGES_REQUESTED
|
|
88
|
+
last: 50
|
|
89
|
+
before: $changesRequestedCursor
|
|
90
|
+
) {
|
|
84
91
|
pageInfo {
|
|
85
92
|
hasPreviousPage
|
|
86
93
|
startCursor
|
|
@@ -93,6 +100,20 @@ query BatchPr(
|
|
|
93
100
|
body
|
|
94
101
|
}
|
|
95
102
|
}
|
|
103
|
+
reviewSummaries: reviews(states: COMMENTED, last: 50, before: $reviewSummariesCursor) {
|
|
104
|
+
pageInfo {
|
|
105
|
+
hasPreviousPage
|
|
106
|
+
startCursor
|
|
107
|
+
}
|
|
108
|
+
nodes {
|
|
109
|
+
id
|
|
110
|
+
isMinimized
|
|
111
|
+
author {
|
|
112
|
+
login
|
|
113
|
+
}
|
|
114
|
+
body
|
|
115
|
+
}
|
|
116
|
+
}
|
|
96
117
|
commits(last: 1) {
|
|
97
118
|
nodes {
|
|
98
119
|
commit {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin native-fetch HTTP client for the GitHub API.
|
|
3
|
+
* Replaces the previous `gh` CLI shell-out on every code path.
|
|
4
|
+
*
|
|
5
|
+
* Token resolution order:
|
|
6
|
+
* 1. GH_TOKEN env
|
|
7
|
+
* 2. GITHUB_TOKEN env
|
|
8
|
+
* 3. `gh auth token` (fallback for users who have run `gh auth login`)
|
|
9
|
+
*/
|
|
10
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
const execFile = promisify(execFileCb);
|
|
13
|
+
const BASE_URL = "https://api.github.com";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Auth
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
let _token;
|
|
18
|
+
export function _resetTokenCache() {
|
|
19
|
+
_token = undefined;
|
|
20
|
+
}
|
|
21
|
+
async function resolveToken() {
|
|
22
|
+
if (_token)
|
|
23
|
+
return _token;
|
|
24
|
+
if (process.env["GH_TOKEN"]) {
|
|
25
|
+
_token = process.env["GH_TOKEN"];
|
|
26
|
+
return _token;
|
|
27
|
+
}
|
|
28
|
+
if (process.env["GITHUB_TOKEN"]) {
|
|
29
|
+
_token = process.env["GITHUB_TOKEN"];
|
|
30
|
+
return _token;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const { stdout } = await execFile("gh", ["auth", "token"]);
|
|
34
|
+
const token = stdout.trim();
|
|
35
|
+
if (token) {
|
|
36
|
+
_token = token;
|
|
37
|
+
return _token;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// fall through to error
|
|
42
|
+
}
|
|
43
|
+
throw new Error("No GitHub token found. Set GH_TOKEN or GITHUB_TOKEN, or run `gh auth login`.");
|
|
44
|
+
}
|
|
45
|
+
async function makeHeaders() {
|
|
46
|
+
return {
|
|
47
|
+
Authorization: `Bearer ${await resolveToken()}`,
|
|
48
|
+
Accept: "application/vnd.github+json",
|
|
49
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
50
|
+
"User-Agent": "pr-shepherd",
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// GraphQL
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
async function graphqlInner(query, vars) {
|
|
58
|
+
const res = await fetch(`${BASE_URL}/graphql`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: await makeHeaders(),
|
|
61
|
+
body: JSON.stringify({ query, variables: vars }),
|
|
62
|
+
});
|
|
63
|
+
const rateLimit = parseRateLimit(res.headers);
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const body = await res.text();
|
|
66
|
+
throw new Error(`GitHub GraphQL request failed: ${res.status} ${body.slice(0, 300)}`);
|
|
67
|
+
}
|
|
68
|
+
const parsed = (await res.json());
|
|
69
|
+
if (parsed.errors?.length) {
|
|
70
|
+
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
71
|
+
throw new Error(`GitHub GraphQL error: ${messages}`);
|
|
72
|
+
}
|
|
73
|
+
return { data: parsed.data, rateLimit };
|
|
74
|
+
}
|
|
75
|
+
export async function graphql(query, vars = {}) {
|
|
76
|
+
const { data } = await graphqlInner(query, vars);
|
|
77
|
+
return { data };
|
|
78
|
+
}
|
|
79
|
+
export async function graphqlWithRateLimit(query, vars = {}) {
|
|
80
|
+
const { data, rateLimit } = await graphqlInner(query, vars);
|
|
81
|
+
return { data, rateLimit: rateLimit ?? undefined };
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// REST
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
export async function rest(method, path, body) {
|
|
87
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
88
|
+
method,
|
|
89
|
+
headers: await makeHeaders(),
|
|
90
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${text.slice(0, 300)}`);
|
|
95
|
+
}
|
|
96
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
97
|
+
if (ct.includes("application/json")) {
|
|
98
|
+
return res.json();
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* GET request that returns plain text.
|
|
104
|
+
* Handles the 302 redirect pattern used by the GitHub Actions job-logs endpoint —
|
|
105
|
+
* the redirect target (a signed storage URL) is fetched without auth headers.
|
|
106
|
+
*/
|
|
107
|
+
export async function restText(path) {
|
|
108
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
109
|
+
method: "GET",
|
|
110
|
+
headers: await makeHeaders(),
|
|
111
|
+
redirect: "manual",
|
|
112
|
+
});
|
|
113
|
+
if (res.status === 301 || res.status === 302 || res.status === 307 || res.status === 308) {
|
|
114
|
+
const location = res.headers.get("location");
|
|
115
|
+
if (location) {
|
|
116
|
+
const redirectRes = await fetch(location);
|
|
117
|
+
if (!redirectRes.ok) {
|
|
118
|
+
throw new Error(`redirect target ${location} failed: ${redirectRes.status}`);
|
|
119
|
+
}
|
|
120
|
+
return redirectRes.text();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
const text = await res.text();
|
|
125
|
+
throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${text.slice(0, 300)}`);
|
|
126
|
+
}
|
|
127
|
+
return res.text();
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Helpers
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
function parseRateLimit(headers) {
|
|
133
|
+
const rRaw = headers.get("x-ratelimit-remaining");
|
|
134
|
+
const lRaw = headers.get("x-ratelimit-limit");
|
|
135
|
+
const tRaw = headers.get("x-ratelimit-reset");
|
|
136
|
+
if (rRaw === null || lRaw === null || tRaw === null)
|
|
137
|
+
return null;
|
|
138
|
+
const remaining = Number(rRaw);
|
|
139
|
+
const limit = Number(lRaw);
|
|
140
|
+
const resetAt = Number(tRaw);
|
|
141
|
+
if (Number.isFinite(remaining) && Number.isFinite(limit) && Number.isFinite(resetAt)) {
|
|
142
|
+
return { remaining, limit, resetAt };
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
package/bin/github/queries.mjs
CHANGED
|
@@ -19,3 +19,7 @@ export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
|
|
|
19
19
|
export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
|
|
20
20
|
/** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
|
|
21
21
|
export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
|
|
22
|
+
/** Look up PR number by branch name (for getCurrentPrNumber). */
|
|
23
|
+
export const PR_NUMBER_BY_BRANCH_QUERY = gql("pr-number-by-branch.gql");
|
|
24
|
+
/** Convert a draft PR to ready for review. */
|
|
25
|
+
export const MARK_PR_READY_MUTATION = gql("mark-pr-ready.gql");
|
package/package.json
CHANGED
|
@@ -9,24 +9,18 @@ allowed-tools:
|
|
|
9
9
|
|
|
10
10
|
# pr-shepherd monitor — Continuous PR Monitor
|
|
11
11
|
|
|
12
|
+
> Action reference (all 8 actions, JSON fields, examples): [docs/actions.md](../../../docs/actions.md)
|
|
13
|
+
|
|
12
14
|
## Arguments: $ARGUMENTS
|
|
13
15
|
|
|
14
16
|
## Resolve PR number
|
|
15
17
|
|
|
16
18
|
1. Strip any trailing `every <N> <unit>` interval clause from `$ARGUMENTS` first.
|
|
17
|
-
2. Extract `--ready-delay <duration>` if present (e.g. `--ready-delay 15m`). Default: `10m`.
|
|
19
|
+
2. Extract `--ready-delay <duration>` if present (e.g. `--ready-delay 15m`). Default: `10m`. Keep the raw duration string (e.g. `10m`) — do **not** convert to seconds.
|
|
18
20
|
3. If the remaining text contains a PR number or GitHub PR URL, extract the number.
|
|
19
21
|
4. Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
|
|
20
22
|
5. If no PR found, report an error and stop.
|
|
21
23
|
|
|
22
|
-
## Detect base branch
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
BASE_BRANCH=$(gh pr view <PR_NUMBER> --json baseRefName --jq '.baseRefName')
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Default to `main` if the command fails.
|
|
29
|
-
|
|
30
24
|
## Start the loop
|
|
31
25
|
|
|
32
26
|
**Before starting:** List existing cron jobs with `CronList`.
|
|
@@ -39,63 +33,26 @@ Invoke `/loop <INTERVAL> --max-turns 50 --expires 8h` via the Skill tool. Use th
|
|
|
39
33
|
|
|
40
34
|
````
|
|
41
35
|
# pr-shepherd-loop:pr=<PR_NUMBER>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- `escalate` → invoke `/loop cancel` via Skill tool, then print:
|
|
64
|
-
|
|
65
|
-
⚠️ /pr-shepherd:monitor paused — needs human direction
|
|
66
|
-
|
|
67
|
-
Triggers: <escalate.triggers joined by ", ">
|
|
68
|
-
<escalate.suggestion>
|
|
69
|
-
|
|
70
|
-
Items needing attention:
|
|
71
|
-
<for each thread in escalate.unresolvedThreads: "- threadId=<id> <path ?? '(no location)'>:<line ?? '?'> (@<author>): <body first line>">
|
|
72
|
-
<if escalate.changesRequestedReviews.length > 0: for each "- reviewId=<id> (@<author>): <body first line>">
|
|
73
|
-
<if escalate.attemptHistory: "Fix attempts: " + each "threadId=<id> attempted <N> times">
|
|
74
|
-
|
|
75
|
-
Run /pr-shepherd:check <PR> to see current state.
|
|
76
|
-
After fixing manually, rerun /pr-shepherd:monitor <PR> to resume.
|
|
77
|
-
|
|
78
|
-
- `fix_code` → do the following, then stop this iteration (CI needs time):
|
|
79
|
-
0. **Triage `fix.comments`** into two buckets before taking any action:
|
|
80
|
-
- **Noise** (`NOISE_COMMENT_IDS`): bot-authored comments with no actionable code feedback — e.g. quota/rate-limit warnings ("you have reached your daily quota", "please wait up to N hours"), "resuming" notices, bare acknowledgements, or any comment whose body contains no file path, line number, or concrete code suggestion. Collect their `id`s.
|
|
81
|
-
- **Actionable**: everything else. When in doubt, treat as actionable.
|
|
82
|
-
All items in `fix.threads` are always actionable (they carry a file path and line by construction).
|
|
83
|
-
1. For each item in `fix.threads` and each **actionable** `fix.comments`: read the referenced file/line and apply the fix (Edit/Write tools).
|
|
84
|
-
2. For each item in `fix.checks`:
|
|
85
|
-
- If `runId` is non-null: fetch the failure log with `gh run view <runId> --log-failed`, scan the output to identify the failure (e.g. grep for `FAIL` for test failures, `error:` for type/compile errors, lint rule names for lint failures), then read the relevant file and apply the fix (Edit/Write tools).
|
|
86
|
-
- If `runId` is null: the failed check is an external status check that cannot be inspected via run logs. Escalate — tell the user to open `detailsUrl` in the PR checks UI, inspect the failure manually, and rerun `/pr-shepherd:monitor <PR_NUMBER>` after addressing it. Do not attempt to fix these automatically.
|
|
87
|
-
3. For each item in `fix.changesRequestedReviews`: read the review body and apply the requested changes.
|
|
88
|
-
4. If files were changed, `git add <files> && git commit -m "<appropriate commit message>"`
|
|
89
|
-
5. If files were changed: `git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease`, then `HEAD_SHA=$(git rev-parse HEAD)`.
|
|
90
|
-
6. If **only noise** was found (no files changed, no threads/checks/reviews to act on): skip commit/push and omit `--require-sha` in the next step.
|
|
91
|
-
7. Resolve the items on GitHub. Build the command from the non-empty ID lists only — always start with:
|
|
92
|
-
`npx pr-shepherd resolve <PR_NUMBER>`
|
|
93
|
-
Then append:
|
|
94
|
-
- `--resolve-thread-ids <IDs>` only if `fix.threads` was non-empty.
|
|
95
|
-
- `--minimize-comment-ids <IDs>` if any comments exist (use `NOISE_COMMENT_IDS` plus IDs of any other comments to minimize).
|
|
96
|
-
- `--dismiss-review-ids <IDs> --message "<specific description of what you changed>"` only if `fix.changesRequestedReviews` was non-empty. The message is shown to the reviewer on GitHub — write one sentence describing the actual fix (e.g. `"Switched to parameterized query in src/db.ts"`). Never use generic text like `"address review comments"`.
|
|
97
|
-
- `--require-sha "$HEAD_SHA"` only if a push occurred (omit when only noise was handled).
|
|
98
|
-
Omit any flag whose ID list is empty.
|
|
36
|
+
|
|
37
|
+
**IMPORTANT — recurrence rules:**
|
|
38
|
+
- **Do NOT call `ScheduleWakeup` or `/loop`.** This session is fired by a recurring cron job. Either call creates a duplicate runner, causing concurrent git operations and `.git/index.lock` collisions.
|
|
39
|
+
- End the turn cleanly after completing the actions below. The cron job handles the next fire.
|
|
40
|
+
|
|
41
|
+
**Self-dedup:** Run `CronList`. If more than one job contains `# pr-shepherd-loop:pr=<PR_NUMBER>`, keep the lowest job ID and `CronDelete` the rest (ignore errors — a concurrent runner may have already deleted them).
|
|
42
|
+
|
|
43
|
+
Run in a single Bash call:
|
|
44
|
+
npx pr-shepherd iterate <PR_NUMBER> --ready-delay <READY_DELAY_DURATION> --no-cache --last-push-time "$(git log -1 --format=%ct HEAD)"
|
|
45
|
+
(`<READY_DELAY_DURATION>` is the raw duration string, e.g. `10m` — never a bare number of seconds)
|
|
46
|
+
|
|
47
|
+
Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with `# PR #<N> [`), log the first line of stderr and continue — do not cancel the loop. The next cron fire will retry.
|
|
48
|
+
|
|
49
|
+
The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Read the `[<ACTION>]` tag to decide what to do (see [docs/actions.md](../../../docs/actions.md) for full output shapes):
|
|
50
|
+
|
|
51
|
+
- `[COOLDOWN]` | `[WAIT]` | `[RERUN_CI]` | `[MARK_READY]` → print the output, continue.
|
|
52
|
+
- `[CANCEL]` → print the output, then invoke `/loop cancel` via Skill tool and stop.
|
|
53
|
+
- `[REBASE]` → print the output, then extract the shell script from the ` ```bash ` fenced block and run it in Bash.
|
|
54
|
+
- `[ESCALATE]` → print the output, then invoke `/loop cancel` via Skill tool and stop.
|
|
55
|
+
- `[FIX_CODE]` → follow the numbered items under `## Instructions` in order. Only run a `resolve` command if those instructions explicitly include a "Run the `resolve:` command…" step, or if the provided resolve command includes mutation flags. In that case, the `resolve` bullet under `## Rebase` holds the final resolve command inside backticks — strip the backticks and run it, substituting `"$HEAD_SHA"` with the pushed SHA and `$DISMISS_MESSAGE` with a one-sentence description of the actual fix (never generic text like "address review comments"). **Never manually run `gh run cancel` after your push** — stale runs listed under `## Cancelled runs` were already cancelled by the CLI (using the pre-push run IDs, as required by the "cancel CI runs before fixing and pushing" rule); running it again post-push would hit the NEW runs your push just triggered. If you run the resolve command, stop this iteration afterward — CI needs time before the next tick.
|
|
99
56
|
|
|
100
57
|
````
|
|
101
58
|
|
|
@@ -111,7 +68,7 @@ The default 4-minute interval is chosen for two reasons:
|
|
|
111
68
|
The loop prompt above handles each iteration directly — no subagent is spawned. The same iterate command can be run manually at any time:
|
|
112
69
|
|
|
113
70
|
```bash
|
|
114
|
-
npx pr-shepherd iterate <PR_NUMBER> --ready-delay <
|
|
71
|
+
npx pr-shepherd iterate <PR_NUMBER> --ready-delay <READY_DELAY_DURATION> --no-cache --last-push-time "$(git log -1 --format=%ct HEAD)"
|
|
115
72
|
```
|
|
116
73
|
|
|
117
74
|
To stop monitoring manually, use `/loop cancel` or close the session.
|
|
@@ -44,12 +44,18 @@ Resolve unresolved review threads and minimize PR comments on the current PR —
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
The CLI auto-resolves outdated threads.
|
|
47
|
-
Parse the JSON for `actionableThreads`, `actionableComments`, `changesRequestedReviews`.
|
|
47
|
+
Parse the JSON for `actionableThreads`, `actionableComments`, `changesRequestedReviews`, `reviewSummaries`.
|
|
48
48
|
|
|
49
|
-
3. **Triage each actionable item
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
49
|
+
3. **Triage each actionable item** into exactly one of these five buckets. Before classifying, read the comment body and — for threads — the referenced file and line.
|
|
50
|
+
- **Fixed** — already addressed in a prior commit; no new work needed.
|
|
51
|
+
- **Actionable** — real issue, not yet fixed; proceed to step 4.
|
|
52
|
+
- **Not relevant** — does not apply to this PR (e.g. comment is about unrelated code).
|
|
53
|
+
- **Outdated** — refers to code that no longer exists.
|
|
54
|
+
- **Acknowledge** — real comment, intentionally not acting on it (e.g. reviewer flagged it as "won't fix" or "not worth it," scope-out decision, deferring to a follow-up PR). Record the one-sentence reason — you will include it in the step 7 report so the user can override.
|
|
55
|
+
|
|
56
|
+
Every item returned by step 2 **must** land in one of these buckets. Do not carry an item forward as "unclassified" or silently skip it. If you genuinely can't decide, that's the Acknowledge bucket with reason "unclear — flagging for human review."
|
|
57
|
+
|
|
58
|
+
**Review summaries** (`reviewSummaries`): these are PR-level overview bodies from COMMENTED reviews. Bot-generated summaries (authors like `copilot-pull-request-reviewer`, `gemini-code-assist`, or other bot accounts) are almost always noise — default them to **Acknowledge** with reason "bot summary — no actionable content" unless the body explicitly calls out an unaddressed issue. Human-authored review summaries should be read carefully and classified like any other item.
|
|
53
59
|
|
|
54
60
|
4. **Fix actionable items.** For each Actionable item:
|
|
55
61
|
- Read the relevant file(s) and apply the fix (Edit/Write tools)
|
|
@@ -59,29 +65,38 @@ Resolve unresolved review threads and minimize PR comments on the current PR —
|
|
|
59
65
|
5. **Commit and push** (only if code was changed):
|
|
60
66
|
- `git add <file1> <file2> …` (NOT `git add -A`)
|
|
61
67
|
- `git commit -m "<appropriate commit message>"`
|
|
68
|
+
- If the fixes alter the PR's scope or intent, run `gh pr edit <N> --title "<new title>" --body "<new body>"` to keep the PR title and description in sync with what was committed. Skip if the existing text still accurately describes the PR.
|
|
62
69
|
- `git fetch origin && git rebase origin/$BASE_BRANCH && git push --force-with-lease`
|
|
63
70
|
- Cancel stale CI runs: `gh run list --branch "$BRANCH" --status in_progress --json databaseId --jq '.[].databaseId' | xargs -I{} gh run cancel {}`
|
|
64
71
|
|
|
65
|
-
6. **Resolve all verified items** — **only
|
|
72
|
+
6. **Resolve all verified items** — **only if at least one of the three ID lists is non-empty.** If all lists are empty, skip this step entirely (running resolve with no mutation IDs enters fetch mode as a side effect). Build the command from the non-empty ID lists; omit any flag whose list is empty. For Fixed items, this step runs only after the push; Acknowledge / Not relevant / Outdated items can be resolved without a push (and therefore without `--require-sha`).
|
|
73
|
+
|
|
74
|
+
Each bucket maps to a mutation flag:
|
|
75
|
+
- **Fixed** threads → `--resolve-thread-ids`; Fixed comments → `--minimize-comment-ids`; Fixed reviews (CHANGES_REQUESTED) → `--dismiss-review-ids --message "<what you changed>"`.
|
|
76
|
+
- **Acknowledge / Not relevant / Outdated** threads → `--resolve-thread-ids`; same-bucket comments → `--minimize-comment-ids`; same-bucket reviews (CHANGES_REQUESTED) → `--dismiss-review-ids --message "<why you're not acting>"`.
|
|
77
|
+
- **Review summaries** in any bucket (Fixed, Acknowledge, Not relevant, Outdated) → `--minimize-comment-ids`. Review summary IDs (`PRR_…` from `reviewSummaries`) are passed here, not to `--dismiss-review-ids`. Do not pass review summary IDs to `--dismiss-review-ids` — that flag is only for CHANGES_REQUESTED reviews.
|
|
66
78
|
|
|
67
79
|
```bash
|
|
68
80
|
npx pr-shepherd resolve <N> \
|
|
69
81
|
--resolve-thread-ids <comma-separated-IDs> \
|
|
70
82
|
--minimize-comment-ids <comma-separated-IDs> \
|
|
71
83
|
--dismiss-review-ids <comma-separated-IDs> \
|
|
72
|
-
--message "<specific description of the fix
|
|
84
|
+
--message "<specific description of the fix OR the reason you're not acting>" \
|
|
73
85
|
--require-sha $(git rev-parse HEAD)
|
|
74
86
|
```
|
|
75
87
|
|
|
76
|
-
`--message` belongs **only** with `--dismiss-review-ids`. Omit it entirely when not dismissing a review. When you are dismissing, write one sentence describing the actual fix
|
|
88
|
+
`--message` belongs **only** with `--dismiss-review-ids`. Omit it entirely when not dismissing a review. When you are dismissing, write one sentence — either describing the actual fix (for Fixed) or the concrete reason for not acting (for Acknowledge). The text is sent to GitHub as the dismissal reason and is shown to the reviewer. Generic text like `"Addressed in <SHA>"` or `"address review comments"` is not acceptable.
|
|
89
|
+
|
|
90
|
+
Include `--require-sha $(git rev-parse HEAD)` whenever a push happened in step 5 (it gates the whole command, not per-item — safe to mix Fixed and Acknowledge IDs under one `--require-sha`). Omit it when no code changed.
|
|
77
91
|
|
|
78
|
-
|
|
92
|
+
7. **Report results.** Echo the CLI's output, then append a one-line summary per Acknowledge item: `Acknowledged <threadId|commentId|reviewId> (@<author>): <reason>`. This surfaces the decisions so the user can override any that were wrong.
|
|
79
93
|
|
|
80
|
-
|
|
94
|
+
If any fetched item was neither resolved nor acknowledged (step 3 is supposed to prevent this, but guard against it), **stop and escalate** to the user: `<N> item(s) fetched but not acted on or acknowledged — need human direction before closing`. Do not silently drop items.
|
|
81
95
|
|
|
82
96
|
## Rules
|
|
83
97
|
|
|
84
|
-
- NEVER resolve threads before pushing
|
|
98
|
+
- NEVER resolve **Fixed** threads before pushing the fix (use `--require-sha`). Acknowledge / Not relevant / Outdated do not require a push and omit `--require-sha`.
|
|
85
99
|
- NEVER blindly resolve items — always read and verify first.
|
|
100
|
+
- NEVER silently skip a fetched item. Every item must be resolved, acknowledged with a reason, or escalated.
|
|
86
101
|
- Resolve from ALL authors — bots, AI reviewers, and humans alike.
|
|
87
|
-
- `--message` is required when using `--dismiss-review-ids`, and must NOT be passed otherwise. The CLI throws if it is missing during dismissal. The message must describe the specific change that addressed the review (e.g. `"Added null check in handler.ts:42"`); generic boilerplate like `"address review comments"` or `"Addressed in <SHA>"` is reviewer-hostile and forbidden.
|
|
102
|
+
- `--message` is required when using `--dismiss-review-ids`, and must NOT be passed otherwise. The CLI throws if it is missing during dismissal. The message must describe the specific change that addressed the review or the concrete reason for not acting (e.g. `"Added null check in handler.ts:42"`, or `"Acknowledged as won't-fix — reviewer noted not worth refactoring"`); generic boilerplate like `"address review comments"` or `"Addressed in <SHA>"` is reviewer-hostile and forbidden.
|