pr-shepherd 0.25.3 → 0.26.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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +23 -0
  3. package/bin/checks/classify.mjs +10 -2
  4. package/bin/classify/apply.mjs +110 -0
  5. package/bin/classify/loader.mjs +85 -0
  6. package/bin/classify/types.mjs +1 -0
  7. package/bin/cli/args.mjs +1 -0
  8. package/bin/cli/default-poll.mjs +1 -0
  9. package/bin/cli/fix-formatter.mjs +3 -4
  10. package/bin/cli/help-command-pages.mjs +20 -1
  11. package/bin/cli/help-top-page.mjs +3 -0
  12. package/bin/cli/iterate-formatter.mjs +42 -7
  13. package/bin/cli/iterate-instructions.mjs +3 -22
  14. package/bin/cli/iterate-lean.mjs +32 -9
  15. package/bin/cli/journal-handler.mjs +79 -0
  16. package/bin/cli/poll-handler.mjs +1 -0
  17. package/bin/cli-parser.mjs +5 -23
  18. package/bin/commands/check-terminal-report.mjs +1 -0
  19. package/bin/commands/check.mjs +36 -8
  20. package/bin/commands/iterate/classify.mjs +14 -5
  21. package/bin/commands/iterate/fix-code.mjs +2 -2
  22. package/bin/commands/iterate/helpers.mjs +15 -0
  23. package/bin/commands/iterate/index.mjs +8 -2
  24. package/bin/commands/iterate/render.mjs +15 -9
  25. package/bin/commands/iterate/stall.mjs +4 -0
  26. package/bin/commands/journal/index.mjs +26 -0
  27. package/bin/commands/journal/transform.mjs +112 -0
  28. package/bin/commands/poll.mjs +45 -3
  29. package/bin/commands/ready-mergeability.mjs +2 -2
  30. package/bin/commands/shepherd-journal.mjs +2 -2
  31. package/bin/config/load.mjs +7 -0
  32. package/bin/config.json +1 -0
  33. package/bin/github/activity.mjs +57 -0
  34. package/bin/github/batch-parser-helpers.mjs +5 -0
  35. package/bin/github/batch-parsers.mjs +22 -33
  36. package/bin/github/branch-protection.mjs +12 -0
  37. package/bin/github/client.mjs +17 -1
  38. package/bin/github/gql/batch-pr.gql +11 -0
  39. package/bin/github/gql/get-pr-body.gql +8 -0
  40. package/bin/github/gql/update-pr-body.gql +7 -0
  41. package/bin/github/queries.mjs +4 -0
  42. package/bin/types/activity.mjs +1 -0
  43. package/bin/types/iterate.mjs +0 -1
  44. package/bin/types.mjs +1 -0
  45. package/package.json +12 -2
  46. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  47. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +7 -22
  48. package/src/classify/types.mts +48 -0
@@ -10,6 +10,37 @@ function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
10
10
  process.stderr.write(".");
11
11
  }
12
12
  }
