pr-shepherd 0.5.2 → 0.7.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.
@@ -1,63 +1,27 @@
1
1
  /**
2
- * Thin wrapper around the `gh` CLI for GraphQL and REST calls.
2
+ * High-level GitHub client wraps http.mts for application-level concerns.
3
+ *
4
+ * All GitHub I/O goes through native fetch (via http.mts); the `gh` CLI is no
5
+ * longer used for GitHub API calls, but may be invoked as an auth-token
6
+ * fallback via `gh auth token` when neither GH_TOKEN nor GITHUB_TOKEN is set.
3
7
  */
4
8
  import { execFile as execFileCb } from "node:child_process";
5
9
  import { promisify } from "node:util";
6
- import { loadConfig } from "../config/load.mjs";
10
+ import { graphql as httpGraphql, rest } from "./http.mjs";
11
+ import { PR_NUMBER_BY_BRANCH_QUERY } from "./queries.mjs";
7
12
  const execFile = promisify(execFileCb);
8
13
  // ---------------------------------------------------------------------------
9
- // GraphQL
14
+ // GraphQL — thin re-exports so callers don't need to import http.mts directly
10
15
  // ---------------------------------------------------------------------------
16
+ export { graphql, graphqlWithRateLimit } from "./http.mjs";
11
17
  /**
12
- * Execute a GraphQL query via `gh api graphql`.
13
- *
14
- * @param query The full GraphQL query/mutation string.
15
- * @param vars Key-value pairs forwarded as `-f key=value` or `-F key=value`.
16
- * Numeric values are passed with `-F`; everything else with `-f`.
18
+ * Returns the current repo's owner and name by parsing `git remote get-url origin`.
19
+ * Handles https://, git@, and ssh:// remote URL formats.
17
20
  */
18
- export async function graphql(query, vars = {}) {
19
- const args = buildGraphqlArgs(query, vars);
20
- const raw = await runGh(args);
21
- const parsed = JSON.parse(raw);
22
- if (parsed.errors?.length) {
23
- const messages = parsed.errors.map((e) => e.message).join("; ");
24
- throw new Error(`GitHub GraphQL error: ${messages}`);
25
- }
26
- return { data: parsed.data };
27
- }
28
- /** Like {@link graphql} but also returns the `x-ratelimit-remaining` header. */
29
- export async function graphqlWithRateLimit(query, vars = {}) {
30
- // --include must come after 'api' (it's a flag for `gh api`, not for `gh`).
31
- const [api, ...extraArgs] = buildGraphqlArgs(query, vars);
32
- const args = [api, "--include", ...extraArgs];
33
- const raw = await runGh(args);
34
- // `gh api -i` prepends HTTP headers before the JSON body.
35
- // Handle both CRLF (\r\n\r\n) and LF-only (\n\n) header separators.
36
- const crlfEnd = raw.indexOf("\r\n\r\n");
37
- const lfEnd = raw.indexOf("\n\n");
38
- const headerEnd = crlfEnd >= 0 ? crlfEnd : lfEnd;
39
- const headerSection = headerEnd >= 0 ? raw.slice(0, headerEnd) : "";
40
- const body = headerEnd >= 0 ? raw.slice(headerEnd + (crlfEnd >= 0 ? 4 : 2)) : raw;
41
- const remaining = parseHeaderNumber(headerSection, "x-ratelimit-remaining");
42
- const limit = parseHeaderNumber(headerSection, "x-ratelimit-limit");
43
- const resetAt = parseHeaderNumber(headerSection, "x-ratelimit-reset");
44
- const parsed = JSON.parse(body);
45
- if (parsed.errors?.length) {
46
- const messages = parsed.errors.map((e) => e.message).join("; ");
47
- throw new Error(`GitHub GraphQL error: ${messages}`);
48
- }
49
- return {
50
- data: parsed.data,
51
- rateLimit: remaining !== null && limit !== null && resetAt !== null
52
- ? { remaining, limit, resetAt }
53
- : undefined,
54
- };
55
- }
56
- /** Returns the current repo's owner and name from `gh repo view`. */
57
21
  export async function getRepoInfo() {
58
- const raw = await runGh(["repo", "view", "--json", "owner,name"]);
59
- const parsed = JSON.parse(raw);
60
- return { owner: parsed.owner.login, name: parsed.name };
22
+ const { stdout } = await execFile("git", ["remote", "get-url", "origin"]);
23
+ const url = stdout.trim();
24
+ return parseRemoteUrl(url);
61
25
  }
