pr-shepherd 0.2.0 → 0.4.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/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +8 -2
- package/README.md +128 -83
- package/bin/cache/file-cache.mjs +79 -0
- package/bin/cache/fix-attempts.mjs +67 -0
- package/bin/checks/classify.mjs +53 -0
- package/bin/checks/triage.mjs +77 -0
- package/bin/cli/args.mjs +173 -0
- package/bin/cli.mjs +204 -0
- package/bin/commands/check.mjs +140 -0
- package/bin/commands/iterate.mjs +301 -0
- package/bin/commands/ready-delay.mjs +87 -0
- package/bin/commands/resolve.mjs +64 -0
- package/bin/commands/status.mjs +107 -0
- package/{src/comments/outdated.mts → bin/comments/outdated.mjs} +2 -5
- package/bin/comments/resolve.mjs +111 -0
- package/bin/config/load.mjs +158 -0
- package/bin/github/batch.mjs +208 -0
- package/bin/github/client.mjs +152 -0
- package/{src/github/pagination.mts → bin/github/pagination.mjs} +26 -52
- package/{src/github/queries.mts → bin/github/queries.mjs} +1 -10
- package/{src/index.mts → bin/index.mjs} +3 -5
- package/bin/merge-status/derive.mjs +72 -0
- package/bin/pr-shepherd +2 -0
- package/bin/reporters/agent.mjs +41 -0
- package/{src/reporters/json.mts → bin/reporters/json.mjs} +2 -5
- package/bin/reporters/text.mjs +111 -0
- package/bin/types.mjs +2 -0
- package/package.json +9 -9
- package/skills/check/SKILL.md +12 -14
- package/skills/monitor/SKILL.md +9 -5
- package/src/cache/file-cache.mts +0 -101
- package/src/cache/file-cache.test.mts +0 -91
- package/src/cache/fix-attempts.mts +0 -86
- package/src/checks/classify.mts +0 -80
- package/src/checks/classify.test.mts +0 -164
- package/src/checks/triage.mock.test.mts +0 -202
- package/src/checks/triage.mts +0 -88
- package/src/cli.mts +0 -423
- package/src/commands/check.mts +0 -188
- package/src/commands/iterate.mock.test.mts +0 -1111
- package/src/commands/iterate.mts +0 -371
- package/src/commands/ready-delay.mts +0 -117
- package/src/commands/ready-delay.test.mts +0 -116
- package/src/commands/resolve.mts +0 -92
- package/src/commands/status.mts +0 -173
- package/src/comments/resolve.mts +0 -179
- package/src/config/load.mts +0 -240
- package/src/github/batch.mts +0 -351
- package/src/github/client.mts +0 -207
- package/src/github/client.test.mts +0 -19
- package/src/github/pagination.test.mts +0 -140
- package/src/merge-status/derive.mts +0 -74
- package/src/merge-status/derive.test.mts +0 -130
- package/src/reporters/text.mts +0 -140
- package/src/types.mts +0 -309
- /package/{src → bin}/config.json +0 -0
- /package/{src → bin}/github/gql/batch-pr.gql +0 -0
- /package/{src → bin}/github/gql/dismiss-review.gql +0 -0
- /package/{src → bin}/github/gql/minimize-comment.gql +0 -0
- /package/{src → bin}/github/gql/multi-pr-status-paged.gql +0 -0
- /package/{src → bin}/github/gql/multi-pr-status.gql +0 -0
- /package/{src → bin}/github/gql/resolve-thread.gql +0 -0
- /package/{src/util/path-segment.mts → bin/util/path-segment.mjs} +0 -0
|
@@ -9,19 +9,6 @@
|
|
|
9
9
|
* - Forward (`after` + `first`) — used by check contexts.
|
|
10
10
|
* - Backward (`before` + `last`) — used by reviewThreads (default GitHub order).
|
|
11
11
|
*/
|
|
12
|
-
|
|
13
|
-
export interface PageInfo {
|
|
14
|
-
hasNextPage?: boolean;
|
|
15
|
-
hasPreviousPage?: boolean;
|
|
16
|
-
endCursor?: string | null;
|
|
17
|
-
startCursor?: string | null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface Connection<T> {
|
|
21
|
-
pageInfo: PageInfo;
|
|
22
|
-
nodes: T[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
12
|
/**
|
|
26
13
|
* Paginate forward through a GraphQL connection (`first` / `after` cursors).
|
|
27
14
|
*
|
|
@@ -31,26 +18,19 @@ export interface Connection<T> {
|
|
|
31
18
|
* `endCursor` of an already-fetched page to fetch only
|
|
32
19
|
* the pages *after* it, avoiding a duplicate re-fetch.
|
|
33
20
|
*/
|
|
34
|
-
export async function paginateForward
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
all
|
|
46
|
-
|
|
47
|
-
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) break;
|
|
48
|
-
cursor = conn.pageInfo.endCursor;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return all;
|
|
21
|
+
export async function paginateForward(fetchFn, initialCursor) {
|
|
22
|
+
const all = [];
|
|
23
|
+
let cursor = initialCursor ?? null;
|
|
24
|
+
for (;;) {
|
|
25
|
+
// eslint-disable-next-line no-await-in-loop
|
|
26
|
+
const conn = await fetchFn(cursor);
|
|
27
|
+
all.push(...conn.nodes);
|
|
28
|
+
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor)
|
|
29
|
+
break;
|
|
30
|
+
cursor = conn.pageInfo.endCursor;
|
|
31
|
+
}
|
|
32
|
+
return all;
|
|
52
33
|
}
|
|
53
|
-
|
|
54
34
|
/**
|
|
55
35
|
* Paginate backward through a GraphQL connection (`last` / `before` cursors).
|
|
56
36
|
*
|
|
@@ -63,24 +43,18 @@ export async function paginateForward<T>(
|
|
|
63
43
|
* `startCursor` of an already-fetched page to avoid
|
|
64
44
|
* re-fetching it (fetch only the pages *before* it).
|
|
65
45
|
*/
|
|
66
|
-
export async function paginateBackward
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
all
|
|
80
|
-
|
|
81
|
-
if (!conn.pageInfo.hasPreviousPage || !conn.pageInfo.startCursor) break;
|
|
82
|
-
cursor = conn.pageInfo.startCursor;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return all;
|
|
46
|
+
export async function paginateBackward(fetchFn, initialCursor) {
|
|
47
|
+
const all = [];
|
|
48
|
+
let cursor = initialCursor ?? null;
|
|
49
|
+
for (;;) {
|
|
50
|
+
// eslint-disable-next-line no-await-in-loop
|
|
51
|
+
const conn = await fetchFn(cursor);
|
|
52
|
+
// Backward pagination returns items oldest-first within each page but pages
|
|
53
|
+
// go from newest-to-oldest. Prepend each page so final array is oldest-first.
|
|
54
|
+
all.unshift(...conn.nodes);
|
|
55
|
+
if (!conn.pageInfo.hasPreviousPage || !conn.pageInfo.startCursor)
|
|
56
|
+
break;
|
|
57
|
+
cursor = conn.pageInfo.startCursor;
|
|
58
|
+
}
|
|
59
|
+
return all;
|
|
86
60
|
}
|
|
@@ -4,27 +4,18 @@
|
|
|
4
4
|
* Query strings live in src/github/gql/*.gql.
|
|
5
5
|
* Never inline raw GraphQL strings in .ts source files.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
7
|
import { readFileSync } from "node:fs";
|
|
9
8
|
import { join } from "node:path";
|
|
10
|
-
|
|
11
|
-
const gql = (name: string): string =>
|
|
12
|
-
readFileSync(join((import.meta as { dirname: string }).dirname, "gql", name), "utf8");
|
|
13
|
-
|
|
9
|
+
const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
|
|
14
10
|
/** The primary batch query that fetches CI + comments + merge status in one round-trip. */
|
|
15
11
|
export const BATCH_PR_QUERY = gql("batch-pr.gql");
|
|
16
|
-
|
|
17
12
|
/** Resolve a single review thread. */
|
|
18
13
|
export const RESOLVE_THREAD_MUTATION = gql("resolve-thread.gql");
|
|
19
|
-
|
|
20
14
|
/** Minimize a PR comment (IssueComment). */
|
|
21
15
|
export const MINIMIZE_COMMENT_MUTATION = gql("minimize-comment.gql");
|
|
22
|
-
|
|
23
16
|
/** Dismiss a pull request review. */
|
|
24
17
|
export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
|
|
25
|
-
|
|
26
18
|
/** Multi-PR status query for `shepherd status PR1 PR2 …`. */
|
|
27
19
|
export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
|
|
28
|
-
|
|
29
20
|
/** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
|
|
30
21
|
export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
|
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
* pr-shepherd iterate [PR]
|
|
9
9
|
* pr-shepherd status PR1 [PR2 …]
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
import { main } from "./cli.mts";
|
|
13
|
-
|
|
11
|
+
import { main } from "./cli.mjs";
|
|
14
12
|
main(process.argv).catch((err) => {
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
process.stderr.write(`pr-shepherd error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
14
|
+
process.exit(1);
|
|
17
15
|
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a shepherd `MergeStatusResult` from raw PR data.
|
|
3
|
+
*
|
|
4
|
+
* `pr.state` is passed through unchanged; `iterate` handles the cancel action
|
|
5
|
+
* for non-OPEN (merged/closed) PRs — this function does not branch on it.
|
|
6
|
+
*
|
|
7
|
+
* Interpretation order for `status` — first match wins:
|
|
8
|
+
* 1. mergeable == CONFLICTING → CONFLICTS
|
|
9
|
+
* 2. mergeStateStatus DIRTY → CONFLICTS (GitHub merge conflicts)
|
|
10
|
+
* 3. copilotReviewInProgress → BLOCKED
|
|
11
|
+
* 4. mergeStateStatus BEHIND → BEHIND
|
|
12
|
+
* 5. mergeStateStatus BLOCKED / HAS_HOOKS → BLOCKED
|
|
13
|
+
* 6. mergeStateStatus UNSTABLE → UNSTABLE
|
|
14
|
+
* 7. isDraft → DRAFT
|
|
15
|
+
* 8. mergeStateStatus UNKNOWN → UNKNOWN
|
|
16
|
+
* 9. mergeStateStatus CLEAN → CLEAN
|
|
17
|
+
*/
|
|
18
|
+
import { loadConfig } from "../config/load.mjs";
|
|
19
|
+
export function deriveMergeStatus(pr) {
|
|
20
|
+
const copilotReviewInProgress = detectCopilotReview(pr);
|
|
21
|
+
let status;
|
|
22
|
+
if (pr.mergeable === "CONFLICTING") {
|
|
23
|
+
status = "CONFLICTS";
|
|
24
|
+
}
|
|
25
|
+
else if (copilotReviewInProgress) {
|
|
26
|
+
status = "BLOCKED";
|
|
27
|
+
}
|
|
28
|
+
else if (pr.mergeStateStatus === "DIRTY") {
|
|
29
|
+
// DIRTY means GitHub detected merge conflicts in the branch.
|
|
30
|
+
status = "CONFLICTS";
|
|
31
|
+
}
|
|
32
|
+
else if (pr.mergeStateStatus === "BEHIND") {
|
|
33
|
+
status = "BEHIND";
|
|
34
|
+
}
|
|
35
|
+
else if (pr.mergeStateStatus === "BLOCKED" || pr.mergeStateStatus === "HAS_HOOKS") {
|
|
36
|
+
status = "BLOCKED";
|
|
37
|
+
}
|
|
38
|
+
else if (pr.mergeStateStatus === "UNSTABLE") {
|
|
39
|
+
status = "UNSTABLE";
|
|
40
|
+
}
|
|
41
|
+
else if (pr.isDraft || pr.mergeStateStatus === "DRAFT") {
|
|
42
|
+
status = "DRAFT";
|
|
43
|
+
}
|
|
44
|
+
else if (pr.mergeStateStatus === "UNKNOWN") {
|
|
45
|
+
status = "UNKNOWN";
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
status = "CLEAN";
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
status,
|
|
52
|
+
state: pr.state,
|
|
53
|
+
isDraft: pr.isDraft,
|
|
54
|
+
mergeable: pr.mergeable,
|
|
55
|
+
reviewDecision: pr.reviewDecision,
|
|
56
|
+
copilotReviewInProgress,
|
|
57
|
+
mergeStateStatus: pr.mergeStateStatus,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Copilot review detection
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function detectCopilotReview(pr) {
|
|
64
|
+
// A blocking bot review is "in progress" when:
|
|
65
|
+
// 1. Any reviewRequest has a login starting with one of the configured prefixes, OR
|
|
66
|
+
// 2. Any latestReview has such a login AND state == "PENDING"
|
|
67
|
+
const prefixes = loadConfig().mergeStatus.blockingReviewerLogins.map((l) => l.toLowerCase());
|
|
68
|
+
const isBlocking = (login) => prefixes.some((p) => login.toLowerCase().startsWith(p));
|
|
69
|
+
const requested = pr.reviewRequests.some((r) => isBlocking(r.login));
|
|
70
|
+
const pendingReview = pr.latestReviews.some((r) => isBlocking(r.login) && r.state === "PENDING");
|
|
71
|
+
return requested || pendingReview;
|
|
72
|
+
}
|
package/bin/pr-shepherd
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projections for the agent-facing iterate output.
|
|
3
|
+
*
|
|
4
|
+
* These strip fields that are always-false by the time items reach iterate
|
|
5
|
+
* (isResolved, isOutdated, isMinimized, createdAtUnix) and metadata that the
|
|
6
|
+
* monitor prompt never reads (detailsUrl, event, status, conclusion, category,
|
|
7
|
+
* logExcerpt). The original domain types are preserved for check command output.
|
|
8
|
+
*/
|
|
9
|
+
export function toAgentThread(t) {
|
|
10
|
+
return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body };
|
|
11
|
+
}
|
|
12
|
+
export function toAgentComment(c) {
|
|
13
|
+
return { id: c.id, author: c.author, body: c.body };
|
|
14
|
+
}
|
|
15
|
+
export function toAgentCheck(c) {
|
|
16
|
+
return { name: c.name, runId: c.runId, detailsUrl: c.detailsUrl, failureKind: c.failureKind };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Project and deduplicate checks so the agent makes one `gh run view` call
|
|
20
|
+
* per run (dedup by runId) and skips duplicate external status checks (dedup
|
|
21
|
+
* by name when runId is null).
|
|
22
|
+
*/
|
|
23
|
+
export function toAgentChecks(checks) {
|
|
24
|
+
const seenRunIds = new Set();
|
|
25
|
+
const seenNames = new Set();
|
|
26
|
+
const result = [];
|
|
27
|
+
for (const c of checks) {
|
|
28
|
+
if (c.runId !== null) {
|
|
29
|
+
if (seenRunIds.has(c.runId))
|
|
30
|
+
continue;
|
|
31
|
+
seenRunIds.add(c.runId);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
if (seenNames.has(c.name))
|
|
35
|
+
continue;
|
|
36
|
+
seenNames.add(c.name);
|
|
37
|
+
}
|
|
38
|
+
result.push(toAgentCheck(c));
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -4,9 +4,6 @@
|
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export function formatJson(report: ShepherdReport): string {
|
|
11
|
-
return JSON.stringify(report, null, 2);
|
|
7
|
+
export function formatJson(report) {
|
|
8
|
+
return JSON.stringify(report, null, 2);
|
|
12
9
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable text reporter for shepherd check output.
|
|
3
|
+
*/
|
|
4
|
+
export function formatText(report) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
// Header
|
|
7
|
+
lines.push(`\nPR #${report.pr} — ${report.repo}`);
|
|
8
|
+
lines.push(`Status: ${report.status}`);
|
|
9
|
+
lines.push("");
|
|
10
|
+
// Merge status
|
|
11
|
+
const ms = report.mergeStatus;
|
|
12
|
+
lines.push(`Merge Status: ${ms.status}`);
|
|
13
|
+
lines.push(` mergeStateStatus: ${ms.mergeStateStatus}`);
|
|
14
|
+
lines.push(` mergeable: ${ms.mergeable}`);
|
|
15
|
+
lines.push(` reviewDecision: ${ms.reviewDecision ?? "(none)"}`);
|
|
16
|
+
lines.push(` isDraft: ${ms.isDraft}`);
|
|
17
|
+
lines.push(` copilotReviewInProgress:${ms.copilotReviewInProgress}`);
|
|
18
|
+
lines.push("");
|
|
19
|
+
// CI checks
|
|
20
|
+
const { passing, failing, inProgress, skipped } = report.checks;
|
|
21
|
+
const total = passing.length + failing.length + inProgress.length + skipped.length;
|
|
22
|
+
lines.push(`CI Checks: ${passing.length}/${total} passed`);
|
|
23
|
+
if (failing.length > 0) {
|
|
24
|
+
lines.push(`\nFailed Checks (${failing.length}):`);
|
|
25
|
+
for (const c of failing) {
|
|
26
|
+
const triaged = c;
|
|
27
|
+
const kind = triaged.failureKind ? ` [${triaged.failureKind}]` : "";
|
|
28
|
+
lines.push(` - ${c.name}${kind}: ${c.conclusion ?? c.status}`);
|
|
29
|
+
if (triaged.logExcerpt) {
|
|
30
|
+
lines.push(indent(triaged.logExcerpt.split("\n").slice(-10).join("\n"), " "));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (inProgress.length > 0) {
|
|
35
|
+
lines.push(`\nIn Progress (${inProgress.length}):`);
|
|
36
|
+
for (const c of inProgress) {
|
|
37
|
+
lines.push(` - ${c.name}: ${c.status}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (skipped.length > 0) {
|
|
41
|
+
lines.push(`\nSkipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
if (report.checks.filtered.length > 0) {
|
|
44
|
+
lines.push(`\nFiltered (non-PR-trigger) (${report.checks.filtered.length}): ${report.checks.filtered.map((c) => c.name).join(", ")}`);
|
|
45
|
+
if (report.checks.blockedByFilteredCheck) {
|
|
46
|
+
lines.push(" Note: PR is BLOCKED and all filtered checks are non-PR-trigger — one of these filtered checks may be a required status check blocking merge.");
|
|
47
|
+
}
|
|
48
|
+
else if (report.mergeStatus.status === "BLOCKED") {
|
|
49
|
+
lines.push(" Note: one or more of these filtered checks may be a required status check blocking merge.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
lines.push("");
|
|
53
|
+
// Review threads
|
|
54
|
+
const { actionable: actionableThreads, autoResolved, autoResolveErrors } = report.threads;
|
|
55
|
+
if (autoResolved.length > 0) {
|
|
56
|
+
lines.push(`Auto-resolved outdated threads (${autoResolved.length}):`);
|
|
57
|
+
for (const t of autoResolved) {
|
|
58
|
+
lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
|
|
59
|
+
}
|
|
60
|
+
lines.push("");
|
|
61
|
+
}
|
|
62
|
+
if (autoResolveErrors.length > 0) {
|
|
63
|
+
lines.push(`Auto-resolve errors (${autoResolveErrors.length}):`);
|
|
64
|
+
for (const e of autoResolveErrors) {
|
|
65
|
+
lines.push(` - ${e}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push("");
|
|
68
|
+
}
|
|
69
|
+
if (actionableThreads.length > 0) {
|
|
70
|
+
lines.push(`Actionable Review Threads (${actionableThreads.length}):`);
|
|
71
|
+
for (const t of actionableThreads) {
|
|
72
|
+
const label = t.path ? `${t.path}:${t.line ?? "?"}` : "(general)";
|
|
73
|
+
lines.push(` - threadId=${t.id} ${label} (@${t.author})`);
|
|
74
|
+
lines.push(` ${firstLine(t.body)}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push("");
|
|
77
|
+
}
|
|
78
|
+
// PR comments
|
|
79
|
+
const { actionable: actionableComments } = report.comments;
|
|
80
|
+
if (actionableComments.length > 0) {
|
|
81
|
+
lines.push(`Actionable PR Comments (${actionableComments.length}):`);
|
|
82
|
+
for (const c of actionableComments) {
|
|
83
|
+
lines.push(` - commentId=${c.id} (@${c.author}): ${firstLine(c.body)}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push("");
|
|
86
|
+
}
|
|
87
|
+
// CHANGES_REQUESTED reviews
|
|
88
|
+
if (report.changesRequestedReviews.length > 0) {
|
|
89
|
+
lines.push(`Pending CHANGES_REQUESTED reviews (${report.changesRequestedReviews.length}):`);
|
|
90
|
+
for (const r of report.changesRequestedReviews) {
|
|
91
|
+
lines.push(` - reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
|
|
92
|
+
}
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
// Summary
|
|
96
|
+
const totalActionable = actionableThreads.length + actionableComments.length + report.changesRequestedReviews.length;
|
|
97
|
+
lines.push(`Summary: ${totalActionable === 0 ? "0 actionable — all threads resolved/minimized" : `${totalActionable} actionable item(s) remaining`}`);
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Helpers
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
function firstLine(text) {
|
|
104
|
+
return (text.split("\n")[0] ?? "").trim().slice(0, 120);
|
|
105
|
+
}
|
|
106
|
+
function indent(text, prefix) {
|
|
107
|
+
return text
|
|
108
|
+
.split("\n")
|
|
109
|
+
.map((l) => prefix + l)
|
|
110
|
+
.join("\n");
|
|
111
|
+
}
|
package/bin/types.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"pr-shepherd": "
|
|
9
|
+
"pr-shepherd": "bin/index.mjs"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"
|
|
13
|
-
"src/**/*.json",
|
|
14
|
-
"src/**/*.gql",
|
|
12
|
+
"bin/**",
|
|
15
13
|
"skills/**",
|
|
16
14
|
".claude-plugin/**",
|
|
17
15
|
"marketplace.json",
|
|
@@ -19,21 +17,23 @@
|
|
|
19
17
|
"LICENSE"
|
|
20
18
|
],
|
|
21
19
|
"engines": {
|
|
22
|
-
"node": ">=
|
|
20
|
+
"node": ">=22.0.0"
|
|
23
21
|
},
|
|
24
22
|
"dependencies": {
|
|
25
23
|
"yaml": "^2.7.0"
|
|
26
24
|
},
|
|
27
25
|
"devDependencies": {
|
|
28
26
|
"@types/node": "^25.6.0",
|
|
29
|
-
"oxfmt": "
|
|
30
|
-
"oxlint": "
|
|
27
|
+
"oxfmt": "^0.45.0",
|
|
28
|
+
"oxlint": "^1.60.0",
|
|
31
29
|
"typescript": "^6.0.3",
|
|
32
30
|
"vitest": "^4.1.4",
|
|
33
31
|
"@vitest/coverage-v8": "^4.1.4"
|
|
34
32
|
},
|
|
35
33
|
"scripts": {
|
|
36
|
-
"
|
|
34
|
+
"build": "node scripts/build.mjs",
|
|
35
|
+
"prepare": "npm run build",
|
|
36
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
38
|
"lint": "oxlint src/ skills/",
|
|
39
39
|
"format": "oxfmt src/ skills/ docs/ README.md",
|
package/skills/check/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: check
|
|
|
3
3
|
description: "Check GitHub CI status and review comments for the current PR"
|
|
4
4
|
argument-hint: "[PR number or URL ...]"
|
|
5
5
|
user-invocable: true
|
|
6
|
-
allowed-tools: ["Bash"
|
|
6
|
+
allowed-tools: ["Bash"]
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# pr-shepherd check — PR Status
|
|
@@ -40,24 +40,22 @@ Parse the JSON output and report all three:
|
|
|
40
40
|
|
|
41
41
|
## Rebase policy
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
BASE_BRANCH=$(gh pr view <N> --json baseRefName --jq '.baseRefName')
|
|
45
|
-
```
|
|
43
|
+
The CLI already determines whether a rebase is warranted. Read `report.mergeStatus.status` directly:
|
|
46
44
|
|
|
47
|
-
|
|
45
|
+
- `CONFLICTS` — a rebase is required to resolve the merge conflict before the PR can land.
|
|
46
|
+
- `BEHIND` — a rebase may be appropriate; a `flaky` failure while `BEHIND` is the canonical rebase signal. If all checks pass but the PR is `BEHIND`, a rebase is optional.
|
|
47
|
+
- Any other status — no rebase needed.
|
|
48
48
|
|
|
49
|
-
-
|
|
50
|
-
- About to push commits and branch is behind main, OR
|
|
51
|
-
- `failureKind == 'flaky'` AND branch is behind main
|
|
52
|
-
|
|
53
|
-
Do NOT rebase when nothing to push, no conflicts, and no flaky failures.
|
|
49
|
+
Do not re-derive these conditions from raw branch state. For automated monitoring that acts on these signals, use `/pr-shepherd:monitor` — it handles rebase decisions end-to-end.
|
|
54
50
|
|
|
55
51
|
## CI budget policy
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
53
|
+
Each entry in `report.checks` carries a `failureKind` field. Read it directly rather than re-classifying failures:
|
|
54
|
+
|
|
55
|
+
- `actionable` — the failure is code-level and needs a fix.
|
|
56
|
+
- `infrastructure` — transient infra problem; re-run with `gh run rerun <runId> --failed`.
|
|
57
|
+
- `timeout` — job exceeded the time limit; re-run with `gh run rerun <runId> --failed`.
|
|
58
|
+
- `flaky` — known-flaky test; do NOT cancel. Rebase first if `mergeStatus.status` is `BEHIND`.
|
|
61
59
|
|
|
62
60
|
## Never declare ready to merge
|
|
63
61
|
|
package/skills/monitor/SKILL.md
CHANGED
|
@@ -76,11 +76,15 @@ Parse the `action` field and act:
|
|
|
76
76
|
After fixing manually, rerun /pr-shepherd:monitor <PR> to resume.
|
|
77
77
|
|
|
78
78
|
- `fix_code` → do the following, then stop this iteration (CI needs time):
|
|
79
|
-
1. For each item in `fix.threads
|
|
80
|
-
2.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
1. For each item in `fix.threads` and `fix.comments`: read the referenced file/line and apply the fix (Edit/Write tools).
|
|
80
|
+
2. For each item in `fix.checks`:
|
|
81
|
+
- If `runId` is non-null: fetch the failure log with `gh run view <runId> --log-failed` (dangerouslyDisableSandbox: true), 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).
|
|
82
|
+
- 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.
|
|
83
|
+
3. For each item in `fix.changesRequestedReviews`: read the review body and apply the requested changes.
|
|
84
|
+
4. If files were changed, `git add <files> && git commit -m "<appropriate commit message>"`
|
|
85
|
+
5. `git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease` (dangerouslyDisableSandbox: true)
|
|
86
|
+
6. `HEAD_SHA=$(git rev-parse HEAD)`
|
|
87
|
+
7. `npx pr-shepherd resolve <PR_NUMBER> --resolve-thread-ids <IDs> --minimize-comment-ids <IDs> --dismiss-review-ids <IDs> --message "address review comments" --require-sha "$HEAD_SHA"` (dangerouslyDisableSandbox: true). Omit any flag whose ID list is empty.
|
|
84
88
|
|
|
85
89
|
````
|
|
86
90
|
|
package/src/cache/file-cache.mts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Simple filesystem-based cache for shepherd batch reads.
|
|
3
|
-
*
|
|
4
|
-
* Cache entries live in `${TMPDIR}/pr-shepherd-cache/<owner>-<repo>/<pr>/<shape>.json`.
|
|
5
|
-
* TTL defaults to 5 minutes (configurable via PR_SHEPHERD_CACHE_TTL_SECONDS or --cache-ttl).
|
|
6
|
-
*
|
|
7
|
-
* Mutations are never cached — this module is read-path only.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises";
|
|
11
|
-
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { join, dirname } from "node:path";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { loadConfig } from "../config/load.mts";
|
|
15
|
-
import { SAFE_SEGMENT } from "../util/path-segment.mts";
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Public API
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
export interface CacheOptions {
|
|
22
|
-
ttlSeconds?: number;
|
|
23
|
-
disabled?: boolean;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Read a value from the cache. Returns null on miss or expiry.
|
|
28
|
-
*/
|
|
29
|
-
export async function cacheGet<T>(key: CacheKey, opts: CacheOptions = {}): Promise<T | null> {
|
|
30
|
-
if (opts.disabled) return null;
|
|
31
|
-
|
|
32
|
-
const ttl = opts.ttlSeconds ?? ttlFromEnv() ?? loadConfig().cache.ttlSeconds;
|
|
33
|
-
// A TTL of 0 (or negative) means "always expired" — skip the filesystem read entirely.
|
|
34
|
-
if (ttl <= 0) return null;
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
const path = resolvePath(key);
|
|
38
|
-
const stats = await stat(path);
|
|
39
|
-
const ageSeconds = (Date.now() - stats.mtimeMs) / 1000;
|
|
40
|
-
if (ageSeconds >= ttl) return null;
|
|
41
|
-
|
|
42
|
-
const raw = await readFile(path, "utf8");
|
|
43
|
-
return JSON.parse(raw) as T;
|
|
44
|
-
} catch {
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Write a value to the cache (fire-and-forget — never throws).
|
|
51
|
-
*/
|
|
52
|
-
export async function cacheSet<T>(key: CacheKey, value: T, opts: CacheOptions = {}): Promise<void> {
|
|
53
|
-
if (opts.disabled) return;
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
const path = resolvePath(key);
|
|
57
|
-
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
58
|
-
await mkdir(dirname(path), { recursive: true });
|
|
59
|
-
await writeFile(tmp, JSON.stringify(value), "utf8");
|
|
60
|
-
// Atomic rename — prevents a partial read if two processes write concurrently.
|
|
61
|
-
await rename(tmp, path);
|
|
62
|
-
} catch {
|
|
63
|
-
// Cache writes are best-effort.
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// Cache key
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
export interface CacheKey {
|
|
72
|
-
owner: string;
|
|
73
|
-
repo: string;
|
|
74
|
-
pr: number;
|
|
75
|
-
shape: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// Internal helpers
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
function resolvePath(key: CacheKey): string {
|
|
83
|
-
for (const [field, value] of [
|
|
84
|
-
["owner", key.owner],
|
|
85
|
-
["repo", key.repo],
|
|
86
|
-
["shape", key.shape],
|
|
87
|
-
] as const) {
|
|
88
|
-
if (!SAFE_SEGMENT.test(value)) {
|
|
89
|
-
throw new Error(`Invalid cache key segment "${field}": ${value}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
|
|
93
|
-
return join(base, `${key.owner}-${key.repo}`, String(key.pr), `${key.shape}.json`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function ttlFromEnv(): number | undefined {
|
|
97
|
-
const raw = process.env["PR_SHEPHERD_CACHE_TTL_SECONDS"];
|
|
98
|
-
if (!raw) return undefined;
|
|
99
|
-
const parsed = parseInt(raw, 10);
|
|
100
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
101
|
-
}
|