13
+ function waitSignature(result) {
14
+ const activity = result.activity ?? {
15
+ commitCount: 0,
16
+ reviewRoundCount: 0,
17
+ latestCommitCommittedAtUnix: null,
18
+ reviewItemsSinceLatestCommit: [],
19
+ };
20
+ return JSON.stringify({
21
+ status: result.status,
22
+ mergeStateStatus: result.mergeStateStatus,
23
+ reviewDecision: result.reviewDecision,
24
+ state: result.state,
25
+ active: (result.inProgressChecks ?? []).map((c) => [c.name, c.status, c.runId]),
26
+ commitCount: activity.commitCount,
27
+ latestCommitCommittedAtUnix: activity.latestCommitCommittedAtUnix,
28
+ reviewRoundCount: activity.reviewRoundCount,
29
+ reviewItemsSinceLatestCommit: activity.reviewItemsSinceLatestCommit.length,
30
+ });
31
+ }
32
+ function writeQuietStatus(tick, elapsedSeconds, sleepSeconds, result) {
33
+ const activeChecks = result.inProgressChecks ?? [];
34
+ const activeCheckText = activeChecks.map((c) => `${c.name} (${c.status})`).join(", ");
35
+ const active = activeChecks.length > 0 ? ` · active: ${activeCheckText}` : "";
36
+ const commitCount = result.activity?.commitCount ?? 0;
37
+ const reviewItems = result.activity?.reviewItemsSinceLatestCommit.length ?? 0;
38
+ const reviewRounds = result.activity?.reviewRoundCount ?? 0;
39
+ const commitSeg = commitCount > 0 ? ` · ${commitCount} commits` : "";
40
+ const reviewRoundSeg = reviewRounds > 0 ? ` · ${reviewRounds} review rounds` : "";
41
+ const reviewSeg = reviewItems > 0 ? ` · ${reviewItems} review items since latest commit` : "";
42
+ process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT ${result.status}/${result.mergeStateStatus}/${result.reviewDecision ?? "NO_REVIEW_DECISION"}${active}${commitSeg}${reviewRoundSeg}${reviewSeg} — sleeping ${sleepSeconds}s\n`);
43
+ }
13
44
  const MAX_TIMER_MS = 2 ** 31 - 1;
14
45
  export async function runPoll(opts) {
15
46
  const { intervalSeconds, timeoutSeconds, ...iterateOpts } = opts;
@@ -19,7 +50,9 @@ export async function runPoll(opts) {
19
50
  let tick = 0;
20
51
  let lastResult;
21
52
  const verbose = opts.verbose === true;
53
+ const quietStatus = opts.quietStatus === true;
22
54
  let dotsPrinted = false;
55
+ let lastWaitSignature = null;
23
56
  while (true) {
24
57
  tick += 1;
25
58
  lastResult = await runIterate(iterateOpts);
@@ -30,9 +63,18 @@ export async function runPoll(opts) {
30
63
  if (remainingMs <= 0)
31
64
  break;
32
65
  const nextSleepMs = Math.min(intervalMs, remainingMs);
33
- writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
34
- if (!verbose)
35
- dotsPrinted = true;
66
+ if (quietStatus) {
67
+ const signature = waitSignature(lastResult);
68
+ if (signature !== lastWaitSignature) {
69
+ writeQuietStatus(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), lastResult);
70
+ }
71
+ lastWaitSignature = signature;
72
+ }
73
+ else {
74
+ writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
75
+ if (!verbose)
76
+ dotsPrinted = true;
77
+ }
36
78
  await sleep(nextSleepMs);
37
79
  }
38
80
  if (dotsPrinted)
@@ -8,10 +8,10 @@ export async function refreshUnknownMergeability(prNumber, repo, batchData) {
8
8
  }
9
9
  return { batchData: await readMergeability(prNumber, repo, batchData), didRefresh: true };
10
10
  }
11
- export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments) {
11
+ export async function refreshReadyMergeability(prNumber, repo, batchData, verdict, unresolvedThreads, unresolvedComments, changesRequestedCount) {
12
12
  const refreshedBatchData = await readMergeability(prNumber, repo, batchData);
13
13
  const mergeStatus = deriveMergeStatus(refreshedBatchData);
14
- const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, refreshedBatchData.changesRequestedReviews.length);
14
+ const status = computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedCount);
15
15
  return { batchData: refreshedBatchData, mergeStatus, status };
16
16
  }
17
17
  export function isBlockedByFilteredCheck(mergeStatus, verdict) {
@@ -4,9 +4,9 @@ export const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, app
4
4
  export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look)` — you are seeing these for the first time. Eligible non-human IDs, when present, are already included in `--minimize-comment-ids` in the resolve or resolve-only command above; if any warrants a Shepherd Journal note, append it before running resolve.";
5
5
  export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
6
6
  return [
7
- `For any large decisions or rejections you made this iteration, add or update a \`${SHEPHERD_JOURNAL_SECTION}\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision.`,
7
+ `For any large decisions or rejections you made this iteration, run \`pr-shepherd journal ${prNumber} '- <decision>'\` to append an entry to the \`${SHEPHERD_JOURNAL_SECTION}\` section.`,
8
8
  itemReferenceGuidance,
9
- SHEPHERD_JOURNAL_APPEND_HINT,
9
+ `The command is idempotent — re-running with the same text is a no-op.`,
10
10
  ].join(" ");
11
11
  }
12
12
  export const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "For threads and comments, use the markdown link shown in its heading above; for reviews, reference the review ID.";
@@ -51,6 +51,12 @@ function parseBotUsernames(value) {
51
51
  }
52
52
  return value;
