pr-shepherd 0.8.0 → 0.9.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,18 +1,3 @@
1
- /**
2
- * `shepherd resolve [PR] [flags]`
3
- *
4
- * Two modes:
5
- *
6
- * Fetch mode (--fetch or no mutation flags):
7
- * Auto-resolves outdated threads and returns all active threads,
8
- * visible comments, and CHANGES_REQUESTED reviews for LLM triage.
9
- * Sonnet reads this output, applies code fixes, pushes, then calls
10
- * resolve in mutation mode to resolve/minimize/dismiss by ID.
11
- *
12
- * Mutation mode (--resolve-thread-ids, --minimize-comment-ids, --dismiss-review-ids):
13
- * Resolves/minimizes/dismisses by ID. Optionally verifies the push
14
- * has landed on GitHub before mutating (--require-sha).
15
- */
16
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
17
2
  import { fetchPrBatch } from "../github/batch.mjs";
18
3
  import { getOutdatedThreads } from "../comments/outdated.mjs";
@@ -20,6 +5,7 @@ import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mj
20
5
  import { loadConfig } from "../config/load.mjs";
21
6
  import { parseSuggestion } from "../suggestions/parse.mjs";
22
7
  import { buildFetchInstructions } from "./resolve-instructions.mjs";
8
+ import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
23
9
  /**
24
10
  * Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
25
11
  */
@@ -31,12 +17,26 @@ export async function runResolveFetch(opts) {
31
17
  }
32
18
  // Always bypass cache for resolve — we need fresh data before mutating.
33
19
  const { data } = await fetchPrBatch(prNumber, repo);
20
+ const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
34
21
  const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
35
22
  const visibleComments = data.comments.filter((c) => !c.isMinimized);
36
- // Auto-resolve outdated.
23
+ // First-look: collect items that would normally be hidden and check seen markers.
24
+ const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
25
+ const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
26
+ const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
27
+ const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
28
+ const seenSet = await loadSeenSet(stateKey);
29
+ const unseenOutdated = outdatedCandidates.filter((t) => !seenSet.has(t.id));
30
+ const unseenResolved = resolvedCandidates.filter((t) => !seenSet.has(t.id));
31
+ const unseenMinimizedThreads = minimizedThreadCandidates.filter((t) => !seenSet.has(t.id));
32
+ const unseenMinimizedComments = minimizedCommentCandidates.filter((c) => !seenSet.has(c.id));
33
+ // Auto-resolve outdated (same as before — fires regardless of first-look status).
37
34
  const outdated = getOutdatedThreads(unresolvedThreads);
35
+ const autoResolvedIds = new Set();
38
36
  if (outdated.length > 0) {
39
- const { errors } = await autoResolveOutdated(outdated.map((t) => t.id));
37
+ const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
38
+ for (const id of resolvedIds)
39
+ autoResolvedIds.add(id);
40
40
  if (errors.length > 0) {
41
41
  process.stderr.write(`pr-shepherd: auto-resolve outdated threads failed (continuing): ${errors.join(", ")}\n`);
42
42
  }
@@ -50,10 +50,30 @@ export async function runResolveFetch(opts) {
50
50
  thread.suggestion = suggestion;
51
51
  return thread;
52
52
  });
