pr-shepherd 0.25.4 → 0.26.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.
Files changed (47) 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-parsers.mjs +20 -34
  35. package/bin/github/branch-protection.mjs +12 -0
  36. package/bin/github/client.mjs +17 -1
  37. package/bin/github/gql/batch-pr.gql +8 -0
  38. package/bin/github/gql/get-pr-body.gql +8 -0
  39. package/bin/github/gql/update-pr-body.gql +7 -0
  40. package/bin/github/queries.mjs +4 -0
  41. package/bin/types/activity.mjs +1 -0
  42. package/bin/types/iterate.mjs +0 -1
  43. package/bin/types.mjs +1 -0
  44. package/package.json +12 -2
  45. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  46. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +7 -22
  47. 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
+ }
@@ -1,4 +1,15 @@
1
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;
@@ -19,7 +30,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
19
30
  authorType: mapAuthorType(c.author?.__typename, c.author?.login),
20
31
  body: c.body,
21
32
  url: c.url,
22
- createdAtUnix: parseCreatedAt(c.createdAt),
33
+ createdAtUnix: c.createdAt ? parseCreatedAt(c.createdAt) : 0,
23
34
  }));
24
35
  return {
25
36
  id: t.id,
@@ -34,7 +45,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
34
45
  authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
35
46
  body: comment?.body ?? "",
36
47
  url: comment?.url ?? "",
37
- createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
48
+ createdAtUnix: comment?.createdAt ? parseCreatedAt(comment.createdAt) : 0,
38
49
  comments,
39
50
  };
40
51
  });
@@ -45,35 +56,19 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
45
56
  authorType: mapAuthorType(c.author?.__typename, c.author?.login),
46
57
  body: c.body,
47
58
  url: c.url,
48
- createdAtUnix: parseCreatedAt(c.createdAt),
49
- }));
50
- const changesRequestedReviews = rawReviewNodes
51
- .filter((r) => !crDone.has(r.author?.login ?? "unknown"))
52
- .map((r) => ({
53
- id: r.id,
54
- author: r.author?.login ?? "unknown",
55
- authorType: mapAuthorType(r.author?.__typename, r.author?.login),
56
- body: r.body,
59
+ createdAtUnix: c.createdAt ? parseCreatedAt(c.createdAt) : 0,
57
60
  }));
61
+ const allChangesRequestedReviews = rawReviewNodes.map((r) => parseReviewNode(r));
62
+ const changesRequestedReviews = allChangesRequestedReviews.filter((r) => !crDone.has(r.author));
58
63
  const reviewSummaries = rawReviewSummaryNodes
59
64
  .filter((r) => !r.isMinimized && r.body.trim() !== "")
60
- .map((r) => ({
61
- id: r.id,
62
- author: r.author?.login ?? "unknown",
63
- authorType: mapAuthorType(r.author?.__typename, r.author?.login),
64
- body: r.body,
65
- }));
65
+ .map((r) => parseReviewNode(r));
66
66
  // APPROVED reviews often have empty bodies (clicking "Approve" without a comment), so
67
67
  // we keep them — only the isMinimized filter applies. Monitor/iterate uses these IDs
68
68
  // when the user opts in to minimizing approvals.
69
69
  const approvedReviews = rawApprovedReviewNodes
70
70
  .filter((r) => !r.isMinimized)
71
- .map((r) => ({
72
- id: r.id,
73
- author: r.author?.login ?? "unknown",
74
- authorType: mapAuthorType(r.author?.__typename, r.author?.login),
75
- body: r.body,
76
- }));
71
+ .map((r) => parseReviewNode(r));
77
72
  const checks = rawCheckNodes.flatMap((node) => {
78
73
  if (node.__typename === "CheckRun") {
79
74
  const event = node.checkSuite?.workflowRun?.event ?? null;
@@ -125,16 +120,6 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
125
120
  }
126
121
  return [];
127
122
  });
128
- const rawProtection = raw.baseRef?.branchProtectionRule ?? null;
129
- const branchProtection = rawProtection
130
- ? {
131
- requiresApprovingReviews: rawProtection.requiresApprovingReviews,
132
- requiredApprovingReviewCount: rawProtection.requiredApprovingReviewCount,
133
- requiresConversationResolution: rawProtection.requiresConversationResolution,
134
- requiresStatusChecks: rawProtection.requiresStatusChecks,
135
- requiredStatusCheckContexts: rawProtection.requiredStatusCheckContexts ?? [],
136
- }
137
- : null;
138
123
  return {
139
124
  nodeId: raw.id,
140
125
  number: raw.number,
@@ -155,6 +140,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
155
140
  reviewSummaries,
156
141
  approvedReviews,
157
142
  checks,
158
- branchProtection,
143
+ branchProtection: parseBranchProtection(raw),
144
+ activity: buildPrActivitySummary(raw, comments, reviewThreads, reviewSummaries, allChangesRequestedReviews, approvedReviews),
159
145
  };
160
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
  *
@@ -131,6 +131,7 @@ query BatchPr(
131
131
  login
132
132
  }
133
133
  body
134
+ createdAt
134
135
  }
135
136
  }
136
137
  reviewSummaries: reviews(states: COMMENTED, last: 50, before: $reviewSummariesCursor) {
@@ -146,8 +147,12 @@ query BatchPr(
146
147
  login
147
148
  }
148
149
  body
150
+ createdAt
149
151
  }
150
152
  }
153
+ allReviews: reviews(last: 1) {
154
+ totalCount
155
+ }
151
156
  approvedReviews: reviews(states: APPROVED, last: 50, before: $approvedReviewsCursor) {
152
157
  pageInfo {
153
158
  hasPreviousPage
@@ -161,12 +166,15 @@ query BatchPr(
161
166
  login
162
167
  }
163
168
  body
169
+ createdAt
164
170
  }
165
171
  }
166
172
  commits(last: 1) {
173
+ totalCount
167
174
  nodes {
168
175
  commit {
169
176
  oid
177
+ committedDate
170
178
  statusCheckRollup {
171
179
  contexts(first: 100, after: $checksCursor) {
172
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.4",
3
+ "version": "0.26.1",
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.4",
3
+ "version": "0.26.1",
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;