62
26
  /**
63
27
  * Derives the PR number for the current HEAD branch.
@@ -66,87 +30,52 @@ export async function getRepoInfo() {
66
30
  export async function getCurrentPrNumber() {
67
31
  try {
68
32
  const branch = await getCurrentBranch();
69
- // In detached HEAD state git returns "HEAD" — no branch name to look up.
70
33
  if (branch === "HEAD")
71
34
  return null;
72
- const raw = await runGh([
73
- "pr",
74
- "list",
75
- "--head",
76
- branch,
77
- "--json",
78
- "number",
79
- "--jq",
80
- ".[0].number",
81
- ]);
82
- const trimmed = raw.trim();
83
- if (!trimmed || trimmed === "null")
84
- return null;
85
- return parseInt(trimmed, 10);
35
+ const repo = await getRepoInfo();
36
+ const result = await httpGraphql(PR_NUMBER_BY_BRANCH_QUERY, { owner: repo.owner, repo: repo.name, branch });
37
+ return result.data.repository?.pullRequests.nodes[0]?.number ?? null;
86
38
  }
87
39
  catch {
88
40
  return null;
89
41
  }
90
42
  }
91
- async function getCurrentBranch() {
92
- // Use git directly for branch name — gh doesn't expose it.
93
- const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
94
- return stdout.trim();
95
- }
96
43
  /** Returns the `headRefOid` (commit SHA) of the given PR as reported by GitHub. */
97
44
  export async function getPrHeadSha(pr, owner, name) {
98
- const raw = await runGh(["api", `repos/${owner}/${name}/pulls/${pr}`, "--jq", ".head.sha"]);
99
- return raw.trim();
45
+ const data = await rest("GET", `/repos/${owner}/${name}/pulls/${pr}`);
46
+ return data.head.sha;
100
47
  }
101
48
  /**
102
- * Fetches `mergeable` and `mergeStateStatus` via the REST API (`gh pr view`).
49
+ * Fetches `mergeable` and `mergeStateStatus` via the REST API.
103
50
  *
104
51
  * Used as a fallback when the GraphQL API returns `UNKNOWN` for these fields —
105
52
  * a known GitHub quirk where GraphQL lags behind the REST layer.
106
53
  */
107
54
  export async function getMergeableState(pr, owner, repo) {
108
- const raw = await runGh([
109
- "pr",
110
- "view",
111
- String(pr),
112
- "--repo",
113
- `${owner}/${repo}`,
114
- "--json",
115
- "mergeable,mergeStateStatus",
116
- ]);
117
- const parsed = JSON.parse(raw);
118
- return parsed;
55
+ const data = await rest("GET", `/repos/${owner}/${repo}/pulls/${pr}`);
56
+ const mergeable = data.mergeable === true ? "MERGEABLE" : data.mergeable === false ? "CONFLICTING" : "UNKNOWN";
57
+ const mergeStateStatus = data.mergeable_state.toUpperCase();
58
+ return { mergeable, mergeStateStatus };
119
59
  }
120
60
  // ---------------------------------------------------------------------------
121
61
  // Internal helpers
122
62
  // ---------------------------------------------------------------------------