53
53
  }
54
+ function parseIgnoreChecks(value) {
55
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
56
+ throw new Error(`Invalid config: ignoreChecks must be an array of strings`);
57
+ }
58
+ return value;
59
+ }
54
60
  const defaults = builtins;
55
61
  const configCache = new Map();
56
62
  export function loadConfig() {
@@ -67,6 +73,7 @@ export function loadConfig() {
67
73
  const parsed = (parse(raw) ?? {});
68
74
  const config = deepMerge(defaults, parsed);
69
75
  config.botUsernames = parseBotUsernames(config.botUsernames);
76
+ config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
70
77
  config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
71
78
  configCache.set(cwd, config);
72
79
  return config;
package/bin/config.json CHANGED
@@ -13,6 +13,7 @@
13
13
  "sonarqubecloud",
14
14
  "what-the-diff"
15
15
  ],
16
+ "ignoreChecks": [],
16
17
  "iterate": {
17
18
  "fixAttemptsPerThread": 3,
18
19
  "stallTimeoutMinutes": 60,
@@ -0,0 +1,57 @@
1
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
2
+ function reviewActivityItems(reviews, latestCommitCommittedAtUnix, kind) {
3
+ return reviews
4
+ .filter((r) => (r.createdAtUnix ?? 0) > latestCommitCommittedAtUnix)
5
+ .filter((r) => r.body.trim() !== "")
6
+ .map((r) => ({
7
+ kind,
8
+ id: r.id,
9
+ author: r.author,
10
+ authorType: r.authorType,
11
+ body: r.body,
12
+ createdAtUnix: r.createdAtUnix ?? 0,
13
+ }));
14
+ }
15
+ export function buildPrActivitySummary(raw, comments, reviewThreads, reviewSummaries, changesRequestedReviews, approvedReviews) {
16
+ const latestCommitCommittedAtUnix = raw.commits.nodes[0]?.commit.committedDate
17
+ ? parseCreatedAt(raw.commits.nodes[0].commit.committedDate)
18
+ : null;
19
+ const reviewItemsSinceLatestCommit = latestCommitCommittedAtUnix === null
20
+ ? []
21
+ : [
22
+ ...comments
23
+ .filter((c) => c.createdAtUnix > latestCommitCommittedAtUnix)
24
+ .map((c) => ({
25
+ kind: "pr-comment",
26
+ id: c.id,
27
+ author: c.author,
28
+ authorType: c.authorType,
29
+ body: c.body,
30
+ url: c.url,
31
+ createdAtUnix: c.createdAtUnix,
32
+ })),
33
+ ...reviewThreads.flatMap((t) => (t.comments ?? [])
34
+ .filter((c) => c.createdAtUnix > latestCommitCommittedAtUnix)
35
+ .map((c) => ({
36
+ kind: "review-thread-comment",
37
+ id: c.id,
38
+ author: c.author,
39
+ authorType: c.authorType,
40
+ body: c.body,
41
+ url: c.url,
42
+ createdAtUnix: c.createdAtUnix,
43
+ threadId: t.id,
44
+ path: t.path,
45
+ line: t.line,
46
+ }))),
47
+ ...reviewActivityItems(reviewSummaries, latestCommitCommittedAtUnix, "review-summary"),
48
+ ...reviewActivityItems(changesRequestedReviews, latestCommitCommittedAtUnix, "changes-requested-review"),
49
+ ...reviewActivityItems(approvedReviews, latestCommitCommittedAtUnix, "approved-review"),
50
+ ].sort((a, b) => a.createdAtUnix - b.createdAtUnix);
51
+ return {
52
+ commitCount: raw.commits.totalCount ?? raw.commits.nodes.length,
53
+ reviewRoundCount: raw.allReviews?.totalCount ?? 0,
54
+ latestCommitCommittedAtUnix,
55
+ reviewItemsSinceLatestCommit,
56
+ };
57
+ }
@@ -22,6 +22,11 @@ export function extractCheckRunSummary(title, summary) {
22
22
  ?.trim();
23
23
  return firstLine || undefined;
24
24
  }
25
+ export function latestApprovedLogins(latest) {
26
+ return new Set(latest
27
+ .filter((r) => r.login !== "unknown" && (r.state === "APPROVED" || r.state === "DISMISSED"))
28
+ .map((r) => r.login));
29
+ }
25
30
  export function mapStatusContextState(state) {
26
31
  switch (state) {
27
32
  case "SUCCESS":
@@ -1,4 +1,15 @@
1
- import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, } from "./batch-parser-helpers.mjs";
1
+ import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, latestApprovedLogins, } from "./batch-parser-helpers.mjs";
2
+ import { buildPrActivitySummary } from "./activity.mjs";
3
+ import { parseBranchProtection } from "./branch-protection.mjs";
4
+ function parseReviewNode(r) {
5
+ return {
6
+ id: r.id,
7
+ author: r.author?.login ?? "unknown",
8
+ authorType: mapAuthorType(r.author?.__typename, r.author?.login),
9
+ body: r.body,
10
+ createdAtUnix: r.createdAt ? parseCreatedAt(r.createdAt) : 0,
11
+ };
12
+ }
2
13
  export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes) {
3
14
  const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
4
15
  const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
@@ -8,6 +19,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
8
19
  login: n.author?.login ?? "unknown",
9
20
  state: n.state,
10
21
  }));
