pr-shepherd 0.9.0 → 0.10.1
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 +2 -2
- package/bin/checks/triage.mjs +32 -40
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +31 -25
- package/bin/cli/formatters.mjs +33 -51
- package/bin/cli/handlers.mjs +1 -1
- package/bin/cli/iterate-formatter.mjs +20 -35
- package/bin/cli/iterate-lean.mjs +3 -3
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +2 -2
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check.mjs +7 -5
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +10 -32
- package/bin/commands/iterate/escalate.mjs +3 -12
- package/bin/commands/iterate/fix-code.mjs +7 -11
- package/bin/commands/iterate/index.mjs +2 -1
- package/bin/commands/iterate/render.mjs +30 -21
- package/bin/commands/log-file.mjs +7 -0
- package/bin/commands/monitor.mjs +7 -4
- package/bin/commands/ready-delay.mjs +2 -2
- package/bin/commands/resolve-instructions.mjs +5 -2
- package/bin/commands/resolve.mjs +1 -20
- package/bin/commands/status.mjs +40 -28
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +2 -2
- package/bin/github/batch-parsers.mjs +2 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +4 -0
- package/bin/github/gql/get-pr-head-sha.gql +7 -0
- package/bin/github/http.mjs +166 -11
- package/bin/github/queries.mjs +7 -11
- package/bin/log/log-file.mjs +88 -0
- package/bin/log/session.mjs +100 -0
- package/bin/log/setup.mjs +54 -0
- package/bin/reporters/agent.mjs +14 -1
- package/bin/reporters/text.mjs +82 -102
- package/bin/state/base.mjs +5 -0
- package/bin/state/fix-attempts.mjs +2 -2
- package/bin/state/iterate-stall.mjs +2 -2
- package/bin/state/seen-comments.mjs +2 -2
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +6 -5
- package/bin/github/gql/dismiss-review.gql +0 -7
- package/bin/github/gql/minimize-comment.gql +0 -7
- package/bin/github/gql/multi-pr-status.gql +0 -32
- package/bin/github/gql/resolve-thread.gql +0 -7
package/bin/comments/resolve.mjs
CHANGED
|
@@ -1,32 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Batched mutations for resolving threads, minimizing comments, and dismissing reviews.
|
|
3
|
-
*
|
|
4
|
-
* The three mutation types (resolve / minimize / dismiss) are run sequentially so total
|
|
5
|
-
* in-flight mutations never exceed CONCURRENCY at once, keeping us well within GitHub's
|
|
6
|
-
* secondary rate-limit window.
|
|
7
|
-
*
|
|
8
|
-
* Push-before-resolve safety:
|
|
9
|
-
* When `requireSha` is set, shepherd verifies that GitHub has received that
|
|
10
|
-
* commit before issuing any resolve/dismiss mutations. It polls up to 20 seconds.
|
|
11
|
-
* If the push hasn't landed, shepherd throws rather than resolving prematurely
|
|
12
|
-
* (which could allow auto-merge before reviewers see the fix).
|
|
13
|
-
*/
|
|
14
1
|
import { graphql, getPrHeadSha } from "../github/client.mjs";
|
|
15
|
-
import { RESOLVE_THREAD_MUTATION, MINIMIZE_COMMENT_MUTATION, DISMISS_REVIEW_MUTATION, } from "../github/queries.mjs";
|
|
16
2
|
import { loadConfig } from "../config/load.mjs";
|
|
17
|
-
/**
|
|
18
|
-
* Execute all requested resolve/minimize/dismiss mutations.
|
|
19
|
-
*
|
|
20
|
-
* @throws Error if `requireSha` is set and GitHub hasn't received that commit
|
|
21
|
-
* within the polling window.
|
|
22
|
-
*/
|
|
23
3
|
export async function applyResolveOptions(pr, repo, opts) {
|
|
24
|
-
// Require --message when dismissing reviews.
|
|
25
4
|
if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
|
|
26
5
|
throw new Error("--message is required when dismissing reviews");
|
|
27
6
|
}
|
|
28
|
-
// Safety check: verify the push landed before resolving.
|
|
29
7
|
if (opts.requireSha) {
|
|
8
|
+
// Verify GitHub received the commit before resolving — prevents auto-merge
|
|
9
|
+
// before reviewers see the fix.
|
|
30
10
|
await waitForSha(pr, repo, opts.requireSha);
|
|
31
11
|
}
|
|
32
12
|
const result = {
|
|
@@ -35,55 +15,87 @@ export async function applyResolveOptions(pr, repo, opts) {
|
|
|
35
15
|
dismissedReviews: [],
|
|
36
16
|
errors: [],
|
|
37
17
|
};
|
|
38
|
-
await
|
|
39
|
-
await runBatched(opts.minimizeCommentIds ?? [], (id) => minimizeComment(id, "RESOLVED"), result.minimizedComments, result.errors);
|
|
40
|
-
await runBatched(opts.dismissReviewIds ?? [], (id) => dismissReview(id, opts.dismissMessage ?? ""), result.dismissedReviews, result.errors);
|
|
18
|
+
await bulkApply(opts.resolveThreadIds ?? [], opts.minimizeCommentIds ?? [], opts.dismissReviewIds ?? [], opts.dismissMessage ?? "", result);
|
|
41
19
|
return result;
|
|
42
20
|
}
|
|
43
|
-
/**
|
|
44
|
-
* Auto-resolve a batch of outdated threads via the resolveReviewThread mutation.
|
|
45
|
-
*/
|
|
46
21
|
export async function autoResolveOutdated(threadIds) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
async function resolveThread(threadId) {
|
|
56
|
-
await graphql(RESOLVE_THREAD_MUTATION, { threadId });
|
|
57
|
-
}
|
|
58
|
-
async function minimizeComment(commentId, classifier) {
|
|
59
|
-
await graphql(MINIMIZE_COMMENT_MUTATION, { commentId, classifier });
|
|
22
|
+
const result = {
|
|
23
|
+
resolvedThreads: [],
|
|
24
|
+
minimizedComments: [],
|
|
25
|
+
dismissedReviews: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
};
|
|
28
|
+
await bulkApply(threadIds, [], [], "", result);
|
|
29
|
+
return { resolved: result.resolvedThreads, errors: result.errors };
|
|
60
30
|
}
|
|
61
|
-
|
|
62
|
-
|
|
31
|
+
// Chunk at 50 so a single oversized list never fails the entire call.
|
|
32
|
+
const BULK_CHUNK_SIZE = 50;
|
|
33
|
+
function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage) {
|
|
34
|
+
const ops = [];
|
|
35
|
+
for (let i = 0; i < resolveIds.length; i++) {
|
|
36
|
+
ops.push(` r${i}: resolveReviewThread(input: { threadId: ${JSON.stringify(resolveIds[i])} }) { thread { isResolved } }`);
|
|
37
|
+
}
|
|
38
|
+
for (let i = 0; i < minimizeIds.length; i++) {
|
|
39
|
+
ops.push(` m${i}: minimizeComment(input: { subjectId: ${JSON.stringify(minimizeIds[i])}, classifier: RESOLVED }) { minimizedComment { isMinimized } }`);
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i < dismissIds.length; i++) {
|
|
42
|
+
ops.push(` d${i}: dismissPullRequestReview(input: { pullRequestReviewId: ${JSON.stringify(dismissIds[i])}, message: ${JSON.stringify(dismissMessage)} }) { pullRequestReview { state } }`);
|
|
43
|
+
}
|
|
44
|
+
return `mutation BulkApply {\n${ops.join("\n")}\n}`;
|
|
63
45
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
for (let i = 0; i <
|
|
71
|
-
const chunk =
|
|
46
|
+
async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
|
|
47
|
+
const allOps = [
|
|
48
|
+
...resolveIds.map((id) => ({ kind: "r", id })),
|
|
49
|
+
...minimizeIds.map((id) => ({ kind: "m", id })),
|
|
50
|
+
...dismissIds.map((id) => ({ kind: "d", id })),
|
|
51
|
+
];
|
|
52
|
+
for (let i = 0; i < allOps.length; i += BULK_CHUNK_SIZE) {
|
|
53
|
+
const chunk = allOps.slice(i, i + BULK_CHUNK_SIZE);
|
|
72
54
|
// eslint-disable-next-line no-await-in-loop
|
|
73
|
-
await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
55
|
+
await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
|
|
59
|
+
if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
|
|
62
|
+
let data;
|
|
63
|
+
try {
|
|
64
|
+
const resp = await graphql(doc, {});
|
|
65
|
+
data = resp.data;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
69
|
+
for (const id of resolveIds)
|
|
70
|
+
result.errors.push(`${id}: ${msg}`);
|
|
71
|
+
for (const id of minimizeIds)
|
|
72
|
+
result.errors.push(`${id}: ${msg}`);
|
|
73
|
+
for (const id of dismissIds)
|
|
74
|
+
result.errors.push(`${id}: ${msg}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (let i = 0; i < resolveIds.length; i++) {
|
|
78
|
+
const r = data[`r${i}`];
|
|
79
|
+
if (r?.thread?.isResolved === true)
|
|
80
|
+
result.resolvedThreads.push(resolveIds[i]);
|
|
81
|
+
else
|
|
82
|
+
result.errors.push(`${resolveIds[i]}: resolve returned null or thread not resolved`);
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < minimizeIds.length; i++) {
|
|
85
|
+
const m = data[`m${i}`];
|
|
86
|
+
if (m?.minimizedComment?.isMinimized === true)
|
|
87
|
+
result.minimizedComments.push(minimizeIds[i]);
|
|
88
|
+
else
|
|
89
|
+
result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
|
|
90
|
+
}
|
|
91
|
+
for (let i = 0; i < dismissIds.length; i++) {
|
|
92
|
+
const d = data[`d${i}`];
|
|
93
|
+
if (d?.pullRequestReview != null)
|
|
94
|
+
result.dismissedReviews.push(dismissIds[i]);
|
|
95
|
+
else
|
|
96
|
+
result.errors.push(`${dismissIds[i]}: dismiss returned null`);
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// SHA polling
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
99
|
async function waitForSha(pr, repo, expectedSha) {
|
|
88
100
|
const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
|
|
89
101
|
for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
|
|
@@ -94,7 +106,6 @@ async function waitForSha(pr, repo, expectedSha) {
|
|
|
94
106
|
return;
|
|
95
107
|
}
|
|
96
108
|
catch (err) {
|
|
97
|
-
// Transient network / 5xx error — keep polling unless this is the last attempt.
|
|
98
109
|
if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
|
|
99
110
|
throw err;
|
|
100
111
|
}
|
|
@@ -103,7 +114,6 @@ async function waitForSha(pr, repo, expectedSha) {
|
|
|
103
114
|
await sleep(SHA_POLL_INTERVAL_MS);
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
|
-
// Total actual wait = (SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS (no sleep after last poll).
|
|
107
117
|
throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
|
|
108
118
|
}
|
|
109
119
|
function sleep(ms) {
|
package/bin/config.json
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"maxTurns": 50
|
|
13
13
|
},
|
|
14
14
|
"resolve": {
|
|
15
|
-
"concurrency": 4,
|
|
16
15
|
"shaPoll": {
|
|
17
16
|
"intervalMs": 2000,
|
|
18
17
|
"maxAttempts": 10
|
|
@@ -21,7 +20,8 @@
|
|
|
21
20
|
},
|
|
22
21
|
"checks": {
|
|
23
22
|
"ciTriggerEvents": ["pull_request", "pull_request_target"],
|
|
24
|
-
"logTailLines":
|
|
23
|
+
"logTailLines": 5,
|
|
24
|
+
"logTailChars": 200
|
|
25
25
|
},
|
|
26
26
|
"mergeStatus": {
|
|
27
27
|
"blockingReviewerLogins": ["copilot"]
|
|
@@ -96,6 +96,8 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
96
96
|
mergeStateStatus: raw.mergeStateStatus,
|
|
97
97
|
reviewDecision: (raw.reviewDecision ?? null),
|
|
98
98
|
headRefOid: raw.headRefOid,
|
|
99
|
+
headRefName: raw.headRefName,
|
|
100
|
+
headRepoWithOwner: raw.headRepository?.nameWithOwner ?? null,
|
|
99
101
|
baseRefName: raw.baseRefName,
|
|
100
102
|
reviewRequests,
|
|
101
103
|
latestReviews,
|
package/bin/github/client.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { execFile as execFileCb } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { graphql as httpGraphql, rest } from "./http.mjs";
|
|
11
|
-
import { PR_NUMBER_BY_BRANCH_QUERY } from "./queries.mjs";
|
|
11
|
+
import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
|
|
12
12
|
const execFile = promisify(execFileCb);
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
// GraphQL — thin re-exports so callers don't need to import http.mts directly
|
|
@@ -42,38 +42,17 @@ export async function getCurrentPrNumber() {
|
|
|
42
42
|
}
|
|
43
43
|
/** Returns the `headRefOid` (commit SHA) of the given PR as reported by GitHub. */
|
|
44
44
|
export async function getPrHeadSha(pr, owner, name) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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"}).`);
|
|
45
|
+
const result = await httpGraphql(GET_PR_HEAD_SHA_QUERY, { owner, repo: name, pr });
|
|
46
|
+
const sha = result.data.repository?.pullRequest?.headRefOid;
|
|
47
|
+
if (!sha) {
|
|
48
|
+
const detail = !result.data.repository
|
|
49
|
+
? "repository not found or access denied"
|
|
50
|
+
: !result.data.repository.pullRequest
|
|
51
|
+
? "PR not found or access denied"
|
|
52
|
+
: "headRefOid missing";
|
|
53
|
+
throw new Error(`Could not resolve head SHA for ${owner}/${name} PR #${pr}: ${detail}`);
|
|
71
54
|
}
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
// Encode every path segment but preserve the slashes between them.
|
|
75
|
-
function encodePathForApi(path) {
|
|
76
|
-
return path.split("/").map(encodeURIComponent).join("/");
|
|
55
|
+
return sha;
|
|
77
56
|
}
|
|
78
57
|
/**
|
|
79
58
|
* Fetches `mergeable` and `mergeStateStatus` via the REST API.
|
package/bin/github/http.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { execFile as execFileCb } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { appendEntry, nextEntry } from "../log/log-file.mjs";
|
|
4
|
+
import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
|
|
3
5
|
const execFile = promisify(execFileCb);
|
|
4
6
|
const BASE_URL = "https://api.github.com";
|
|
5
7
|
// ---------------------------------------------------------------------------
|
|
@@ -42,33 +44,87 @@ async function makeHeaders() {
|
|
|
42
44
|
function sanitizeBody(body) {
|
|
43
45
|
return body.replace(/Bearer\s+\S+/gi, "[REDACTED]").slice(0, 200);
|
|
44
46
|
}
|
|
45
|
-
|
|
47
|
+
function redactToken(body) {
|
|
48
|
+
return body.replace(/Bearer\s+\S+/gi, "[REDACTED]");
|
|
49
|
+
}
|
|
50
|
+
function redactUrl(url) {
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(url);
|
|
53
|
+
return `${u.origin}${u.pathname}`;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function requestWithTokenRetry(fn, t0, onIntermediate) {
|
|
46
60
|
const res = await fn();
|
|
47
61
|
if (res.status === 401 && _token !== undefined) {
|
|
62
|
+
onIntermediate?.(401, Math.round(performance.now() - t0));
|
|
48
63
|
try {
|
|
49
64
|
await res.arrayBuffer();
|
|
50
65
|
}
|
|
51
66
|
catch { }
|
|
52
67
|
_token = undefined;
|
|
53
|
-
|
|
68
|
+
const retryT0 = performance.now();
|
|
69
|
+
return { res: await fn(), attempt: 2, retryT0 };
|
|
54
70
|
}
|
|
55
|
-
return res;
|
|
71
|
+
return { res, attempt: 1, retryT0: t0 };
|
|
56
72
|
}
|
|
57
73
|
// ---------------------------------------------------------------------------
|
|
58
74
|
// GraphQL
|
|
59
75
|
// ---------------------------------------------------------------------------
|
|
60
76
|
async function graphqlInner(query, vars) {
|
|
61
|
-
const
|
|
77
|
+
const url = `${BASE_URL}/graphql`;
|
|
78
|
+
const n = nextEntry();
|
|
79
|
+
appendEntry(formatRequestEntry({
|
|
80
|
+
n,
|
|
81
|
+
kind: "GraphQL",
|
|
82
|
+
method: "POST",
|
|
83
|
+
url,
|
|
84
|
+
body: { query, variables: vars },
|
|
85
|
+
}));
|
|
86
|
+
const t0 = performance.now();
|
|
87
|
+
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
|
|
62
88
|
method: "POST",
|
|
63
89
|
headers: await makeHeaders(),
|
|
64
90
|
body: JSON.stringify({ query, variables: vars }),
|
|
65
|
-
}))
|
|
91
|
+
}), t0, (status, firstDurationMs) => {
|
|
92
|
+
appendEntry(formatResponseEntry({
|
|
93
|
+
n,
|
|
94
|
+
kind: "GraphQL",
|
|
95
|
+
method: "POST",
|
|
96
|
+
url,
|
|
97
|
+
status,
|
|
98
|
+
durationMs: firstDurationMs,
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
const durationMs = Math.round(performance.now() - retryT0);
|
|
66
102
|
const rateLimit = parseRateLimit(res.headers);
|
|
67
103
|
if (!res.ok) {
|
|
68
104
|
const body = await res.text();
|
|
105
|
+
appendEntry(formatResponseEntry({
|
|
106
|
+
n,
|
|
107
|
+
kind: "GraphQL",
|
|
108
|
+
method: "POST",
|
|
109
|
+
url,
|
|
110
|
+
status: res.status,
|
|
111
|
+
durationMs,
|
|
112
|
+
textBody: redactToken(body),
|
|
113
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
114
|
+
}));
|
|
69
115
|
throw new Error(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`);
|
|
70
116
|
}
|
|
71
117
|
const parsed = (await res.json());
|
|
118
|
+
appendEntry(formatResponseEntry({
|
|
119
|
+
n,
|
|
120
|
+
kind: "GraphQL",
|
|
121
|
+
method: "POST",
|
|
122
|
+
url,
|
|
123
|
+
status: res.status,
|
|
124
|
+
durationMs,
|
|
125
|
+
body: parsed,
|
|
126
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
127
|
+
}));
|
|
72
128
|
if (parsed.data == null) {
|
|
73
129
|
const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
|
|
74
130
|
throw new Error(`GitHub GraphQL error (no data): ${messages}`);
|
|
@@ -91,31 +147,109 @@ export async function graphqlWithRateLimit(query, vars = {}) {
|
|
|
91
147
|
// REST
|
|
92
148
|
// ---------------------------------------------------------------------------
|
|
93
149
|
export async function rest(method, path, body) {
|
|
94
|
-
const
|
|
150
|
+
const url = `${BASE_URL}${path}`;
|
|
151
|
+
const n = nextEntry();
|
|
152
|
+
appendEntry(formatRequestEntry({ n, kind: "REST", method, url, body }));
|
|
153
|
+
const t0 = performance.now();
|
|
154
|
+
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
|
|
95
155
|
method,
|
|
96
156
|
headers: await makeHeaders(),
|
|
97
157
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
98
|
-
}))
|
|
158
|
+
}), t0, (status, firstDurationMs) => {
|
|
159
|
+
appendEntry(formatResponseEntry({ n, kind: "REST", method, url, status, durationMs: firstDurationMs }));
|
|
160
|
+
});
|
|
161
|
+
const durationMs = Math.round(performance.now() - retryT0);
|
|
162
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
99
163
|
if (!res.ok) {
|
|
100
164
|
const text = await res.text();
|
|
165
|
+
appendEntry(formatResponseEntry({
|
|
166
|
+
n,
|
|
167
|
+
kind: "REST",
|
|
168
|
+
method,
|
|
169
|
+
url,
|
|
170
|
+
status: res.status,
|
|
171
|
+
durationMs,
|
|
172
|
+
textBody: redactToken(text),
|
|
173
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
174
|
+
}));
|
|
101
175
|
throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`);
|
|
102
176
|
}
|
|
103
|
-
const ct = res.headers.get("content-type") ?? "";
|
|
104
177
|
if (ct.includes("application/json")) {
|
|
105
|
-
|
|
178
|
+
const json = (await res.json());
|
|
179
|
+
appendEntry(formatResponseEntry({
|
|
180
|
+
n,
|
|
181
|
+
kind: "REST",
|
|
182
|
+
method,
|
|
183
|
+
url,
|
|
184
|
+
status: res.status,
|
|
185
|
+
durationMs,
|
|
186
|
+
contentType: ct,
|
|
187
|
+
body: json,
|
|
188
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
189
|
+
}));
|
|
190
|
+
return json;
|
|
106
191
|
}
|
|
192
|
+
appendEntry(formatResponseEntry({
|
|
193
|
+
n,
|
|
194
|
+
kind: "REST",
|
|
195
|
+
method,
|
|
196
|
+
url,
|
|
197
|
+
status: res.status,
|
|
198
|
+
durationMs,
|
|
199
|
+
contentType: ct || undefined,
|
|
200
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
201
|
+
}));
|
|
107
202
|
return undefined;
|
|
108
203
|
}
|
|
109
204
|
export async function restText(path) {
|
|
110
|
-
const
|
|
205
|
+
const url = `${BASE_URL}${path}`;
|
|
206
|
+
const n = nextEntry();
|
|
207
|
+
appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
|
|
208
|
+
const t0 = performance.now();
|
|
209
|
+
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
|
|
111
210
|
method: "GET",
|
|
112
211
|
headers: await makeHeaders(),
|
|
113
212
|
redirect: "manual",
|
|
114
|
-
}))
|
|
213
|
+
}), t0, (status, firstDurationMs) => {
|
|
214
|
+
appendEntry(formatResponseEntry({
|
|
215
|
+
n,
|
|
216
|
+
kind: "restText",
|
|
217
|
+
method: "GET",
|
|
218
|
+
url,
|
|
219
|
+
status,
|
|
220
|
+
durationMs: firstDurationMs,
|
|
221
|
+
}));
|
|
222
|
+
});
|
|
223
|
+
const durationMs = Math.round(performance.now() - retryT0);
|
|
115
224
|
if (res.status === 301 || res.status === 302 || res.status === 307 || res.status === 308) {
|
|
225
|
+
appendEntry(formatResponseEntry({
|
|
226
|
+
n,
|
|
227
|
+
kind: "restText",
|
|
228
|
+
method: "GET",
|
|
229
|
+
url,
|
|
230
|
+
status: res.status,
|
|
231
|
+
durationMs,
|
|
232
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
233
|
+
}));
|
|
116
234
|
const location = res.headers.get("location");
|
|
117
235
|
if (location) {
|
|
236
|
+
const n2 = nextEntry();
|
|
237
|
+
const logUrl = redactUrl(location);
|
|
238
|
+
appendEntry(formatRequestEntry({ n: n2, kind: "restText", method: "GET", url: logUrl }));
|
|
239
|
+
const t1 = performance.now();
|
|
118
240
|
const redirectRes = await fetch(location);
|
|
241
|
+
const duration2 = Math.round(performance.now() - t1);
|
|
242
|
+
const clRaw = redirectRes.headers.get("content-length");
|
|
243
|
+
const contentLength = clRaw !== null && Number.isFinite(Number(clRaw)) ? Number(clRaw) : undefined;
|
|
244
|
+
appendEntry(formatResponseEntry({
|
|
245
|
+
n: n2,
|
|
246
|
+
kind: "restText",
|
|
247
|
+
method: "GET",
|
|
248
|
+
url: logUrl,
|
|
249
|
+
status: redirectRes.status,
|
|
250
|
+
durationMs: duration2,
|
|
251
|
+
contentLength,
|
|
252
|
+
}));
|
|
119
253
|
if (!redirectRes.ok) {
|
|
120
254
|
throw new Error(`redirect target ${location} failed: ${redirectRes.status}`);
|
|
121
255
|
}
|
|
@@ -124,8 +258,29 @@ export async function restText(path) {
|
|
|
124
258
|
}
|
|
125
259
|
if (!res.ok) {
|
|
126
260
|
const text = await res.text();
|
|
261
|
+
appendEntry(formatResponseEntry({
|
|
262
|
+
n,
|
|
263
|
+
kind: "restText",
|
|
264
|
+
method: "GET",
|
|
265
|
+
url,
|
|
266
|
+
status: res.status,
|
|
267
|
+
durationMs,
|
|
268
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
269
|
+
}));
|
|
127
270
|
throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`);
|
|
128
271
|
}
|
|
272
|
+
const clRaw = res.headers.get("content-length");
|
|
273
|
+
const contentLength = clRaw !== null && Number.isFinite(Number(clRaw)) ? Number(clRaw) : undefined;
|
|
274
|
+
appendEntry(formatResponseEntry({
|
|
275
|
+
n,
|
|
276
|
+
kind: "restText",
|
|
277
|
+
method: "GET",
|
|
278
|
+
url,
|
|
279
|
+
status: res.status,
|
|
280
|
+
durationMs,
|
|
281
|
+
contentLength,
|
|
282
|
+
attempt: attempt > 1 ? attempt : undefined,
|
|
283
|
+
}));
|
|
129
284
|
return res.text();
|
|
130
285
|
}
|
|
131
286
|
// ---------------------------------------------------------------------------
|
package/bin/github/queries.mjs
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GraphQL query strings used by pr-shepherd.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Static query strings live in src/github/gql/*.gql and are loaded here.
|
|
5
|
+
* Dynamic documents whose content varies per call (e.g. BulkApply in
|
|
6
|
+
* src/comments/resolve.mts) are built at runtime and are exempt — they
|
|
7
|
+
* cannot be expressed as static files.
|
|
6
8
|
*/
|
|
7
9
|
import { readFileSync } from "node:fs";
|
|
8
10
|
import { join } from "node:path";
|
|
9
11
|
const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
|
|
10
12
|
/** The primary batch query that fetches CI + comments + merge status in one round-trip. */
|
|
11
13
|
export const BATCH_PR_QUERY = gql("batch-pr.gql");
|
|
12
|
-
/**
|
|
13
|
-
export const
|
|
14
|
-
/**
|
|
15
|
-
export const MINIMIZE_COMMENT_MUTATION = gql("minimize-comment.gql");
|
|
16
|
-
/** Dismiss a pull request review. */
|
|
17
|
-
export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
|
|
18
|
-
/** Multi-PR status query for `shepherd status PR1 PR2 …`. */
|
|
19
|
-
export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
|
|
20
|
-
/** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
|
|
14
|
+
/** Returns the current head commit SHA for a PR. Used by waitForSha polling. */
|
|
15
|
+
export const GET_PR_HEAD_SHA_QUERY = gql("get-pr-head-sha.gql");
|
|
16
|
+
/** Paginated follow-up for `shepherd status` — used when reviewThreads is truncated (totalCount > 100). */
|
|
21
17
|
export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
|
|
22
18
|
/** Look up PR number by branch name (for getCurrentPrNumber). */
|
|
23
19
|
export const PR_NUMBER_BY_BRANCH_QUERY = gql("pr-number-by-branch.gql");
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only per-worktree markdown log.
|
|
3
|
+
*
|
|
4
|
+
* Log path: $PR_SHEPHERD_STATE_DIR/<owner>-<repo>/worktrees/<basename>-<sha8>.md
|
|
5
|
+
*
|
|
6
|
+
* Always-on by default. Set PR_SHEPHERD_LOG_DISABLED=1 or CI=true to disable.
|
|
7
|
+
* Write failures flip an internal disabled flag so the CLI never crashes because
|
|
8
|
+
* logging failed.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { resolveStateBase } from "../state/base.mjs";
|
|
13
|
+
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
14
|
+
import { getWorktreeKey } from "../util/worktree.mjs";
|
|
15
|
+
function computeDisabled() {
|
|
16
|
+
if (process.env["PR_SHEPHERD_LOG_DISABLED"] === "1")
|
|
17
|
+
return true;
|
|
18
|
+
const ci = process.env["CI"];
|
|
19
|
+
return ci !== undefined && ci !== "" && ci !== "0" && ci !== "false";
|
|
20
|
+
}
|
|
21
|
+
let _disabled = computeDisabled();
|
|
22
|
+
let _logPath = null;
|
|
23
|
+
let _entryCounter = 0;
|
|
24
|
+
/** Returns the next monotonically-increasing entry number for the current session. */
|
|
25
|
+
export function nextEntry() {
|
|
26
|
+
return ++_entryCounter;
|
|
27
|
+
}
|
|
28
|
+
export function getLogFilePath(key) {
|
|
29
|
+
const { owner, repo } = key;
|
|
30
|
+
if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo)) {
|
|
31
|
+
throw new Error(`Invalid repo key segments: ${owner}/${repo}`);
|
|
32
|
+
}
|
|
33
|
+
const base = resolveStateBase();
|
|
34
|
+
// Worktree key injected at init time; fall back to "unknown" if not yet set.
|
|
35
|
+
const wkey = _worktreeKey ?? "unknown";
|
|
36
|
+
return join(base, `${owner}-${repo}`, "worktrees", `${wkey}.md`);
|
|
37
|
+
}
|
|
38
|
+
let _worktreeKey = null;
|
|
39
|
+
/**
|
|
40
|
+
* Initialize the log for this process. Must be called before appendEntry().
|
|
41
|
+
* Silently disables logging on any error (no git repo, bad repo name, etc.).
|
|
42
|
+
*/
|
|
43
|
+
export async function initLog(repoKey) {
|
|
44
|
+
if (_disabled)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
const { owner, repo } = repoKey;
|
|
48
|
+
if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo))
|
|
49
|
+
return null;
|
|
50
|
+
_worktreeKey = await getWorktreeKey();
|
|
51
|
+
const path = getLogFilePath(repoKey);
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
_logPath = path;
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
_disabled = true;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Append a pre-formatted markdown chunk to the log. No-op if disabled. */
|
|
62
|
+
export function appendEntry(markdown) {
|
|
63
|
+
if (_disabled || _logPath === null)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
appendFileSync(_logPath, markdown);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
process.stderr.write(`pr-shepherd: log write failed (disabling log): ${String(e)}\n`);
|
|
70
|
+
_disabled = true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Resolve the log path without initializing (for the log-file subcommand). */
|
|
74
|
+
export async function resolveLogPath(repoKey) {
|
|
75
|
+
if (!SAFE_SEGMENT.test(repoKey.owner) || !SAFE_SEGMENT.test(repoKey.repo)) {
|
|
76
|
+
throw new Error(`Invalid repo key segments: ${repoKey.owner}/${repoKey.repo}`);
|
|
77
|
+
}
|
|
78
|
+
const wkey = await getWorktreeKey();
|
|
79
|
+
const base = resolveStateBase();
|
|
80
|
+
return join(base, `${repoKey.owner}-${repoKey.repo}`, "worktrees", `${wkey}.md`);
|
|
81
|
+
}
|
|
82
|
+
/** Exposed for tests to reset module state. */
|
|
83
|
+
export function _resetLogState() {
|
|
84
|
+
_disabled = computeDisabled();
|
|
85
|
+
_logPath = null;
|
|
86
|
+
_worktreeKey = null;
|
|
87
|
+
_entryCounter = 0;
|
|
88
|
+
}
|