123
- function buildGraphqlArgs(query, vars) {
124
- const args = ["api", "graphql", "-f", `query=${query}`];
125
- for (const [k, v] of Object.entries(vars)) {
126
- if (typeof v === "number" || typeof v === "boolean") {
127
- args.push("-F", `${k}=${String(v)}`);
128
- }
129
- else {
130
- args.push("-f", `${k}=${v}`);
131
- }
132
- }
133
- return args;
63
+ async function getCurrentBranch() {
64
+ const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
65
+ return stdout.trim();
134
66
  }
135
- async function runGh(args) {
136
- try {
137
- const { stdout } = await execFile("gh", args, {
138
- maxBuffer: loadConfig().execution.maxBufferMb * 1024 * 1024,
139
- });
140
- return stdout;
67
+ function parseRemoteUrl(url) {
68
+ // Strip trailing .git and trailing slash
69
+ const stripped = url.replace(/\.git$/, "").replace(/\/$/, "");
70
+ // ssh: git@host:owner/repo
71
+ const sshMatch = /^git@[^:]+:([^/]+)\/(.+)$/.exec(stripped);
72
+ if (sshMatch) {
73
+ return { owner: sshMatch[1], name: sshMatch[2] };
141
74
  }
142
- catch (err) {
143
- // Re-throw with a more useful message
144
- const msg = err instanceof Error ? err.message : String(err);
145
- throw new Error(`gh ${args[0] ?? ""} failed: ${msg}`, { cause: err });
75
+ // https or ssh://: https://host/owner/repo or ssh://git@host/owner/repo
76
+ const httpsMatch = /^(?:https?|ssh):\/\/[^/]+\/([^/]+)\/(.+)$/.exec(stripped);
77
+ if (httpsMatch) {
78
+ return { owner: httpsMatch[1], name: httpsMatch[2] };
146
79
  }
147
- }
148
- function parseHeaderNumber(headers, name) {
149
- const re = new RegExp(`^${name}:\\s*(\\d+)`, "im");
150
- const m = re.exec(headers);
151
- return m ? parseInt(m[1], 10) : null;
80
+ throw new Error(`Cannot parse GitHub remote URL: ${url}`);
152
81
  }
@@ -5,10 +5,12 @@ query BatchPr(
5
5
  $threadsCursor: String
6
6
  $checksCursor: String
7
7
  $commentsCursor: String
8
- $reviewsCursor: String
8
+ $changesRequestedCursor: String
9
+ $reviewSummariesCursor: String
9
10
  ) {
10
11
  repository(owner: $owner, name: $repo) {
11
12
  pullRequest(number: $pr) {
13
+ id
12
14
  number
13
15
  state
14
16
  isDraft
@@ -16,6 +18,7 @@ query BatchPr(
16
18
  mergeStateStatus
17
19
  reviewDecision
18
20
  headRefOid
21
+ baseRefName
19
22
  # Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
20
23
  reviewRequests(last: 50) {
21
24
  nodes {
@@ -80,7 +83,11 @@ query BatchPr(
80
83
  createdAt
81
84
  }
82
85
  }
83
- reviews(states: CHANGES_REQUESTED, last: 50, before: $reviewsCursor) {
86
+ changesRequestedReviews: reviews(
87
+ states: CHANGES_REQUESTED
88
+ last: 50
89
+ before: $changesRequestedCursor
90
+ ) {
84
91
  pageInfo {
85
92
  hasPreviousPage
86
93
  startCursor
@@ -93,6 +100,20 @@ query BatchPr(
93
100
  body
94
101
  }
95
102
  }
103
+ reviewSummaries: reviews(states: COMMENTED, last: 50, before: $reviewSummariesCursor) {
104
+ pageInfo {
105
+ hasPreviousPage
106
+ startCursor
107
+ }
108
+ nodes {
109
+ id
110
+ isMinimized
111
+ author {
112
+ login
113
+ }
114
+ body
115
+ }
116
+ }
96
117
  commits(last: 1) {
97
118
  nodes {
98
119
  commit {
@@ -0,0 +1,7 @@
1
+ mutation MarkPrReady($pullRequestId: ID!) {
2
+ markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
3
+ pullRequest {
4
+ isDraft
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,9 @@
1
+ query PrNumberByBranch($owner: String!, $repo: String!, $branch: String!) {
2
+ repository(owner: $owner, name: $repo) {
3
+ pullRequests(headRefName: $branch, states: OPEN, first: 1) {
4
+ nodes {
5
+ number
6
+ }
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Thin native-fetch HTTP client for the GitHub API.
3
+ * Replaces the previous `gh` CLI shell-out on every code path.
4
+ *
5
+ * Token resolution order:
6
+ * 1. GH_TOKEN env
7
+ * 2. GITHUB_TOKEN env
8
+ * 3. `gh auth token` (fallback for users who have run `gh auth login`)
9
+ */
10
+ import { execFile as execFileCb } from "node:child_process";
11
+ import { promisify } from "node:util";
12
+ const execFile = promisify(execFileCb);
13
+ const BASE_URL = "https://api.github.com";
14
+ // ---------------------------------------------------------------------------
15
+ // Auth
16
+ // ---------------------------------------------------------------------------
17
+ let _token;
18
+ export function _resetTokenCache() {
19
+ _token = undefined;
20
+ }
21
+ async function resolveToken() {
22
+ if (_token)
23
+ return _token;
24
+ if (process.env["GH_TOKEN"]) {
25
+ _token = process.env["GH_TOKEN"];
26
+ return _token;
27
+ }
28
+ if (process.env["GITHUB_TOKEN"]) {
29
+ _token = process.env["GITHUB_TOKEN"];
30
+ return _token;
31
+ }
32
+ try {
33
+ const { stdout } = await execFile("gh", ["auth", "token"]);
34
+ const token = stdout.trim();
35
+ if (token) {
36
+ _token = token;
37
+ return _token;
38
+ }
39
+ }
40
+ catch {
41
+ // fall through to error
42
+ }
43
+ throw new Error("No GitHub token found. Set GH_TOKEN or GITHUB_TOKEN, or run `gh auth login`.");
44
+ }
45
+ async function makeHeaders() {
46
+ return {
47
+ Authorization: `Bearer ${await resolveToken()}`,
48
+ Accept: "application/vnd.github+json",
49
+ "X-GitHub-Api-Version": "2022-11-28",
50
+ "User-Agent": "pr-shepherd",
51
+ "Content-Type": "application/json",
52
+ };
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // GraphQL
56
+ // ---------------------------------------------------------------------------
57
+ async function graphqlInner(query, vars) {
58
+ const res = await fetch(`${BASE_URL}/graphql`, {
59
+ method: "POST",
60
+ headers: await makeHeaders(),
61
+ body: JSON.stringify({ query, variables: vars }),
62
+ });
63
+ const rateLimit = parseRateLimit(res.headers);
64
+ if (!res.ok) {
65
+ const body = await res.text();
66
+ throw new Error(`GitHub GraphQL request failed: ${res.status} ${body.slice(0, 300)}`);
67
+ }
68
+ const parsed = (await res.json());
69
+ if (parsed.errors?.length) {
70
+ const messages = parsed.errors.map((e) => e.message).join("; ");
71
+ throw new Error(`GitHub GraphQL error: ${messages}`);
72
+ }
73
+ return { data: parsed.data, rateLimit };
74
+ }
75
+ export async function graphql(query, vars = {}) {
76
+ const { data } = await graphqlInner(query, vars);
77
+ return { data };
78
+ }
79
+ export async function graphqlWithRateLimit(query, vars = {}) {
80
+ const { data, rateLimit } = await graphqlInner(query, vars);
81
+ return { data, rateLimit: rateLimit ?? undefined };
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // REST
85
+ // ---------------------------------------------------------------------------
86
+ export async function rest(method, path, body) {
87
+ const res = await fetch(`${BASE_URL}${path}`, {
88
+ method,
89
+ headers: await makeHeaders(),
90
+ body: body !== undefined ? JSON.stringify(body) : undefined,
91
+ });
92
+ if (!res.ok) {
93
+ const text = await res.text();
94
+ throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${text.slice(0, 300)}`);
95
+ }
96
+ const ct = res.headers.get("content-type") ?? "";
97
+ if (ct.includes("application/json")) {
98
+ return res.json();
99
+ }
100
+ return undefined;
101
+ }
102
+ /**
103
+ * GET request that returns plain text.
104
+ * Handles the 302 redirect pattern used by the GitHub Actions job-logs endpoint —
105
+ * the redirect target (a signed storage URL) is fetched without auth headers.
106
+ */
107
+ export async function restText(path) {
108
+ const res = await fetch(`${BASE_URL}${path}`, {
109
+ method: "GET",
110
+ headers: await makeHeaders(),
111
+ redirect: "manual",
112
+ });
113
+ if (res.status === 301 || res.status === 302 || res.status === 307 || res.status === 308) {
114
+ const location = res.headers.get("location");
115
+ if (location) {
116
+ const redirectRes = await fetch(location);
117
+ if (!redirectRes.ok) {
118
+ throw new Error(`redirect target ${location} failed: ${redirectRes.status}`);
119
+ }
120
+ return redirectRes.text();
121
+ }
122
+ }
123
+ if (!res.ok) {
124
+ const text = await res.text();
125
+ throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${text.slice(0, 300)}`);
126
+ }
127
+ return res.text();
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Helpers
131
+ // ---------------------------------------------------------------------------
132
+ function parseRateLimit(headers) {
133
+ const rRaw = headers.get("x-ratelimit-remaining");
134
+ const lRaw = headers.get("x-ratelimit-limit");
135
+ const tRaw = headers.get("x-ratelimit-reset");
136
+ if (rRaw === null || lRaw === null || tRaw === null)
137
+ return null;
138
+ const remaining = Number(rRaw);
139
+ const limit = Number(lRaw);
140
+ const resetAt = Number(tRaw);
141
+ if (Number.isFinite(remaining) && Number.isFinite(limit) && Number.isFinite(resetAt)) {
142
+ return { remaining, limit, resetAt };
143
+ }
144
+ return null;
145
+ }
@@ -19,3 +19,7 @@ export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
19
19
  export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
20
20
  /** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
21
21
  export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
22
+ /** Look up PR number by branch name (for getCurrentPrNumber). */
23
+ export const PR_NUMBER_BY_BRANCH_QUERY = gql("pr-number-by-branch.gql");
24
+ /** Convert a draft PR to ready for review. */
25
+ export const MARK_PR_READY_MUTATION = gql("mark-pr-ready.gql");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -9,24 +9,18 @@ allowed-tools:
9
9
 
10
10
  # pr-shepherd monitor — Continuous PR Monitor
11
11
 
12
+ > Action reference (all 8 actions, JSON fields, examples): [docs/actions.md](../../../docs/actions.md)
13
+
12
14
  ## Arguments: $ARGUMENTS
13
15
 
14
16
  ## Resolve PR number
15
17
 
16
18
  1. Strip any trailing `every <N> <unit>` interval clause from `$ARGUMENTS` first.
17
- 2. Extract `--ready-delay <duration>` if present (e.g. `--ready-delay 15m`). Default: `10m`.
19
+ 2. Extract `--ready-delay <duration>` if present (e.g. `--ready-delay 15m`). Default: `10m`. Keep the raw duration string (e.g. `10m`) — do **not** convert to seconds.
18
20
  3. If the remaining text contains a PR number or GitHub PR URL, extract the number.
19
21
  4. Otherwise, infer: `gh pr list --head "$(git rev-parse --abbrev-ref HEAD)" --json number --jq '.[0].number'`
20
22
  5. If no PR found, report an error and stop.
21
23
 
22
- ## Detect base branch
23
-
24
- ```bash
25
- BASE_BRANCH=$(gh pr view <PR_NUMBER> --json baseRefName --jq '.baseRefName')
26
- ```
27
-
28
- Default to `main` if the command fails.
29
-
30
24
  ## Start the loop
31
25
 
32
26
  **Before starting:** List existing cron jobs with `CronList`.
@@ -39,63 +33,26 @@ Invoke `/loop <INTERVAL> --max-turns 50 --expires 8h` via the Skill tool. Use th
39
33
 
40
34
  ````
41
35
  # pr-shepherd-loop:pr=<PR_NUMBER>
42
- Run the following in a single Bash invocation:
43
- npx pr-shepherd iterate <PR_NUMBER> --ready-delay <READY_DELAY> --no-cache --last-push-time "$(git log -1 --format=%ct HEAD)" --format=json
44
-
45
- Exit codes 0, 1, 2, and 3 are all valid signals — always try to parse stdout as JSON first. If the command exits non-zero and stdout is not parseable JSON (e.g. a crash), log the first line of stderr and continue (do not cancel the loop).
46
-
47
- Parse the `action` field and act:
48
-
49
- - `cooldown` log: `SKIP: CI still starting`
50
- - `wait` → log: `WAIT: <summary.passing> passing, <summary.inProgress> in-progress (merge state: <mergeStateStatus>, <remainingSeconds>s cooldown remaining)`
51
- - `rerun_ci` log: `RERAN <N> CI checks: <reran joined by space>`
52
- - `mark_ready` → log: `MARKED READY: PR <pr>`
53
- - `cancel` invoke `/loop cancel` and stop
54
- - `rebase` → run:
55
- ```bash
56
- if ! git diff --quiet || ! git diff --cached --quiet; then
57
- echo "SKIP rebase: dirty worktree (uncommitted changes present)"
58
- exit 0
59
- fi
60
- git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease
61
- ```
62
-
63
- - `escalate` → invoke `/loop cancel` via Skill tool, then print:
64
-
65
- ⚠️ /pr-shepherd:monitor paused — needs human direction
66
-
67
- Triggers: <escalate.triggers joined by ", ">
68
- <escalate.suggestion>
69
-
70
- Items needing attention:
71
- <for each thread in escalate.unresolvedThreads: "- threadId=<id> <path ?? '(no location)'>:<line ?? '?'> (@<author>): <body first line>">
72
- <if escalate.changesRequestedReviews.length > 0: for each "- reviewId=<id> (@<author>): <body first line>">
73
- <if escalate.attemptHistory: "Fix attempts: " + each "threadId=<id> attempted <N> times">
74
-
75
- Run /pr-shepherd:check <PR> to see current state.
76
- After fixing manually, rerun /pr-shepherd:monitor <PR> to resume.
77
-
78
- - `fix_code` → do the following, then stop this iteration (CI needs time):
79
- 0. **Triage `fix.comments`** into two buckets before taking any action:
80
- - **Noise** (`NOISE_COMMENT_IDS`): bot-authored comments with no actionable code feedback — e.g. quota/rate-limit warnings ("you have reached your daily quota", "please wait up to N hours"), "resuming" notices, bare acknowledgements, or any comment whose body contains no file path, line number, or concrete code suggestion. Collect their `id`s.
81
- - **Actionable**: everything else. When in doubt, treat as actionable.
82
- All items in `fix.threads` are always actionable (they carry a file path and line by construction).
83
- 1. For each item in `fix.threads` and each **actionable** `fix.comments`: read the referenced file/line and apply the fix (Edit/Write tools).
84
- 2. For each item in `fix.checks`:
85
- - If `runId` is non-null: fetch the failure log with `gh run view <runId> --log-failed`, 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).
86
- - 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.
87
- 3. For each item in `fix.changesRequestedReviews`: read the review body and apply the requested changes.
88
- 4. If files were changed, `git add <files> && git commit -m "<appropriate commit message>"`
89
- 5. If files were changed: `git fetch origin && git rebase origin/<BASE_BRANCH> && git push --force-with-lease`, then `HEAD_SHA=$(git rev-parse HEAD)`.
90
- 6. If **only noise** was found (no files changed, no threads/checks/reviews to act on): skip commit/push and omit `--require-sha` in the next step.
91
- 7. Resolve the items on GitHub. Build the command from the non-empty ID lists only — always start with:
92
- `npx pr-shepherd resolve <PR_NUMBER>`
93
- Then append:
94
- - `--resolve-thread-ids <IDs>` only if `fix.threads` was non-empty.
95
- - `--minimize-comment-ids <IDs>` if any comments exist (use `NOISE_COMMENT_IDS` plus IDs of any other comments to minimize).
96
- - `--dismiss-review-ids <IDs> --message "<specific description of what you changed>"` only if `fix.changesRequestedReviews` was non-empty. The message is shown to the reviewer on GitHub — write one sentence describing the actual fix (e.g. `"Switched to parameterized query in src/db.ts"`). Never use generic text like `"address review comments"`.
97
- - `--require-sha "$HEAD_SHA"` only if a push occurred (omit when only noise was handled).
98
- Omit any flag whose ID list is empty.
36
+
37
+ **IMPORTANT recurrence rules:**
38
+ - **Do NOT call `ScheduleWakeup` or `/loop`.** This session is fired by a recurring cron job. Either call creates a duplicate runner, causing concurrent git operations and `.git/index.lock` collisions.
39
+ - End the turn cleanly after completing the actions below. The cron job handles the next fire.
40
+
41
+ **Self-dedup:** Run `CronList`. If more than one job contains `# pr-shepherd-loop:pr=<PR_NUMBER>`, keep the lowest job ID and `CronDelete` the rest (ignore errors — a concurrent runner may have already deleted them).
42
+
43
+ Run in a single Bash call:
44
+ npx pr-shepherd iterate <PR_NUMBER> --ready-delay <READY_DELAY_DURATION> --no-cache --last-push-time "$(git log -1 --format=%ct HEAD)"
45
+ (`<READY_DELAY_DURATION>` is the raw duration string, e.g. `10m` never a bare number of seconds)
46
+
47
+ Exit codes 0–3 are all valid. If the command crashes (non-zero exit, no markdown output starting with `# PR #<N> [`), log the first line of stderr and continue — do not cancel the loop. The next cron fire will retry.
48
+
49
+ The output is Markdown. The first line is an H1 heading of the form `# PR #<N> [<ACTION>]`. Read the `[<ACTION>]` tag to decide what to do (see [docs/actions.md](../../../docs/actions.md) for full output shapes):
50
+
51
+ - `[COOLDOWN]` | `[WAIT]` | `[RERUN_CI]` | `[MARK_READY]` → print the output, continue.
52
+ - `[CANCEL]` → print the output, then invoke `/loop cancel` via Skill tool and stop.
53
+ - `[REBASE]` → print the output, then extract the shell script from the ` ```bash ` fenced block and run it in Bash.
54
+ - `[ESCALATE]` print the output, then invoke `/loop cancel` via Skill tool and stop.
55
+ - `[FIX_CODE]` → follow the numbered items under `## Instructions` in order. Only run a `resolve` command if those instructions explicitly include a "Run the `resolve:` command…" step, or if the provided resolve command includes mutation flags. In that case, the `resolve` bullet under `## Rebase` holds the final resolve command inside backticks — strip the backticks and run it, substituting `"$HEAD_SHA"` with the pushed SHA and `$DISMISS_MESSAGE` with a one-sentence description of the actual fix (never generic text like "address review comments"). **Never manually run `gh run cancel` after your push** — stale runs listed under `## Cancelled runs` were already cancelled by the CLI (using the pre-push run IDs, as required by the "cancel CI runs before fixing and pushing" rule); running it again post-push would hit the NEW runs your push just triggered. If you run the resolve command, stop this iteration afterward — CI needs time before the next tick.
99
56
 
100
57
  ````
101
58
 
@@ -111,7 +68,7 @@ The default 4-minute interval is chosen for two reasons:
111
68
  The loop prompt above handles each iteration directly — no subagent is spawned. The same iterate command can be run manually at any time:
112
69
 
113
70
  ```bash
114
- npx pr-shepherd iterate <PR_NUMBER> --ready-delay <READY_DELAY> --no-cache --last-push-time "$(git log -1 --format=%ct HEAD)" --format=json
71
+ npx pr-shepherd iterate <PR_NUMBER> --ready-delay <READY_DELAY_DURATION> --no-cache --last-push-time "$(git log -1 --format=%ct HEAD)"
115
72
  ```
116
73
 
117
74
  To stop monitoring manually, use `/loop cancel` or close the session.
@@ -44,12 +44,18 @@ Resolve unresolved review threads and minimize PR comments on the current PR —
44
44
  ```
45
45
 
46
46
  The CLI auto-resolves outdated threads.
47
- Parse the JSON for `actionableThreads`, `actionableComments`, `changesRequestedReviews`.
47
+ Parse the JSON for `actionableThreads`, `actionableComments`, `changesRequestedReviews`, `reviewSummaries`.
48
48
 
49
- 3. **Triage each actionable item.** For each unresolved thread or visible comment:
50
- - Read the comment body to understand what it's asking
51
- - For review threads: read the referenced file and line
52
- - Classify as: **Fixed** (already addressed), **Not relevant**, **Outdated**, or **Actionable** (real issue, not yet fixed)
49
+ 3. **Triage each actionable item** into exactly one of these five buckets. Before classifying, read the comment body and — for threads — the referenced file and line.
50
+ - **Fixed** already addressed in a prior commit; no new work needed.
51
+ - **Actionable** real issue, not yet fixed; proceed to step 4.
52
+ - **Not relevant** does not apply to this PR (e.g. comment is about unrelated code).
53
+ - **Outdated** — refers to code that no longer exists.
54
+ - **Acknowledge** — real comment, intentionally not acting on it (e.g. reviewer flagged it as "won't fix" or "not worth it," scope-out decision, deferring to a follow-up PR). Record the one-sentence reason — you will include it in the step 7 report so the user can override.
55
+
56
+ Every item returned by step 2 **must** land in one of these buckets. Do not carry an item forward as "unclassified" or silently skip it. If you genuinely can't decide, that's the Acknowledge bucket with reason "unclear — flagging for human review."
57
+
58
+ **Review summaries** (`reviewSummaries`): these are PR-level overview bodies from COMMENTED reviews. Bot-generated summaries (authors like `copilot-pull-request-reviewer`, `gemini-code-assist`, or other bot accounts) are almost always noise — default them to **Acknowledge** with reason "bot summary — no actionable content" unless the body explicitly calls out an unaddressed issue. Human-authored review summaries should be read carefully and classified like any other item.
53
59
 
54
60
  4. **Fix actionable items.** For each Actionable item:
55
61
  - Read the relevant file(s) and apply the fix (Edit/Write tools)
@@ -59,29 +65,38 @@ Resolve unresolved review threads and minimize PR comments on the current PR —
59
65
  5. **Commit and push** (only if code was changed):
60
66
  - `git add <file1> <file2> …` (NOT `git add -A`)
61
67
  - `git commit -m "<appropriate commit message>"`
68
+ - If the fixes alter the PR's scope or intent, run `gh pr edit <N> --title "<new title>" --body "<new body>"` to keep the PR title and description in sync with what was committed. Skip if the existing text still accurately describes the PR.
62
69
  - `git fetch origin && git rebase origin/$BASE_BRANCH && git push --force-with-lease`
63
70
  - Cancel stale CI runs: `gh run list --branch "$BRANCH" --status in_progress --json databaseId --jq '.[].databaseId' | xargs -I{} gh run cancel {}`
64
71
 
65
- 6. **Resolve all verified items** — **only after the push, and only if at least one of the three ID lists is non-empty.** If all lists are empty, skip this step entirely (running resolve with no mutation IDs enters fetch mode as a side effect). Build the command from the non-empty ID lists; omit any flag whose list is empty:
72
+ 6. **Resolve all verified items** — **only if at least one of the three ID lists is non-empty.** If all lists are empty, skip this step entirely (running resolve with no mutation IDs enters fetch mode as a side effect). Build the command from the non-empty ID lists; omit any flag whose list is empty. For Fixed items, this step runs only after the push; Acknowledge / Not relevant / Outdated items can be resolved without a push (and therefore without `--require-sha`).
73
+
74
+ Each bucket maps to a mutation flag:
75
+ - **Fixed** threads → `--resolve-thread-ids`; Fixed comments → `--minimize-comment-ids`; Fixed reviews (CHANGES_REQUESTED) → `--dismiss-review-ids --message "<what you changed>"`.
76
+ - **Acknowledge / Not relevant / Outdated** threads → `--resolve-thread-ids`; same-bucket comments → `--minimize-comment-ids`; same-bucket reviews (CHANGES_REQUESTED) → `--dismiss-review-ids --message "<why you're not acting>"`.
77
+ - **Review summaries** in any bucket (Fixed, Acknowledge, Not relevant, Outdated) → `--minimize-comment-ids`. Review summary IDs (`PRR_…` from `reviewSummaries`) are passed here, not to `--dismiss-review-ids`. Do not pass review summary IDs to `--dismiss-review-ids` — that flag is only for CHANGES_REQUESTED reviews.
66
78
 
67
79
  ```bash
68
80
  npx pr-shepherd resolve <N> \
69
81
  --resolve-thread-ids <comma-separated-IDs> \
70
82
  --minimize-comment-ids <comma-separated-IDs> \
71
83
  --dismiss-review-ids <comma-separated-IDs> \
72
- --message "<specific description of the fix that addressed this review>" \
84
+ --message "<specific description of the fix OR the reason you're not acting>" \
73
85
  --require-sha $(git rev-parse HEAD)
74
86
  ```
75
87
 
76
- `--message` belongs **only** with `--dismiss-review-ids`. Omit it entirely when not dismissing a review. When you are dismissing, write one sentence describing the actual fix the text is sent to GitHub as the dismissal reason and is shown to the reviewer. Generic text like `"Addressed in <SHA>"` or `"address review comments"` is not acceptable.
88
+ `--message` belongs **only** with `--dismiss-review-ids`. Omit it entirely when not dismissing a review. When you are dismissing, write one sentence — either describing the actual fix (for Fixed) or the concrete reason for not acting (for Acknowledge). The text is sent to GitHub as the dismissal reason and is shown to the reviewer. Generic text like `"Addressed in <SHA>"` or `"address review comments"` is not acceptable.
89
+
90
+ Include `--require-sha $(git rev-parse HEAD)` whenever a push happened in step 5 (it gates the whole command, not per-item — safe to mix Fixed and Acknowledge IDs under one `--require-sha`). Omit it when no code changed.
77
91
 
78
- The `--require-sha` flag ensures pr-shepherd verifies GitHub has the new commit before resolving.
92
+ 7. **Report results.** Echo the CLI's output, then append a one-line summary per Acknowledge item: `Acknowledged <threadId|commentId|reviewId> (@<author>): <reason>`. This surfaces the decisions so the user can override any that were wrong.
79
93
 
80
- 7. **Report results** from the CLI output.
94
+ If any fetched item was neither resolved nor acknowledged (step 3 is supposed to prevent this, but guard against it), **stop and escalate** to the user: `<N> item(s) fetched but not acted on or acknowledged — need human direction before closing`. Do not silently drop items.
81
95
 
82
96
  ## Rules
83
97
 
84
- - NEVER resolve threads before pushing fixes (use `--require-sha`).
98
+ - NEVER resolve **Fixed** threads before pushing the fix (use `--require-sha`). Acknowledge / Not relevant / Outdated do not require a push and omit `--require-sha`.
85
99
  - NEVER blindly resolve items — always read and verify first.
100
+ - NEVER silently skip a fetched item. Every item must be resolved, acknowledged with a reason, or escalated.
86
101
  - Resolve from ALL authors — bots, AI reviewers, and humans alike.
87
- - `--message` is required when using `--dismiss-review-ids`, and must NOT be passed otherwise. The CLI throws if it is missing during dismissal. The message must describe the specific change that addressed the review (e.g. `"Added null check in handler.ts:42"`); generic boilerplate like `"address review comments"` or `"Addressed in <SHA>"` is reviewer-hostile and forbidden.
102
+ - `--message` is required when using `--dismiss-review-ids`, and must NOT be passed otherwise. The CLI throws if it is missing during dismissal. The message must describe the specific change that addressed the review or the concrete reason for not acting (e.g. `"Added null check in handler.ts:42"`, or `"Acknowledged as won't-fix — reviewer noted not worth refactoring"`); generic boilerplate like `"address review comments"` or `"Addressed in <SHA>"` is reviewer-hostile and forbidden.