22
+ const crDone = latestApprovedLogins(latestReviews);
11
23
  const reviewThreads = rawThreadPages.map((t) => {
12
24
  const comment = t.comments.nodes[0];
13
25
  const comments = t.comments.nodes.map((c) => ({
@@ -18,7 +30,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
18
30
  authorType: mapAuthorType(c.author?.__typename, c.author?.login),
19
31
  body: c.body,
20
32
  url: c.url,
21
- createdAtUnix: parseCreatedAt(c.createdAt),
33
+ createdAtUnix: c.createdAt ? parseCreatedAt(c.createdAt) : 0,
22
34
  }));
23
35
  return {
24
36
  id: t.id,
@@ -33,7 +45,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
33
45
  authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
34
46
  body: comment?.body ?? "",
35
47
  url: comment?.url ?? "",
36
- createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
48
+ createdAtUnix: comment?.createdAt ? parseCreatedAt(comment.createdAt) : 0,
37
49
  comments,
38
50
  };
39
51
  });
@@ -44,33 +56,19 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
44
56
  authorType: mapAuthorType(c.author?.__typename, c.author?.login),
45
57
  body: c.body,
46
58
  url: c.url,
47
- createdAtUnix: parseCreatedAt(c.createdAt),
48
- }));
49
- const changesRequestedReviews = rawReviewNodes.map((r) => ({
50
- id: r.id,
51
- author: r.author?.login ?? "unknown",
52
- authorType: mapAuthorType(r.author?.__typename, r.author?.login),
53
- body: r.body,
59
+ createdAtUnix: c.createdAt ? parseCreatedAt(c.createdAt) : 0,
54
60
  }));
61
+ const allChangesRequestedReviews = rawReviewNodes.map((r) => parseReviewNode(r));
62
+ const changesRequestedReviews = allChangesRequestedReviews.filter((r) => !crDone.has(r.author));
55
63
  const reviewSummaries = rawReviewSummaryNodes
56
64
  .filter((r) => !r.isMinimized && r.body.trim() !== "")
57
- .map((r) => ({
58
- id: r.id,
59
- author: r.author?.login ?? "unknown",
60
- authorType: mapAuthorType(r.author?.__typename, r.author?.login),
61
- body: r.body,
62
- }));
65
+ .map((r) => parseReviewNode(r));
63
66
  // APPROVED reviews often have empty bodies (clicking "Approve" without a comment), so
64
67
  // we keep them — only the isMinimized filter applies. Monitor/iterate uses these IDs
65
68
  // when the user opts in to minimizing approvals.
66
69
  const approvedReviews = rawApprovedReviewNodes
67
70
  .filter((r) => !r.isMinimized)
68
- .map((r) => ({
69
- id: r.id,
70
- author: r.author?.login ?? "unknown",
71
- authorType: mapAuthorType(r.author?.__typename, r.author?.login),
72
- body: r.body,
73
- }));
71
+ .map((r) => parseReviewNode(r));
74
72
  const checks = rawCheckNodes.flatMap((node) => {
75
73
  if (node.__typename === "CheckRun") {
76
74
  const event = node.checkSuite?.workflowRun?.event ?? null;
@@ -122,16 +120,6 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
122
120
  }
123
121
  return [];
124
122
  });