53
+ const firstLookThreads = [
54
+ ...unseenOutdated.map((t) => ({
55
+ ...t,
56
+ firstLookStatus: "outdated",
57
+ autoResolved: autoResolvedIds.has(t.id),
58
+ })),
59
+ ...unseenResolved.map((t) => ({ ...t, firstLookStatus: "resolved" })),
60
+ ...unseenMinimizedThreads.map((t) => ({ ...t, firstLookStatus: "minimized" })),
61
+ ];
62
+ const firstLookComments = unseenMinimizedComments.map((c) => ({
63
+ ...c,
64
+ firstLookStatus: "minimized",
65
+ }));
66
+ // Mark first-look items as seen (best-effort — markSeen never throws).
67
+ await Promise.allSettled([
68
+ ...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
69
+ ...firstLookComments.map((c) => markSeen(stateKey, c.id)),
70
+ ]);
53
71
  const result = {
54
72
  prNumber,
55
73
  actionableThreads,
74
+ firstLookThreads,
56
75
  actionableComments: visibleComments,
76
+ firstLookComments,
57
77
  changesRequestedReviews: data.changesRequestedReviews,
58
78
  reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
59
79
  commitSuggestionsEnabled: cfg.actions.commitSuggestions,
@@ -102,7 +102,7 @@ export function deriveVerdict(s) {
102
102
  s.reviewDecision !== "CHANGES_REQUESTED") {
103
103
  return "READY";
104
104
  }
105
- if (s.mergeStateStatus === "BLOCKED")
105
+ if (s.mergeStateStatus === "BLOCKED" || s.mergeStateStatus === "HAS_HOOKS")
106
106
  return "BLOCKED";
107
107
  if (s.mergeStateStatus === "DIRTY")
108
108
  return "CONFLICTS";
package/bin/config.json CHANGED
@@ -3,11 +3,7 @@
3
3
  "cooldownSeconds": 30,
4
4
  "fixAttemptsPerThread": 3,
5
5
  "stallTimeoutMinutes": 30,
6
- "minimizeReviewSummaries": {
7
- "bots": true,
8
- "humans": true,
9
- "approvals": false
10
- }
6
+ "minimizeApprovals": false
11
7
  },
12
8
  "watch": {
13
9
  "interval": "4m",
@@ -24,7 +20,8 @@
24
20
  "fetchReviewSummaries": true
25
21
  },
26
22
  "checks": {
27
- "ciTriggerEvents": ["pull_request", "pull_request_target"]
23
+ "ciTriggerEvents": ["pull_request", "pull_request_target"],
24
+ "logTailLines": 200
28
25
  },
29
26
  "mergeStatus": {
30
27
  "blockingReviewerLogins": ["copilot"]
@@ -19,6 +19,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
19
19
  startLine: comment?.startLine ?? null,
20
20
  author: comment?.author?.login ?? "unknown",
21
21
  body: comment?.body ?? "",
22
+ url: comment?.url ?? "",
22
23
  createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
23
24
  };
24
25
  });
@@ -27,6 +28,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
27
28
  isMinimized: c.isMinimized,
28
29
  author: c.author?.login ?? "unknown",
29
30
  body: c.body,
31
+ url: c.url,
30
32
  createdAtUnix: parseCreatedAt(c.createdAt),
31
33
  }));
