pr-shepherd 0.7.1 → 0.8.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 +37 -303
- package/bin/checks/classify.mjs +5 -4
- package/bin/checks/triage.mjs +76 -62
- package/bin/cli/args.mjs +29 -61
- package/bin/cli/exit-codes.mjs +39 -0
- package/bin/cli/fix-formatter.mjs +76 -0
- package/bin/cli/formatters.mjs +108 -0
- package/bin/cli/handlers.mjs +138 -0
- package/bin/cli/iterate-formatter.mjs +78 -0
- package/bin/cli-parser.iterate-fixtures.mjs +65 -0
- package/bin/cli-parser.mjs +110 -0
- package/bin/commands/check-status.mjs +35 -0
- package/bin/commands/check.mjs +14 -61
- package/bin/commands/commit-suggestion.mjs +159 -0
- package/bin/commands/iterate/classify.mjs +77 -0
- package/bin/commands/iterate/escalate.mjs +124 -0
- package/bin/commands/iterate/fix-code.mjs +97 -0
- package/bin/commands/iterate/helpers.mjs +103 -0
- package/bin/commands/iterate/index.mjs +122 -0
- package/bin/commands/iterate/render.mjs +119 -0
- package/bin/commands/iterate/stall.mjs +65 -0
- package/bin/commands/iterate/steps.mjs +31 -0
- package/bin/commands/iterate.mjs +2 -628
- package/bin/commands/monitor.mjs +78 -0
- package/bin/commands/ready-delay.mjs +3 -4
- package/bin/commands/resolve-instructions.mjs +39 -0
- package/bin/commands/resolve.mjs +34 -3
- package/bin/commands/status.mjs +7 -0
- package/bin/comments/resolve.mjs +1 -1
- package/bin/config/load.mjs +17 -113
- package/bin/config.json +10 -22
- package/bin/github/batch-parsers.mjs +140 -0
- package/bin/github/batch-raw-types.mjs +2 -0
- package/bin/github/batch.mjs +34 -129
- package/bin/github/client.mjs +47 -9
- package/bin/github/gql/batch-pr.gql +20 -0
- package/bin/github/http.mjs +32 -30
- package/bin/index.mjs +15 -2
- package/bin/merge-status/derive.mjs +11 -11
- package/bin/reporters/agent.mjs +13 -4
- package/bin/reporters/check-instructions.mjs +65 -0
- package/bin/reporters/json.mjs +3 -2
- package/bin/reporters/text.mjs +108 -61
- package/bin/{cache → state}/fix-attempts.mjs +3 -3
- package/bin/state/iterate-stall.mjs +74 -0
- package/bin/suggestions/parse.mjs +119 -0
- package/bin/suggestions/patch.mjs +52 -0
- package/bin/types/github.mjs +2 -0
- package/bin/types/iterate.mjs +2 -0
- package/bin/types/report.mjs +2 -0
- package/bin/types.mjs +3 -1
- package/package.json +3 -3
- package/plugin/skills/check/SKILL.md +15 -48
- package/plugin/skills/monitor/SKILL.md +11 -64
- package/plugin/skills/resolve/SKILL.md +10 -76
- package/bin/cache/file-cache.mjs +0 -79
- package/bin/cli.mjs +0 -298
package/bin/github/batch.mjs
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Executes the primary batch GraphQL query and parses the raw GitHub response
|
|
3
|
-
* into shepherd's typed `BatchPrData` shape.
|
|
4
|
-
*
|
|
5
|
-
* The batch query fetches CI checks + review threads + PR comments + merge
|
|
6
|
-
* status in a single network round-trip, drastically reducing API call counts
|
|
7
|
-
* compared to the previous per-agent approach.
|
|
8
|
-
*/
|
|
9
1
|
import { graphql, graphqlWithRateLimit } from "./client.mjs";
|
|
10
2
|
import { paginateForward, paginateBackward } from "./pagination.mjs";
|
|
11
3
|
import { BATCH_PR_QUERY } from "./queries.mjs";
|
|
4
|
+
import { parseRawPr } from "./batch-parsers.mjs";
|
|
12
5
|
/**
|
|
13
6
|
* Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
|
|
14
7
|
*/
|
|
15
|
-
export async function fetchPrBatch(pr, repo) {
|
|
8
|
+
export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
16
9
|
// First page: no cursor variables.
|
|
17
10
|
const result = await graphqlWithRateLimit(BATCH_PR_QUERY, {
|
|
18
11
|
owner: repo.owner,
|
|
@@ -95,12 +88,34 @@ export async function fetchPrBatch(pr, repo) {
|
|
|
95
88
|
}, raw.reviewSummaries.pageInfo.startCursor);
|
|
96
89
|
rawReviewSummaryNodes = [...extra, ...rawReviewSummaryNodes];
|
|
97
90
|
}
|
|
91
|
+
// Paginate APPROVED reviews backward if the first page is incomplete — gated behind
|
|
92
|
+
// `paginateApprovedReviews` because approvals minimization is opt-in. See FetchPrBatchOptions.
|
|
93
|
+
let rawApprovedReviewNodes = raw.approvedReviews.nodes;
|
|
94
|
+
if (opts.paginateApprovedReviews &&
|
|
95
|
+
raw.approvedReviews.pageInfo.hasPreviousPage &&
|
|
96
|
+
raw.approvedReviews.pageInfo.startCursor) {
|
|
97
|
+
const extra = await paginateBackward(async (cursor) => {
|
|
98
|
+
const res = await graphql(BATCH_PR_QUERY, {
|
|
99
|
+
owner: repo.owner,
|
|
100
|
+
repo: repo.name,
|
|
101
|
+
pr,
|
|
102
|
+
...(cursor ? { approvedReviewsCursor: cursor } : {}),
|
|
103
|
+
});
|
|
104
|
+
const pr2 = res.data.repository.pullRequest;
|
|
105
|
+
if (!pr2)
|
|
106
|
+
throw new Error(`PR #${pr} not found`);
|
|
107
|
+
return pr2.approvedReviews;
|
|
108
|
+
}, raw.approvedReviews.pageInfo.startCursor);
|
|
109
|
+
rawApprovedReviewNodes = [...extra, ...rawApprovedReviewNodes];
|
|
110
|
+
}
|
|
98
111
|
// Paginate check contexts forward if the first page is incomplete.
|
|
99
112
|
let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
|
|
100
113
|
const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
|
|
114
|
+
const firstOid = raw.commits.nodes[0]?.commit.oid;
|
|
101
115
|
if (checksPageInfo?.hasNextPage && checksPageInfo.endCursor) {
|
|
102
116
|
// Pass endCursor so paginateForward fetches pages *after* the already-
|
|
103
117
|
// fetched first page instead of re-fetching it from the start.
|
|
118
|
+
let pageCount = 0;
|
|
104
119
|
const extra = await paginateForward(async (cursor) => {
|
|
105
120
|
const res = await graphql(BATCH_PR_QUERY, {
|
|
106
121
|
owner: repo.owner,
|
|
@@ -109,129 +124,19 @@ export async function fetchPrBatch(pr, repo) {
|
|
|
109
124
|
...(cursor ? { checksCursor: cursor } : {}),
|
|
110
125
|
});
|
|
111
126
|
const pr2 = res.data.repository.pullRequest;
|
|
112
|
-
|
|
127
|
+
if (!pr2?.commits.nodes[0]?.commit.statusCheckRollup) {
|
|
128
|
+
throw new Error(`Check-context pagination interrupted: statusCheckRollup disappeared on page ${pageCount + 2} (possible force-push race). Retry after the push stabilizes.`);
|
|
129
|
+
}
|
|
130
|
+
const currentOid = pr2.commits.nodes[0]?.commit.oid;
|
|
131
|
+
if (firstOid !== undefined && currentOid !== undefined && currentOid !== firstOid) {
|
|
132
|
+
throw new Error(`Check-context pagination interrupted: head commit changed from ${firstOid} to ${currentOid} between pages (force-push race). Retry.`);
|
|
133
|
+
}
|
|
134
|
+
pageCount++;
|
|
135
|
+
const ctxs = pr2.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
113
136
|
return ctxs ?? { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
|
|
114
137
|
}, checksPageInfo.endCursor);
|
|
115
138
|
rawCheckNodes = [...rawCheckNodes, ...extra];
|
|
116
139
|
}
|
|
117
|
-
const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawCheckNodes);
|
|
140
|
+
const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes);
|
|
118
141
|
return { data, rateLimit: result.rateLimit };
|
|
119
142
|
}
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
// Parsers
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawCheckNodes) {
|
|
124
|
-
const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
|
|
125
|
-
const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
|
|
126
|
-
return login ? [{ login }] : [];
|
|
127
|
-
});
|
|
128
|
-
const latestReviews = (raw.latestReviews?.nodes ?? []).map((n) => ({
|
|
129
|
-
login: n.author?.login ?? "unknown",
|
|
130
|
-
state: n.state,
|
|
131
|
-
}));
|
|
132
|
-
const reviewThreads = rawThreadPages.map((t) => {
|
|
133
|
-
const comment = t.comments.nodes[0];
|
|
134
|
-
return {
|
|
135
|
-
id: t.id,
|
|
136
|
-
isResolved: t.isResolved,
|
|
137
|
-
isOutdated: t.isOutdated,
|
|
138
|
-
isMinimized: comment?.isMinimized ?? false,
|
|
139
|
-
path: comment?.path ?? null,
|
|
140
|
-
line: comment?.line ?? null,
|
|
141
|
-
author: comment?.author?.login ?? "unknown",
|
|
142
|
-
body: comment?.body ?? "",
|
|
143
|
-
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
144
|
-
};
|
|
145
|
-
});
|
|
146
|
-
const comments = rawCommentNodes.map((c) => ({
|
|
147
|
-
id: c.id,
|
|
148
|
-
isMinimized: c.isMinimized,
|
|
149
|
-
author: c.author?.login ?? "unknown",
|
|
150
|
-
body: c.body,
|
|
151
|
-
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
152
|
-
}));
|
|
153
|
-
const changesRequestedReviews = rawReviewNodes.map((r) => ({
|
|
154
|
-
id: r.id,
|
|
155
|
-
author: r.author?.login ?? "unknown",
|
|
156
|
-
body: r.body,
|
|
157
|
-
}));
|
|
158
|
-
const reviewSummaries = rawReviewSummaryNodes
|
|
159
|
-
.filter((r) => !r.isMinimized && r.body.trim() !== "")
|
|
160
|
-
.map((r) => ({
|
|
161
|
-
id: r.id,
|
|
162
|
-
author: r.author?.login ?? "unknown",
|
|
163
|
-
body: r.body,
|
|
164
|
-
}));
|
|
165
|
-
const checks = rawCheckNodes.flatMap((node) => {
|
|
166
|
-
if (node.__typename === "CheckRun") {
|
|
167
|
-
const event = node.checkSuite?.workflowRun?.event ?? null;
|
|
168
|
-
const runId = extractRunId(node.detailsUrl);
|
|
169
|
-
return [
|
|
170
|
-
{
|
|
171
|
-
name: node.name,
|
|
172
|
-
status: node.status,
|
|
173
|
-
conclusion: node.conclusion,
|
|
174
|
-
detailsUrl: node.detailsUrl ?? "",
|
|
175
|
-
event,
|
|
176
|
-
runId,
|
|
177
|
-
},
|
|
178
|
-
];
|
|
179
|
-
}
|
|
180
|
-
if (node.__typename === "StatusContext") {
|
|
181
|
-
const { status, conclusion } = mapStatusContextState(node.state);
|
|
182
|
-
return [
|
|
183
|
-
{
|
|
184
|
-
name: node.context,
|
|
185
|
-
status,
|
|
186
|
-
conclusion,
|
|
187
|
-
detailsUrl: node.targetUrl ?? "",
|
|
188
|
-
event: null,
|
|
189
|
-
runId: null,
|
|
190
|
-
},
|
|
191
|
-
];
|
|
192
|
-
}
|
|
193
|
-
return [];
|
|
194
|
-
});
|
|
195
|
-
return {
|
|
196
|
-
nodeId: raw.id,
|
|
197
|
-
number: raw.number,
|
|
198
|
-
state: raw.state,
|
|
199
|
-
isDraft: raw.isDraft,
|
|
200
|
-
mergeable: raw.mergeable,
|
|
201
|
-
mergeStateStatus: raw.mergeStateStatus,
|
|
202
|
-
reviewDecision: (raw.reviewDecision ?? null),
|
|
203
|
-
headRefOid: raw.headRefOid,
|
|
204
|
-
baseRefName: raw.baseRefName,
|
|
205
|
-
reviewRequests,
|
|
206
|
-
latestReviews,
|
|
207
|
-
reviewThreads,
|
|
208
|
-
comments,
|
|
209
|
-
changesRequestedReviews,
|
|
210
|
-
reviewSummaries,
|
|
211
|
-
checks,
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
function parseCreatedAt(iso) {
|
|
215
|
-
const ms = new Date(iso).getTime();
|
|
216
|
-
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
217
|
-
}
|
|
218
|
-
function extractRunId(url) {
|
|
219
|
-
if (!url)
|
|
220
|
-
return null;
|
|
221
|
-
const m = /\/runs\/(\d+)/.exec(url);
|
|
222
|
-
return m ? (m[1] ?? null) : null;
|
|
223
|
-
}
|
|
224
|
-
/** Maps a GitHub commit status `state` to CheckRun-compatible status + conclusion. */
|
|
225
|
-
function mapStatusContextState(state) {
|
|
226
|
-
switch (state) {
|
|
227
|
-
case "SUCCESS":
|
|
228
|
-
return { status: "COMPLETED", conclusion: "SUCCESS" };
|
|
229
|
-
case "FAILURE":
|
|
230
|
-
case "ERROR":
|
|
231
|
-
return { status: "COMPLETED", conclusion: "FAILURE" };
|
|
232
|
-
case "PENDING":
|
|
233
|
-
case "EXPECTED":
|
|
234
|
-
default:
|
|
235
|
-
return { status: "IN_PROGRESS", conclusion: null };
|
|
236
|
-
}
|
|
237
|
-
}
|
package/bin/github/client.mjs
CHANGED
|
@@ -45,6 +45,36 @@ export async function getPrHeadSha(pr, owner, name) {
|
|
|
45
45
|
const data = await rest("GET", `/repos/${owner}/${name}/pulls/${pr}`);
|
|
46
46
|
return data.head.sha;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Full head info for a PR — sha, branch name, and the head repository's full name.
|
|
50
|
+
* Needed by commit-suggestions to target the correct branch via `createCommitOnBranch`.
|
|
51
|
+
*/
|
|
52
|
+
export async function getPrHead(pr, owner, name) {
|
|
53
|
+
const data = await rest("GET", `/repos/${owner}/${name}/pulls/${pr}`);
|
|
54
|
+
if (!data.head.repo) {
|
|
55
|
+
throw new Error(`PR #${pr} head repository is unavailable (fork may have been deleted).`);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
sha: data.head.sha,
|
|
59
|
+
ref: data.head.ref,
|
|
60
|
+
repoWithOwner: data.head.repo.full_name,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Fetch a file's raw text content at a given ref. Uses the REST contents endpoint,
|
|
65
|
+
* which returns base64 for files under 1MB. Throws for binary / oversize files.
|
|
66
|
+
*/
|
|
67
|
+
export async function getFileContents(repoWithOwner, path, ref) {
|
|
68
|
+
const data = await rest("GET", `/repos/${repoWithOwner}/contents/${encodePathForApi(path)}?ref=${encodeURIComponent(ref)}`);
|
|
69
|
+
if (!data.content || data.encoding !== "base64") {
|
|
70
|
+
throw new Error(`File ${path} could not be read as text (encoding=${data.encoding ?? "n/a"}).`);
|
|
71
|
+
}
|
|
72
|
+
return Buffer.from(data.content, "base64").toString("utf8");
|
|
73
|
+
}
|
|
74
|
+
// Encode every path segment but preserve the slashes between them.
|
|
75
|
+
function encodePathForApi(path) {
|
|
76
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
77
|
+
}
|
|
48
78
|
/**
|
|
49
79
|
* Fetches `mergeable` and `mergeStateStatus` via the REST API.
|
|
50
80
|
*
|
|
@@ -60,22 +90,30 @@ export async function getMergeableState(pr, owner, repo) {
|
|
|
60
90
|
// ---------------------------------------------------------------------------
|
|
61
91
|
// Internal helpers
|
|
62
92
|
// ---------------------------------------------------------------------------
|
|
63
|
-
async function getCurrentBranch() {
|
|
93
|
+
export async function getCurrentBranch() {
|
|
64
94
|
const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
65
95
|
return stdout.trim();
|
|
66
96
|
}
|
|
67
97
|
function parseRemoteUrl(url) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const sshMatch = /^git@[^:]+:([^/]+)\/(.+)$/.exec(stripped);
|
|
98
|
+
const trimmed = url.trim();
|
|
99
|
+
// ssh: git@host:owner/repo[.git]
|
|
100
|
+
const sshMatch = /^git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(trimmed);
|
|
72
101
|
if (sshMatch) {
|
|
73
102
|
return { owner: sshMatch[1], name: sshMatch[2] };
|
|
74
103
|
}
|
|
75
|
-
// https or ssh://:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
// https or ssh://: parse via URL and require exactly /<owner>/<repo>[.git]
|
|
105
|
+
if (/^(?:https?|ssh):\/\//.test(trimmed)) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = new URL(trimmed);
|
|
108
|
+
const pathname = parsed.pathname.replace(/\.git\/?$/, "").replace(/\/$/, "");
|
|
109
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
110
|
+
if (parts.length === 2) {
|
|
111
|
+
return { owner: parts[0], name: parts[1] };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// fall through to error
|
|
116
|
+
}
|
|
79
117
|
}
|
|
80
118
|
throw new Error(`Cannot parse GitHub remote URL: ${url}`);
|
|
81
119
|
}
|
|
@@ -7,6 +7,7 @@ query BatchPr(
|
|
|
7
7
|
$commentsCursor: String
|
|
8
8
|
$changesRequestedCursor: String
|
|
9
9
|
$reviewSummariesCursor: String
|
|
10
|
+
$approvedReviewsCursor: String
|
|
10
11
|
) {
|
|
11
12
|
repository(owner: $owner, name: $repo) {
|
|
12
13
|
pullRequest(number: $pr) {
|
|
@@ -63,6 +64,7 @@ query BatchPr(
|
|
|
63
64
|
body
|
|
64
65
|
path
|
|
65
66
|
line
|
|
67
|
+
startLine
|
|
66
68
|
createdAt
|
|
67
69
|
}
|
|
68
70
|
}
|
|
@@ -114,9 +116,24 @@ query BatchPr(
|
|
|
114
116
|
body
|
|
115
117
|
}
|
|
116
118
|
}
|
|
119
|
+
approvedReviews: reviews(states: APPROVED, last: 50, before: $approvedReviewsCursor) {
|
|
120
|
+
pageInfo {
|
|
121
|
+
hasPreviousPage
|
|
122
|
+
startCursor
|
|
123
|
+
}
|
|
124
|
+
nodes {
|
|
125
|
+
id
|
|
126
|
+
isMinimized
|
|
127
|
+
author {
|
|
128
|
+
login
|
|
129
|
+
}
|
|
130
|
+
body
|
|
131
|
+
}
|
|
132
|
+
}
|
|
117
133
|
commits(last: 1) {
|
|
118
134
|
nodes {
|
|
119
135
|
commit {
|
|
136
|
+
oid
|
|
120
137
|
statusCheckRollup {
|
|
121
138
|
contexts(first: 100, after: $checksCursor) {
|
|
122
139
|
pageInfo {
|
|
@@ -130,6 +147,8 @@ query BatchPr(
|
|
|
130
147
|
status
|
|
131
148
|
conclusion
|
|
132
149
|
detailsUrl
|
|
150
|
+
title
|
|
151
|
+
summary
|
|
133
152
|
checkSuite {
|
|
134
153
|
workflowRun {
|
|
135
154
|
event
|
|
@@ -140,6 +159,7 @@ query BatchPr(
|
|
|
140
159
|
context
|
|
141
160
|
state
|
|
142
161
|
targetUrl
|
|
162
|
+
description
|
|
143
163
|
}
|
|
144
164
|
}
|
|
145
165
|
}
|
package/bin/github/http.mjs
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
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
1
|
import { execFile as execFileCb } from "node:child_process";
|
|
11
2
|
import { promisify } from "node:util";
|
|
12
3
|
const execFile = promisify(execFileCb);
|
|
@@ -21,12 +12,9 @@ export function _resetTokenCache() {
|
|
|
21
12
|
async function resolveToken() {
|
|
22
13
|
if (_token)
|
|
23
14
|
return _token;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
if (process.env["GITHUB_TOKEN"]) {
|
|
29
|
-
_token = process.env["GITHUB_TOKEN"];
|
|
15
|
+
const envToken = process.env["GH_TOKEN"] ?? process.env["GITHUB_TOKEN"];
|
|
16
|
+
if (envToken) {
|
|
17
|
+
_token = envToken;
|
|
30
18
|
return _token;
|
|
31
19
|
}
|
|
32
20
|
try {
|
|
@@ -51,24 +39,43 @@ async function makeHeaders() {
|
|
|
51
39
|
"Content-Type": "application/json",
|
|
52
40
|
};
|
|
53
41
|
}
|
|
42
|
+
function sanitizeBody(body) {
|
|
43
|
+
return body.replace(/Bearer\s+\S+/gi, "[REDACTED]").slice(0, 200);
|
|
44
|
+
}
|
|
45
|
+
async function requestWithTokenRetry(fn) {
|
|
46
|
+
const res = await fn();
|
|
47
|
+
if (res.status === 401 && _token !== undefined) {
|
|
48
|
+
try {
|
|
49
|
+
await res.arrayBuffer();
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
_token = undefined;
|
|
53
|
+
return fn();
|
|
54
|
+
}
|
|
55
|
+
return res;
|
|
56
|
+
}
|
|
54
57
|
// ---------------------------------------------------------------------------
|
|
55
58
|
// GraphQL
|
|
56
59
|
// ---------------------------------------------------------------------------
|
|
57
60
|
async function graphqlInner(query, vars) {
|
|
58
|
-
const res = await fetch(`${BASE_URL}/graphql`, {
|
|
61
|
+
const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}/graphql`, {
|
|
59
62
|
method: "POST",
|
|
60
63
|
headers: await makeHeaders(),
|
|
61
64
|
body: JSON.stringify({ query, variables: vars }),
|
|
62
|
-
});
|
|
65
|
+
}));
|
|
63
66
|
const rateLimit = parseRateLimit(res.headers);
|
|
64
67
|
if (!res.ok) {
|
|
65
68
|
const body = await res.text();
|
|
66
|
-
throw new Error(`GitHub GraphQL request failed: ${res.status} ${body
|
|
69
|
+
throw new Error(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`);
|
|
67
70
|
}
|
|
68
71
|
const parsed = (await res.json());
|
|
72
|
+
if (parsed.data == null) {
|
|
73
|
+
const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
|
|
74
|
+
throw new Error(`GitHub GraphQL error (no data): ${messages}`);
|
|
75
|
+
}
|
|
69
76
|
if (parsed.errors?.length) {
|
|
70
77
|
const messages = parsed.errors.map((e) => e.message).join("; ");
|
|
71
|
-
|
|
78
|
+
process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
|
|
72
79
|
}
|
|
73
80
|
return { data: parsed.data, rateLimit };
|
|
74
81
|
}
|
|
@@ -84,14 +91,14 @@ export async function graphqlWithRateLimit(query, vars = {}) {
|
|
|
84
91
|
// REST
|
|
85
92
|
// ---------------------------------------------------------------------------
|
|
86
93
|
export async function rest(method, path, body) {
|
|
87
|
-
const res = await fetch(`${BASE_URL}${path}`, {
|
|
94
|
+
const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}${path}`, {
|
|
88
95
|
method,
|
|
89
96
|
headers: await makeHeaders(),
|
|
90
97
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
91
|
-
});
|
|
98
|
+
}));
|
|
92
99
|
if (!res.ok) {
|
|
93
100
|
const text = await res.text();
|
|
94
|
-
throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${text
|
|
101
|
+
throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`);
|
|
95
102
|
}
|
|
96
103
|
const ct = res.headers.get("content-type") ?? "";
|
|
97
104
|
if (ct.includes("application/json")) {
|
|
@@ -99,17 +106,12 @@ export async function rest(method, path, body) {
|
|
|
99
106
|
}
|
|
100
107
|
return undefined;
|
|
101
108
|
}
|
|
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
109
|
export async function restText(path) {
|
|
108
|
-
const res = await fetch(`${BASE_URL}${path}`, {
|
|
110
|
+
const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}${path}`, {
|
|
109
111
|
method: "GET",
|
|
110
112
|
headers: await makeHeaders(),
|
|
111
113
|
redirect: "manual",
|
|
112
|
-
});
|
|
114
|
+
}));
|
|
113
115
|
if (res.status === 301 || res.status === 302 || res.status === 307 || res.status === 308) {
|
|
114
116
|
const location = res.headers.get("location");
|
|
115
117
|
if (location) {
|
|
@@ -122,7 +124,7 @@ export async function restText(path) {
|
|
|
122
124
|
}
|
|
123
125
|
if (!res.ok) {
|
|
124
126
|
const text = await res.text();
|
|
125
|
-
throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${text
|
|
127
|
+
throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`);
|
|
126
128
|
}
|
|
127
129
|
return res.text();
|
|
128
130
|
}
|
package/bin/index.mjs
CHANGED
|
@@ -8,8 +8,21 @@
|
|
|
8
8
|
* pr-shepherd iterate [PR]
|
|
9
9
|
* pr-shepherd status PR1 [PR2 …]
|
|
10
10
|
*/
|
|
11
|
-
import { main } from "./cli.mjs";
|
|
11
|
+
import { main } from "./cli-parser.mjs";
|
|
12
|
+
function formatCause(cause, seen = new Set(), depth = 0) {
|
|
13
|
+
if (depth > 5 || seen.has(cause))
|
|
14
|
+
return "[circular or deep cause chain]";
|
|
15
|
+
seen.add(cause);
|
|
16
|
+
if (cause instanceof Error) {
|
|
17
|
+
const stack = cause.stack ?? `${cause.message}`;
|
|
18
|
+
const nested = cause.cause != null ? `\n caused by: ${formatCause(cause.cause, seen, depth + 1)}` : "";
|
|
19
|
+
return `${stack}${nested}`;
|
|
20
|
+
}
|
|
21
|
+
return String(cause);
|
|
22
|
+
}
|
|
12
23
|
main(process.argv).catch((err) => {
|
|
13
|
-
|
|
24
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
25
|
+
const causeStr = err instanceof Error && err.cause != null ? formatCause(err.cause) : null;
|
|
26
|
+
process.stderr.write(`pr-shepherd error: ${msg}${causeStr !== null ? ` (cause: ${causeStr})` : ""}\n`);
|
|
14
27
|
process.exit(1);
|
|
15
28
|
});
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* for non-OPEN (merged/closed) PRs — this function does not branch on it.
|
|
6
6
|
*
|
|
7
7
|
* Interpretation order for `status` — first match wins:
|
|
8
|
-
* 1. mergeable == CONFLICTING → CONFLICTS
|
|
9
|
-
* 2.
|
|
10
|
-
* 3.
|
|
11
|
-
* 4.
|
|
12
|
-
* 5. mergeStateStatus
|
|
13
|
-
* 6. mergeStateStatus
|
|
14
|
-
* 7.
|
|
8
|
+
* 1. mergeable == CONFLICTING → CONFLICTS (hard conflict even for drafts)
|
|
9
|
+
* 2. copilotReviewInProgress → BLOCKED
|
|
10
|
+
* 3. mergeStateStatus DIRTY → CONFLICTS (GitHub merge conflicts, even for drafts)
|
|
11
|
+
* 4. isDraft → DRAFT
|
|
12
|
+
* 5. mergeStateStatus BEHIND → BEHIND
|
|
13
|
+
* 6. mergeStateStatus BLOCKED / HAS_HOOKS → BLOCKED
|
|
14
|
+
* 7. mergeStateStatus UNSTABLE → UNSTABLE
|
|
15
15
|
* 8. mergeStateStatus UNKNOWN → UNKNOWN
|
|
16
16
|
* 9. mergeStateStatus CLEAN → CLEAN
|
|
17
17
|
*/
|
|
@@ -26,9 +26,12 @@ export function deriveMergeStatus(pr) {
|
|
|
26
26
|
status = "BLOCKED";
|
|
27
27
|
}
|
|
28
28
|
else if (pr.mergeStateStatus === "DIRTY") {
|
|
29
|
-
// DIRTY means GitHub detected merge conflicts
|
|
29
|
+
// DIRTY means GitHub detected merge conflicts — surface as CONFLICTS even for drafts.
|
|
30
30
|
status = "CONFLICTS";
|
|
31
31
|
}
|
|
32
|
+
else if (pr.isDraft || pr.mergeStateStatus === "DRAFT") {
|
|
33
|
+
status = "DRAFT";
|
|
34
|
+
}
|
|
32
35
|
else if (pr.mergeStateStatus === "BEHIND") {
|
|
33
36
|
status = "BEHIND";
|
|
34
37
|
}
|
|
@@ -38,9 +41,6 @@ export function deriveMergeStatus(pr) {
|
|
|
38
41
|
else if (pr.mergeStateStatus === "UNSTABLE") {
|
|
39
42
|
status = "UNSTABLE";
|
|
40
43
|
}
|
|
41
|
-
else if (pr.isDraft || pr.mergeStateStatus === "DRAFT") {
|
|
42
|
-
status = "DRAFT";
|
|
43
|
-
}
|
|
44
44
|
else if (pr.mergeStateStatus === "UNKNOWN") {
|
|
45
45
|
status = "UNKNOWN";
|
|
46
46
|
}
|
package/bin/reporters/agent.mjs
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Projections for the agent-facing iterate output.
|
|
3
3
|
*
|
|
4
4
|
* These strip fields that are always-false by the time items reach iterate
|
|
5
|
-
* (isResolved, isOutdated, isMinimized, createdAtUnix) and metadata
|
|
6
|
-
* monitor prompt never reads (
|
|
7
|
-
*
|
|
5
|
+
* (isResolved, isOutdated, isMinimized, createdAtUnix) and check metadata the
|
|
6
|
+
* monitor prompt never reads (event, status, conclusion, category).
|
|
7
|
+
* detailsUrl is preserved in AgentCheck as a fallback for external status checks.
|
|
8
|
+
* The original domain types are preserved for check command output.
|
|
8
9
|
*/
|
|
9
10
|
export function toAgentThread(t) {
|
|
10
11
|
return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body };
|
|
@@ -13,7 +14,15 @@ export function toAgentComment(c) {
|
|
|
13
14
|
return { id: c.id, author: c.author, body: c.body };
|
|
14
15
|
}
|
|
15
16
|
export function toAgentCheck(c) {
|
|
16
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
name: c.name,
|
|
19
|
+
runId: c.runId,
|
|
20
|
+
detailsUrl: c.detailsUrl,
|
|
21
|
+
failureKind: c.failureKind,
|
|
22
|
+
...(c.workflowName !== undefined && { workflowName: c.workflowName }),
|
|
23
|
+
...(c.failedStep !== undefined && { failedStep: c.failedStep }),
|
|
24
|
+
...(c.summary !== undefined && { summary: c.summary }),
|
|
25
|
+
};
|
|
17
26
|
}
|
|
18
27
|
/**
|
|
19
28
|
* Project and deduplicate checks so the agent makes one `gh run view` call
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the numbered instruction steps for the agent to follow after a `check` run.
|
|
3
|
+
* All rebase policy, CI budget policy, and ready-to-merge gating live here so the
|
|
4
|
+
* skill stays a thin dispatcher and these rules co-evolve with the CLI data model.
|
|
5
|
+
*/
|
|
6
|
+
export function buildCheckInstructions(report) {
|
|
7
|
+
const { mergeStatus, checks, threads, comments, changesRequestedReviews, status } = report;
|
|
8
|
+
const instructions = [];
|
|
9
|
+
// 1. Summary
|
|
10
|
+
const totalActionable = threads.actionable.length + comments.actionable.length + changesRequestedReviews.length;
|
|
11
|
+
const total = checks.passing.length +
|
|
12
|
+
checks.failing.length +
|
|
13
|
+
checks.inProgress.length +
|
|
14
|
+
checks.skipped.length;
|
|
15
|
+
const copilotNote = mergeStatus.copilotReviewInProgress ? " (Copilot review in progress)" : "";
|
|
16
|
+
instructions.push(`Report: merge status is ${mergeStatus.status}${copilotNote}, CI ${checks.passing.length}/${total} passed` +
|
|
17
|
+
(checks.failing.length > 0 ? ` (${checks.failing.length} failing)` : "") +
|
|
18
|
+
(checks.inProgress.length > 0 ? ` (${checks.inProgress.length} in progress)` : "") +
|
|
19
|
+
`, ${totalActionable} actionable review item(s).`);
|
|
20
|
+
// 2. Rebase policy (only emit when relevant)
|
|
21
|
+
if (mergeStatus.status === "CONFLICTS") {
|
|
22
|
+
instructions.push("Rebase required: the branch has merge conflicts that must be resolved before this PR can land.");
|
|
23
|
+
}
|
|
24
|
+
else if (mergeStatus.status === "BEHIND") {
|
|
25
|
+
instructions.push("The PR is behind the base branch. A rebase is optional if all CI checks pass.");
|
|
26
|
+
}
|
|
27
|
+
// 3. CI budget policy — one instruction per failing check
|
|
28
|
+
for (const c of checks.failing) {
|
|
29
|
+
if (c.failureKind === "actionable") {
|
|
30
|
+
const diagnosisHint = c.failedStep
|
|
31
|
+
? `the failure was in step \`${c.failedStep}\` — fetch the run log with \`gh run view ${c.runId ?? "<runId>"} --log-failed\` to diagnose`
|
|
32
|
+
: c.runId
|
|
33
|
+
? `fetch the run log with \`gh run view ${c.runId} --log-failed\` to diagnose the failure`
|
|
34
|
+
: `open the check details (${c.detailsUrl}) to diagnose the failure`;
|
|
35
|
+
instructions.push(`Fix code failure: \`${c.name}\` (actionable) — ${diagnosisHint} and apply a fix.`);
|
|
36
|
+
}
|
|
37
|
+
else if (c.failureKind === "cancelled" || c.failureKind === "timeout") {
|
|
38
|
+
const rerunCmd = c.runId
|
|
39
|
+
? `gh run rerun ${c.runId} --failed`
|
|
40
|
+
: `gh run rerun <runId> --failed`;
|
|
41
|
+
instructions.push(`Re-run transient failure: \`${c.name}\` [${c.failureKind}] — run \`${rerunCmd}\`.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// 4. Ready-to-merge gate
|
|
45
|
+
const isClean = mergeStatus.mergeStateStatus === "CLEAN";
|
|
46
|
+
const isReady = isClean && status === "READY" && !mergeStatus.copilotReviewInProgress;
|
|
47
|
+
if (isReady) {
|
|
48
|
+
instructions.push("This PR is ready to merge: mergeStateStatus is CLEAN, status is READY, and no Copilot review is in progress.");
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const blockers = [];
|
|
52
|
+
if (!isClean)
|
|
53
|
+
blockers.push(`mergeStateStatus is ${mergeStatus.mergeStateStatus} (not CLEAN)`);
|
|
54
|
+
if (status !== "READY")
|
|
55
|
+
blockers.push(`status is ${status} (not READY)`);
|
|
56
|
+
if (mergeStatus.copilotReviewInProgress)
|
|
57
|
+
blockers.push("Copilot review is still in progress");
|
|
58
|
+
instructions.push(`Do not declare this PR ready to merge: ${blockers.join("; ")}.`);
|
|
59
|
+
}
|
|
60
|
+
// 5. Continuous monitoring pointer (suppressed only when truly ready to merge)
|
|
61
|
+
if (!isReady) {
|
|
62
|
+
instructions.push("This is a one-shot check. For continuous monitoring that acts on these signals automatically, use `/pr-shepherd:monitor`.");
|
|
63
|
+
}
|
|
64
|
+
return instructions;
|
|
65
|
+
}
|
package/bin/reporters/json.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Machine-readable JSON reporter.
|
|
2
|
+
* Machine-readable JSON reporter for shepherd check output.
|
|
3
3
|
*
|
|
4
4
|
* Slash commands parse this output to extract IDs, status, and actionable items
|
|
5
5
|
* without string-scraping the human-readable text reporter.
|
|
6
6
|
*/
|
|
7
|
+
import { buildCheckInstructions } from "./check-instructions.mjs";
|
|
7
8
|
export function formatJson(report) {
|
|
8
|
-
return JSON.stringify(report, null, 2);
|
|
9
|
+
return JSON.stringify({ ...report, instructions: buildCheckInstructions(report) }, null, 2);
|
|
9
10
|
}
|