125
- const rawProtection = raw.baseRef?.branchProtectionRule ?? null;
126
- const branchProtection = rawProtection
127
- ? {
128
- requiresApprovingReviews: rawProtection.requiresApprovingReviews,
129
- requiredApprovingReviewCount: rawProtection.requiredApprovingReviewCount,
130
- requiresConversationResolution: rawProtection.requiresConversationResolution,
131
- requiresStatusChecks: rawProtection.requiresStatusChecks,
132
- requiredStatusCheckContexts: rawProtection.requiredStatusCheckContexts ?? [],
133
- }
134
- : null;
135
123
  return {
136
124
  nodeId: raw.id,
137
125
  number: raw.number,
@@ -152,6 +140,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
152
140
  reviewSummaries,
153
141
  approvedReviews,
154
142
  checks,
155
- branchProtection,
143
+ branchProtection: parseBranchProtection(raw),
144
+ activity: buildPrActivitySummary(raw, comments, reviewThreads, reviewSummaries, allChangesRequestedReviews, approvedReviews),
156
145
  };
157
146
  }
@@ -0,0 +1,12 @@
1
+ export function parseBranchProtection(raw) {
2
+ const rule = raw.baseRef?.branchProtectionRule ?? null;
3
+ return rule
4
+ ? {
5
+ requiresApprovingReviews: rule.requiresApprovingReviews,
6
+ requiredApprovingReviewCount: rule.requiredApprovingReviewCount,
7
+ requiresConversationResolution: rule.requiresConversationResolution,
8
+ requiresStatusChecks: rule.requiresStatusChecks,
9
+ requiredStatusCheckContexts: rule.requiredStatusCheckContexts ?? [],
10
+ }
11
+ : null;
12
+ }
@@ -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, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
11
+ import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY, GET_PR_BODY_QUERY, UPDATE_PR_BODY_MUTATION, } 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
@@ -63,6 +63,22 @@ export async function getPrHeadSha(pr, owner, name) {
63
63
  }
64
64
  return sha;
65
65
  }
66
+ /** Fetches the node ID and body text for a PR. GitHub returns null body for empty bodies — coerced to "". */
67
+ export async function getPullRequestBody(pr, owner, name) {
68
+ const result = await httpGraphql(GET_PR_BODY_QUERY, { owner, repo: name, pr });
69
+ const pullRequest = result.data.repository?.pullRequest;
70
+ if (!pullRequest) {
71
+ const detail = !result.data.repository
72
+ ? "repository not found or access denied"
73
+ : "PR not found or access denied";
74
+ throw new Error(`Could not fetch body for ${owner}/${name} PR #${pr}: ${detail}`);
75
+ }
76
+ return { nodeId: pullRequest.id, body: pullRequest.body ?? "" };
77
+ }
78
+ /** Overwrites the PR body. */
79
+ export async function updatePullRequestBody(pullRequestId, body) {
80
+ await httpGraphql(UPDATE_PR_BODY_MUTATION, { pullRequestId, body });
81
+ }
66
82
  /**
67
83
  * Fetches `mergeable` and `mergeStateStatus` via the REST API.
68
84
  *
@@ -49,6 +49,9 @@ query BatchPr(
49
49
  }
50
50
  }
51
51
  }
52
+ # Not paginated — capped at 100 distinct reviewers; PRs with more are not plausible
53
+ # in practice. Used to derive which CHANGES_REQUESTED rows are superseded by a later
54
+ # APPROVED/DISMISSED review from the same author.
52
55
  latestReviews(last: 100) {
53
56
  nodes {
54
57
  author {
@@ -128,6 +131,7 @@ query BatchPr(
128
131
  login
129
132
  }
130
133
  body
134
+ createdAt
131
135
  }
132
136
  }
133
137
  reviewSummaries: reviews(states: COMMENTED, last: 50, before: $reviewSummariesCursor) {
@@ -143,8 +147,12 @@ query BatchPr(
143
147
  login
144
148
  }
145
149
  body
150
+ createdAt
146
151
  }
147
152
  }
153
+ allReviews: reviews(last: 1) {
154
+ totalCount
155
+ }
148
156
  approvedReviews: reviews(states: APPROVED, last: 50, before: $approvedReviewsCursor) {
149
157
  pageInfo {
150
158
  hasPreviousPage
@@ -158,12 +166,15 @@ query BatchPr(
158
166
  login
159
167
  }
160
168
  body
169
+ createdAt
161
170
  }
162
171
  }
163
172
  commits(last: 1) {
173
+ totalCount
164
174
  nodes {
165
175
  commit {
166
176
  oid
177
+ committedDate
167
178
  statusCheckRollup {
168
179
  contexts(first: 100, after: $checksCursor) {
169
180
  pageInfo {
@@ -0,0 +1,8 @@
1
+ query GetPrBody($owner: String!, $repo: String!, $pr: Int!) {
2
+ repository(owner: $owner, name: $repo) {
3
+ pullRequest(number: $pr) {
4
+ id
5
+ body
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,7 @@
1
+ mutation UpdatePrBody($pullRequestId: ID!, $body: String!) {
2
+ updatePullRequest(input: { pullRequestId: $pullRequestId, body: $body }) {
3
+ pullRequest {
4
+ id
5
+ }
6
+ }
7
+ }
@@ -21,3 +21,7 @@ export const GET_PR_HEAD_SHA_QUERY = gql("get-pr-head-sha.gql");
21
21
  export const PR_NUMBER_BY_BRANCH_QUERY = gql("pr-number-by-branch.gql");
22
22
  /** Convert a draft PR to ready for review. */