32
34
  const changesRequestedReviews = rawReviewNodes.map((r) => ({
@@ -58,6 +58,7 @@ query BatchPr(
58
58
  nodes {
59
59
  id
60
60
  isMinimized
61
+ url
61
62
  author {
62
63
  login
63
64
  }
@@ -78,6 +79,7 @@ query BatchPr(
78
79
  nodes {
79
80
  id
80
81
  isMinimized
82
+ url
81
83
  author {
82
84
  login
83
85
  }
@@ -8,38 +8,33 @@
8
8
  * The original domain types are preserved for check command output.
9
9
  */
10
10
  export function toAgentThread(t) {
11
- return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body };
11
+ return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body, url: t.url };
12
12
  }
13
13
  export function toAgentComment(c) {
14
- return { id: c.id, author: c.author, body: c.body };
14
+ return { id: c.id, author: c.author, body: c.body, url: c.url };
15
15
  }
16
16
  export function toAgentCheck(c) {
17
17
  return {
18
18
  name: c.name,
19
19
  runId: c.runId,
20
20
  detailsUrl: c.detailsUrl,
21
- failureKind: c.failureKind,
22
21
  ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
22
+ ...(c.jobName !== undefined && { jobName: c.jobName }),
23
23
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
24
24
  ...(c.summary !== undefined && { summary: c.summary }),
25
+ ...(c.logTail !== undefined && { logTail: c.logTail }),
25
26
  };
26
27
  }
27
28
  /**
28
- * Project and deduplicate checks so the agent makes one `gh run view` call
29
- * per run (dedup by runId) and skips duplicate external status checks (dedup
30
- * by name when runId is null).
29
+ * Project failing checks for the agent. Deduplicates only null-runId external
30
+ * checks by name when runId is present each check may have a distinct job and
31
+ * log tail, so they are all kept.
31
32
  */
32
33
  export function toAgentChecks(checks) {
33
- const seenRunIds = new Set();
34
34
  const seenNames = new Set();
35
35
  const result = [];
36
36
  for (const c of checks) {
37
- if (c.runId !== null) {
38
- if (seenRunIds.has(c.runId))
39
- continue;
40
- seenRunIds.add(c.runId);
41
- }
42
- else {
37
+ if (c.runId === null) {
43
38
  if (seenNames.has(c.name))
44
39
  continue;
45
40
  seenNames.add(c.name);
@@ -26,20 +26,15 @@ export function buildCheckInstructions(report) {
26
26
  }
27
27
  // 3. CI budget policy — one instruction per failing check
28
28
  for (const c of checks.failing) {
29
- if (c.failureKind === "actionable") {
30
- const diagnosisHint = c.failedStep
31
- ? `the failure was in step \`${c.failedStep}\` fetch the run log with \`gh run view ${c.runId ?? "<runId>"} --log-failed\` to diagnose`
32
- : c.runId
33
- ? `fetch the run log with \`gh run view ${c.runId} --log-failed\` to diagnose the failure`
34
- : `open the check details (${c.detailsUrl}) to diagnose the failure`;
35
- instructions.push(`Fix code failure: \`${c.name}\` (actionable) ${diagnosisHint} and apply a fix.`);
36
- }
37
- else if (c.failureKind === "cancelled" || c.failureKind === "timeout") {
38
- const rerunCmd = c.runId
39
- ? `gh run rerun ${c.runId} --failed`
40
- : `gh run rerun <runId> --failed`;
41
- instructions.push(`Re-run transient failure: \`${c.name}\` [${c.failureKind}] — run \`${rerunCmd}\`.`);
42
- }
29
+ const stepHint = c.failedStep ? ` (failed step: \`${c.failedStep}\`)` : "";
30
+ const diagnosisHint = c.runId
31
+ ? c.logTail !== undefined
32
+ ? `examine the log tail${stepHint} — if transient, run \`gh run rerun ${c.runId} --failed\`; otherwise apply a fix`
33
+ : `run \`gh run view ${c.runId} --log-failed\`${stepHint} to diagnose — if transient, rerun with \`gh run rerun ${c.runId} --failed\`; otherwise apply a fix`
34
+ : c.detailsUrl
35
+ ? `open the check details (${c.detailsUrl}) to diagnose the failure`
36
+ : `no run or details URL available — escalate to a human`;
37
+ instructions.push(`Failing check: \`${c.name}\` ${diagnosisHint}.`);
43
38
  }
44
39
  // 4. Ready-to-merge gate
45
40
  const isClean = mergeStatus.mergeStateStatus === "CLEAN";
@@ -1,12 +1,10 @@
1
1
  import { buildCheckInstructions } from "./check-instructions.mjs";
2
2
  export function formatText(report) {
3
3
  const parts = [];
4
- // Header — scan-friendly status lines for quick at-a-glance review
5
4
  parts.push(`\nPR #${report.pr} — ${report.repo}`);
6
5
  parts.push(`Status: ${report.status}`);
7
6
  parts.push(`Base: ${report.baseBranch}`);
8
7
  parts.push("");
9
- // Merge status
10
8
  const ms = report.mergeStatus;
11
9
  parts.push("## Merge Status");
12
10
  parts.push("");
@@ -17,7 +15,6 @@ export function formatText(report) {
17
15
  parts.push(` isDraft: ${ms.isDraft}`);
18
16
  parts.push(` copilotReviewInProgress: ${ms.copilotReviewInProgress}`);
19
17
  parts.push("");
20
- // CI checks
21
18
  const { passing, failing, inProgress, skipped } = report.checks;
22
19
  const total = passing.length + failing.length + inProgress.length + skipped.length;
23
20
  parts.push("## CI Checks");
@@ -63,8 +60,7 @@ export function formatText(report) {
63
60
  }
64
61
  parts.push("");
65
62
  }
66
- // Review threads
67
- const { actionable: actionableThreads, autoResolved, autoResolveErrors } = report.threads;
63
+ const { actionable: actionableThreads, autoResolved, autoResolveErrors, firstLook: firstLookThreads, } = report.threads;
68
64
  const hasThreadSection = autoResolved.length > 0 || autoResolveErrors.length > 0 || actionableThreads.length > 0;
69
65
  if (hasThreadSection) {
70
66
  parts.push("## Review Threads");
@@ -94,8 +90,7 @@ export function formatText(report) {
94
90
  parts.push("");
95
91
  }
96
92
  }
97
- // PR comments
98
- const { actionable: actionableComments } = report.comments;
93
+ const { actionable: actionableComments, firstLook: firstLookComments } = report.comments;
99
94
  if (actionableComments.length > 0) {
100
95
  parts.push("## PR Comments");
101
96
  parts.push("");
@@ -106,7 +101,6 @@ export function formatText(report) {
106
101
  }
107
102
  parts.push("");
108
103
  }
109
- // CHANGES_REQUESTED reviews
110
104
  if (report.changesRequestedReviews.length > 0) {
111
105
  parts.push("## CHANGES_REQUESTED Reviews");
112
106
  parts.push("");
@@ -115,7 +109,6 @@ export function formatText(report) {
115
109
  }
116
110
  parts.push("");
117
111
  }
118
- // Review summaries
119
112
  if (report.reviewSummaries.length > 0) {
120
113
  parts.push("## Review Summaries");
121
114
  parts.push("");
@@ -124,7 +117,6 @@ export function formatText(report) {
124
117
  }
125
118
  parts.push("");
126
119
  }
127
- // Approved reviews
128
120
  if (report.approvedReviews.length > 0) {
129
121
  parts.push("## Approved Reviews");
130
122
  parts.push("");
@@ -133,15 +125,35 @@ export function formatText(report) {
133
125
  }
134
126
  parts.push("");
135
127
  }
136
- // Summary
128
+ const firstLookTotal = firstLookThreads.length + firstLookComments.length;
129
+ if (firstLookTotal > 0) {
130
+ parts.push("## First-look items");
131
+ parts.push("");
132
+ for (const t of firstLookThreads) {
133
+ const statusTag = t.autoResolved
134
+ ? `[status: outdated, auto-resolved]`
135
+ : `[status: ${t.firstLookStatus}]`;
136
+ const loc = t.path ? `${t.path}:${t.line ?? "?"}` : "(no location)";
137
+ parts.push(`- threadId=${t.id} ${loc} (@${t.author}) ${statusTag}`);
138
+ parts.push(` ${firstLine(t.body)}`);
139
+ }
140
+ for (const c of firstLookComments) {
141
+ parts.push(`- commentId=${c.id} (@${c.author}) [status: minimized]`);
142
+ parts.push(` ${firstLine(c.body)}`);
143
+ }
144
+ parts.push("");
145
+ }
137
146
  const totalActionable = actionableThreads.length + actionableComments.length + report.changesRequestedReviews.length;
138
147
  parts.push("## Summary");
139
148
  parts.push("");
140
- parts.push(totalActionable === 0
141
- ? "0 actionable — all threads resolved/minimized"
142
- : `${totalActionable} actionable item(s) remaining`);
149
+ const counts = [];
150
+ if (totalActionable > 0)
151
+ counts.push(`${totalActionable} actionable`);
152
+ if (firstLookTotal > 0)
153
+ counts.push(`${firstLookTotal} first-look`);
154
+ const summaryLine = counts.join(", ") || "0 actionable — all threads resolved/minimized";
155
+ parts.push(summaryLine);
143
156
  parts.push("");
144
- // Instructions
145
157
  parts.push("## Instructions");
146
158
  parts.push("");
147
159
  const instructions = buildCheckInstructions(report);
@@ -150,9 +162,6 @@ export function formatText(report) {
150
162
  });
151
163
  return parts.join("\n");
152
164
  }
153
- // ---------------------------------------------------------------------------
154
- // Helpers
155
- // ---------------------------------------------------------------------------
156
165
  function firstLine(text) {
157
166
  return (text.split("\n")[0] ?? "").trim().slice(0, 120);
158
167
  }
@@ -0,0 +1,76 @@
1
+ import { readFile, writeFile, mkdir, access, readdir } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { SAFE_SEGMENT } from "../util/path-segment.mjs";
5
+ // ---------------------------------------------------------------------------
6
+ // Public API
7
+ // ---------------------------------------------------------------------------
8
+ /**
9
+ * Read the seen/ directory once and return a Set of already-seen IDs.
10
+ * Prefer this over repeated hasSeen() calls to avoid EMFILE on large PRs.
11
+ * Returns an empty Set if the directory does not yet exist.
12
+ */
13
+ export async function loadSeenSet(key) {
14
+ try {
15
+ const dir = resolveDir(key);
16
+ const entries = await readdir(dir);
17
+ return new Set(entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5)));
18
+ }
19
+ catch {
20
+ return new Set();
21
+ }
22
+ }
23
+ /** Return true if a "seen" marker exists for this id. */
24
+ export async function hasSeen(key, id) {
25
+ try {
26
+ await access(resolvePath(key, id));
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /** Write a "seen" marker for this id. Idempotent — preserves original seenAt on double-write. */
34
+ export async function markSeen(key, id) {
35
+ try {
36
+ const path = resolvePath(key, id);
37
+ await mkdir(dirname(path), { recursive: true });
38
+ // O_EXCL: create-only — EEXIST means already marked, which is the idempotent success case.
39
+ // seenAt is unix milliseconds (Date.now()), matching JS convention for this module.
40
+ await writeFile(path, JSON.stringify({ seenAt: Date.now() }), { flag: "wx", encoding: "utf8" });
41
+ }
42
+ catch {
43
+ // EEXIST = already seen. All other errors are best-effort.
44
+ }
45
+ }
46
+ /** Read the full marker for inspection (returns null on miss or error). */
47
+ export async function readSeenMarker(key, id) {
48
+ try {
49
+ const raw = await readFile(resolvePath(key, id), "utf8");
50
+ return JSON.parse(raw);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+ function resolveDir(key) {
60
+ for (const [field, value] of [
61
+ ["owner", key.owner],
62
+ ["repo", key.repo],
63
+ ]) {
64
+ if (!SAFE_SEGMENT.test(value)) {
65
+ throw new Error(`Invalid state key segment "${field}": ${value}`);
66
+ }
67
+ }
68
+ const base = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
69
+ return join(base, `${key.owner}-${key.repo}`, String(key.pr), "seen");
70
+ }
71
+ function resolvePath(key, id) {
72
+ if (!SAFE_SEGMENT.test(id)) {
73
+ throw new Error(`Invalid state key segment "id": ${id}`);
74
+ }
75
+ return join(resolveDir(key), `${id}.json`);
76
+ }
@@ -1,2 +1 @@
1
- // Shepherd-specific report types, agent projections, and CLI options.
2
1
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Claude Code",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -1,31 +0,0 @@
1
- import { applyStallGuard } from "./stall.mjs";
2
- export async function buildRerunCiResult(transientChecks, base, prNumber, stallKey, stallTimeoutSeconds, headSha, report, reviewSummaryIds) {
3
- const runMap = new Map();
4
- for (const c of transientChecks) {
5
- if (c.runId === null)
6
- continue;
7
- const existing = runMap.get(c.runId);
8
- if (existing) {
9
- existing.checkNames.push(c.name);
10
- }
11
- else {
12
- runMap.set(c.runId, {
13
- runId: c.runId,
14
- checkNames: [c.name],
15
- failureKind: c.failureKind,
16
- workflowName: c.workflowName,
17
- });
18
- }
19
- }
20
- const reran = [...runMap.values()];
21
- const runSummaries = reran.map(({ runId, checkNames, failureKind, workflowName }) => {
22
- const prefix = workflowName ? `${workflowName} › ` : "";
23
- return `${runId} (${prefix}${checkNames.join(", ")} — ${failureKind})`;
24
- });
25
- return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
26
- ...base,
27
- action: "rerun_ci",
28
- reran,
29
- log: `RERUN NEEDED — ${reran.length} CI run${reran.length === 1 ? "" : "s"}: ${runSummaries.join(", ")}`,
30
- }, report, reviewSummaryIds);
31
- }