23
23
  export const MARK_PR_READY_MUTATION = gql("mark-pr-ready.gql");
24
+ /** Fetch the PR body and node ID. */
25
+ export const GET_PR_BODY_QUERY = gql("get-pr-body.gql");
26
+ /** Update the PR body. */
27
+ export const UPDATE_PR_BODY_MUTATION = gql("update-pr-body.gql");
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1 @@
1
- // Iterate command types.
2
1
  export {};
package/bin/types.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Shared type definitions for the shepherd CLI. */
2
2
  export * from "./types/github.mjs";
3
+ export * from "./types/activity.mjs";
3
4
  export * from "./types/review-thread.mjs";
4
5
  export * from "./types/agent-thread.mjs";
5
6
  export * from "./types/check-annotations.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.25.3",
3
+ "version": "0.26.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "bin/**",
13
+ "src/classify/types.mts",
13
14
  "plugins/**",
14
15
  "plugins/**/.codex-plugin/**",
15
16
  ".claude-plugin/**",
@@ -22,14 +23,23 @@
22
23
  "node": ">=22.0.0"
23
24
  },
24
25
  "dependencies": {
26
+ "picomatch": "^4.0.4",
27
+ "tsx": "^4.22.4",
25
28
  "yaml": "^2.7.0"
26
29
  },
30
+ "exports": {
31
+ "./classify": {
32
+ "types": "./src/classify/types.mts",
33
+ "default": "./bin/classify/types.mjs"
34
+ }
35
+ },
27
36
  "devDependencies": {
28
37
  "@types/node": "^25.6.0",
38
+ "@types/picomatch": "^4.0.3",
29
39
  "@vitest/coverage-v8": "^4.1.4",
30
40
  "husky": "^9.1.7",
31
41
  "knip": "^6.14.1",
32
- "oxfmt": "^0.50.0",
42
+ "oxfmt": "^0.51.0",
33
43
  "oxlint": "^1.60.0",
34
44
  "typescript": "^6.0.3",
35
45
  "vitest": "^4.1.4"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.25.3",
3
+ "version": "0.26.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -14,30 +14,15 @@ Poll dispatcher for iterating a PR to completion.
14
14
 
15
15
  ## Steps
16
16
 
17
- 1. **Resolve PR number:**
18
- - If `$ARGUMENTS` contains a PR number, use it.
19
- - If `$ARGUMENTS` contains a GitHub PR URL, extract the number.
20
- - Otherwise, infer: `gh pr view --json number --jq .number`
21
- - If no PR found, report an error and stop.
17
+ 1. **Resolve the PR number** (`$N`): use the number or URL in `$ARGUMENTS`; otherwise infer it with `gh pr view --json number --jq .number`. If none is found, report an error and stop.
22
18
 
23
- 2. **Run `pr-shepherd`:**
19
+ 2. **Define the poll command once:** `pr-shepherd $N --interval 45s --timeout 4m`. Do not forward `$ARGUMENTS` as extra flags. Run `pr-shepherd --help` to inspect supported options.
24
20
 
25
- ```bash
26
- pr-shepherd <N> --interval 45s --timeout 4m
27
- ```
21
+ 3. **Loop:** Run the poll, print its full output, and follow its `## Instructions` section exactly. Then run the poll again. Repeat until the CLI emits `[CANCEL]` or `[ESCALATE]`, unless the human directs you to stop. Every other action (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) is non-terminal: do its instructions, then poll again. The poll already bounds each wait via `--interval`/`--timeout`; do not add manual `sleep`s between ticks.
28
22
 
29
- Do not pass `$ARGUMENTS` through as extra flags. If you need to inspect supported options, run `pr-shepherd --help`.
23
+ 4. **Nonzero exit codes:** Treat a nonzero poll exit as PR state only when the output contains a matching `# PR #$N [ACTION]` heading. Exit `1` can also mean a command or validation failure; if there is no `[ACTION]` heading, surface the error and stop instead of looping.
30
24
 
31
- Print the full output. Follow the `## Instructions` section exactly for the current action.
32
-
33
- 3. **Persistence:** Continuously call `pr-shepherd <N> --interval 45s --timeout 4m` until the CLI returns `[CANCEL]` or `[ESCALATE]`, unless the human directs you to stop. Every other action is non-terminal:
34
- - `[WAIT]`: call `pr-shepherd <N> --interval 45s --timeout 4m` again.
35
- - `[MARK_READY]`: call `pr-shepherd <N> --interval 45s --timeout 4m` again.
36
- - `[FIX_CODE]`: follow the output's `## Instructions`, then call `pr-shepherd <N> --interval 45s --timeout 4m` again.
37
-
38
- Treat a nonzero poll exit code as PR state only when the output contains a matching `# PR #N [ACTION]` heading. Exit code `1` can also mean a command or validation failure; if there is no `[FIX_CODE]` heading, surface the error and stop instead of looping.
39
-
40
- 4. **Stop conditions (terminal states):**
41
- - Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
42
- - Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures or CI that never starts.
25
+ 5. **Terminal states (stop):**
26
+ - `[CANCEL]` — ready-delay completed, or PR merged/closed.
27
+ - `[ESCALATE]` needs human direction (includes `stall-timeout` for repeated unchanged CI failures or CI that never starts).
43
28
  - **Do NOT merge the pull request** unless the human has explicitly requested or allowed it.
@@ -0,0 +1,48 @@
1
+ export type ClassifyItemKind =
2
+ | "review-thread"
3
+ | "pr-comment"
4
+ | "review-summary"
5
+ | "changes-requested";
6
+
7
+ export interface ClassifyItemBase {
8
+ readonly kind: ClassifyItemKind;
9
+ readonly id: string;
10
+ readonly author: string;
11
+ readonly authorType: "User" | "Bot" | "Unknown";
12
+ readonly body: string;
13
+ readonly url?: string;
14
+ }
15
+
16
+ export interface ClassifyReviewThread extends ClassifyItemBase {
17
+ readonly kind: "review-thread";
18
+ readonly path?: string | null;
19
+ }
20
+
21
+ export interface ClassifyPrComment extends ClassifyItemBase {
22
+ readonly kind: "pr-comment";
23
+ }
24
+
25
+ export interface ClassifyReviewSummary extends ClassifyItemBase {
26
+ readonly kind: "review-summary";
27
+ }
28
+
29
+ export interface ClassifyChangesRequested extends ClassifyItemBase {
30
+ readonly kind: "changes-requested";
31
+ }
32
+
33
+ export type ClassifyItem =
34
+ | ClassifyReviewThread
35
+ | ClassifyPrComment
36
+ | ClassifyReviewSummary
37
+ | ClassifyChangesRequested;
38
+
39
+ export interface ClassifyAction {
40
+ /** When true, routes the item's ID to the appropriate resolve/minimize GitHub mutation. Not supported for changes-requested reviews. */
41
+ readonly autoResolve?: boolean;
42
+ /** When true, hides the item from agent output (seen marker is still written). */
43
+ readonly suppress?: boolean;
44
+ /** Optional note recorded to the debug log when this rule fires. */
45
+ readonly reason?: string;
46
+ }
47
+
48
+ export type ClassifyRule = (item: ClassifyItem) => ClassifyAction | null | undefined;