pr-shepherd 0.52.0 → 0.52.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 (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +11 -8
  3. package/bin/cli/help-command-pages.d.mts +1 -1
  4. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  5. package/bin/cli/help-iterate-poll-pages.mjs +4 -3
  6. package/bin/cli/help-top-page.d.mts +1 -1
  7. package/bin/cli/help-top-page.mjs +3 -2
  8. package/bin/cli/help.d.mts +2 -2
  9. package/bin/cli/iterate-instructions.mjs +41 -0
  10. package/bin/cli/iterate-lean.mjs +1 -0
  11. package/bin/cli/poll-summary-formatter.mjs +3 -1
  12. package/bin/cli/runner.d.mts +1 -0
  13. package/bin/cli/runner.mjs +3 -1
  14. package/bin/commands/check.mjs +13 -4
  15. package/bin/commands/clean.mjs +7 -13
  16. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  17. package/bin/commands/iterate/check-instructions.mjs +11 -7
  18. package/bin/commands/iterate/escalate.mjs +4 -0
  19. package/bin/commands/iterate/fix-code.mjs +6 -1
  20. package/bin/commands/iterate/helpers.d.mts +0 -1
  21. package/bin/commands/iterate/helpers.mjs +0 -15
  22. package/bin/commands/iterate/index.mjs +57 -27
  23. package/bin/commands/iterate/merge-state.mjs +6 -4
  24. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  25. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  26. package/bin/commands/iterate/parent-first.d.mts +18 -2
  27. package/bin/commands/iterate/parent-first.mjs +45 -32
  28. package/bin/commands/iterate/render.d.mts +1 -1
  29. package/bin/commands/iterate/render.mjs +7 -12
  30. package/bin/commands/iterate/stale-ancestry.d.mts +1 -1
  31. package/bin/commands/iterate/stale-ancestry.mjs +10 -7
  32. package/bin/commands/iterate/stall.mjs +40 -3
  33. package/bin/commands/poll-progress.d.mts +5 -1
  34. package/bin/commands/poll-progress.mjs +7 -1
  35. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  36. package/bin/commands/poll-summary-instructions.mjs +51 -87
  37. package/bin/commands/poll-summary.mjs +4 -2
  38. package/bin/commands/poll.mjs +3 -1
  39. package/bin/commands/ready-delay.d.mts +9 -4
  40. package/bin/commands/ready-delay.mjs +34 -20
  41. package/bin/commands/shepherd-journal.mjs +4 -1
  42. package/bin/commands/stack-drain.d.mts +35 -0
  43. package/bin/commands/stack-drain.mjs +129 -0
  44. package/bin/commands/stack-layer-readiness.d.mts +7 -0
  45. package/bin/commands/stack-layer-readiness.mjs +35 -0
  46. package/bin/commands/stack-stall.d.mts +14 -0
  47. package/bin/commands/stack-stall.mjs +69 -0
  48. package/bin/commands/stack-work.d.mts +32 -0
  49. package/bin/commands/stack-work.mjs +39 -0
  50. package/bin/config/load.d.mts +2 -0
  51. package/bin/config/load.mjs +10 -0
  52. package/bin/config.json +1 -0
  53. package/bin/github/batch-parsers.mjs +1 -1
  54. package/bin/github/batch-raw-rules.d.mts +0 -3
  55. package/bin/github/batch-raw-types.d.mts +2 -0
  56. package/bin/github/errors.d.mts +5 -0
  57. package/bin/github/errors.mjs +4 -0
  58. package/bin/github/gql/batch-pr.gql +1 -0
  59. package/bin/github/gql/poll-stack-summary.gql +8 -2
  60. package/bin/github/gql/poll-stack-topology.gql +38 -0
  61. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  62. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  63. package/bin/github/gql/poll-summary-fragment.gql +5 -62
  64. package/bin/github/gql/pr-merge-policy.gql +0 -3
  65. package/bin/github/merge-queue-checks.mjs +10 -1
  66. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  67. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  68. package/bin/github/poll-summary-fingerprint.mjs +24 -4
  69. package/bin/github/poll-summary-projector.mjs +8 -7
  70. package/bin/github/poll-summary-queue-removal.mjs +10 -1
  71. package/bin/github/poll-summary-raw.d.mts +19 -31
  72. package/bin/github/poll-summary-route.mjs +8 -5
  73. package/bin/github/poll-summary.mjs +16 -73
  74. package/bin/github/queries.d.mts +7 -0
  75. package/bin/github/queries.mjs +9 -1
  76. package/bin/github/queue-removal-freshness.d.mts +16 -0
  77. package/bin/github/queue-removal-freshness.mjs +26 -0
  78. package/bin/github/stack-read.d.mts +34 -0
  79. package/bin/github/stack-read.mjs +92 -0
  80. package/bin/log/log-file.d.mts +1 -1
  81. package/bin/log/log-file.mjs +4 -17
  82. package/bin/state/base.d.mts +18 -1
  83. package/bin/state/base.mjs +65 -13
  84. package/bin/state/fix-attempts.d.mts +1 -1
  85. package/bin/state/fix-attempts.mjs +1 -1
  86. package/bin/state/graphql-quota-warnings.mjs +2 -6
  87. package/bin/state/iterate-stall.d.mts +7 -15
  88. package/bin/state/iterate-stall.mjs +6 -64
  89. package/bin/state/rest-cache.d.mts +1 -1
  90. package/bin/state/rest-cache.mjs +1 -1
  91. package/bin/state/stack-stall.d.mts +16 -0
  92. package/bin/state/stack-stall.mjs +12 -0
  93. package/bin/state/stall-state-store.d.mts +37 -0
  94. package/bin/state/stall-state-store.mjs +74 -0
  95. package/bin/types/escalate.d.mts +1 -1
  96. package/bin/types/github.d.mts +1 -1
  97. package/bin/types/iterate.d.mts +2 -1
  98. package/bin/types/merge-requirements.d.mts +19 -0
  99. package/bin/types/poll-summary.d.mts +5 -0
  100. package/bin/types/report.d.mts +1 -1
  101. package/package.json +1 -1
  102. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  103. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  104. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -43,8 +43,23 @@ type RawCheckContext = {
43
43
  context: string;
44
44
  state: string;
45
45
  };
46
- interface RawCheckRollup {
47
- contexts: SummaryConnection<RawCheckContext>;
46
+ interface RawCheckContexts {
47
+ totalCount: number;
48
+ /** `startCursor` pages older contexts; hydration drops it so fingerprints never hash a cursor. */
49
+ pageInfo: {
50
+ hasPreviousPage: boolean;
51
+ startCursor?: string | null;
52
+ };
53
+ nodes: RawCheckContext[];
54
+ }
55
+ export interface RawCheckRollup {
56
+ contexts: RawCheckContexts;
57
+ }
58
+ /** A commit whose status contexts the compact summary reads. */
59
+ export interface RawSummaryCommit {
60
+ oid: string;
61
+ committedDate?: string;
62
+ statusCheckRollup: RawCheckRollup | null;
48
63
  }
49
64
  export interface RawSummaryPr {
50
65
  number: number;
@@ -103,9 +118,7 @@ export interface RawSummaryPr {
103
118
  }>;
104
119
  } | null;
105
120
  mergeQueueEntry: {
106
- headCommit: {
107
- statusCheckRollup: RawCheckRollup | null;
108
- } | null;
121
+ headCommit: RawSummaryCommit | null;
109
122
  } | null;
110
123
  stack: {
111
124
  number: number;
@@ -131,9 +144,7 @@ export interface RawSummaryPr {
131
144
  }>;
132
145
  commits: {
133
146
  nodes: Array<{
134
- commit: {
135
- statusCheckRollup: RawCheckRollup | null;
136
- };
147
+ commit: RawSummaryCommit;
137
148
  }>;
138
149
  };
139
150
  }
@@ -142,27 +153,4 @@ export interface RawExplicitResponse {
142
153
  viewerCanAdminister: boolean;
143
154
  } & Record<string, RawSummaryPr | boolean | null>) | null;
144
155
  }
145
- export interface RawStackResponse {
146
- repository: {
147
- viewerCanAdminister: boolean;
148
- pullRequest: {
149
- stack: {
150
- id: string;
151
- number: number;
152
- size: number;
153
- baseRefName: string;
154
- entries: {
155
- pageInfo: {
156
- hasNextPage: boolean;
157
- endCursor: string | null;
158
- };
159
- nodes: Array<{
160
- position: number;
161
- pullRequest: RawSummaryPr | null;
162
- }>;
163
- };
164
- } | null;
165
- } | null;
166
- } | null;
167
- }
168
156
  export {};
@@ -25,12 +25,15 @@ export function routePollSummary(raw, checks, review, opts) {
25
25
  return { action: "wait", reasons: ["pending-or-unknown"] };
26
26
  }
27
27
  if (raw.isDraft) {
28
- if (opts.noAutoMarkReady || actions.autoMarkReady === false) {
29
- return { action: "wait", reasons: ["draft-auto-mark-ready-disabled"] };
28
+ const autoMarkReadyDisabled = opts.noAutoMarkReady || actions.autoMarkReady === false;
29
+ // The stack selector asks the agent to mark a disabled draft ready, so
30
+ // that transition needs the same capability as the automatic one.
31
+ if (!raw.viewerCanUpdate && (!autoMarkReadyDisabled || opts.stackPrNumber !== undefined)) {
32
+ return { action: "escalate", reasons: ["mark-ready-authorization-required"] };
30
33
  }
31
- return raw.viewerCanUpdate
32
- ? { action: "mark_ready", reasons: ["draft-appears-ready"] }
33
- : { action: "escalate", reasons: ["mark-ready-authorization-required"] };
34
+ return autoMarkReadyDisabled
35
+ ? { action: "wait", reasons: ["draft-auto-mark-ready-disabled"] }
36
+ : { action: "mark_ready", reasons: ["draft-appears-ready"] };
34
37
  }
35
38
  if (opts.merge && raw.isInMergeQueue) {
36
39
  return { action: "wait", reasons: ["already-in-merge-queue"] };
@@ -1,8 +1,10 @@
1
1
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
2
  import { graphqlWithRateLimit } from "./client.mjs";
3
- import { GitHubRequestError } from "./errors.mjs";
3
+ import { missingRepositoryError } from "./errors.mjs";
4
+ import { hydratePollSummaryChecks } from "./poll-summary-check-hydration.mjs";
4
5
  import { summarizePollSummaryPr } from "./poll-summary-projector.mjs";
5
6
  import { POLL_STACK_SUMMARY_QUERY, POLL_SUMMARY_FRAGMENT } from "./queries.mjs";
7
+ import { MAX_STACK_ENTRIES_PER_PAGE, readStack, readStackTopology, stackAncestryGaps, } from "./stack-read.mjs";
6
8
  const MAX_EXPLICIT_PRS_PER_QUERY = 50;
7
9
  export async function fetchPollSummary(opts, repo) {
8
10
  if (opts.stackPrNumber !== undefined)
@@ -42,93 +44,34 @@ async function fetchExplicitChunk(prs, repo) {
42
44
  ...variables,
43
45
  });
44
46
  if (!result.data.repository)
45
- throw missingRepository(repo);
47
+ throw missingRepositoryError(repo);
46
48
  const rawPrs = prs.map((pr, index) => {
47
49
  const raw = result.data.repository[`pr${index}`];
48
50
  if (!raw)
49
51
  throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
50
52
  return raw;
51
53
  });
54
+ for (const raw of rawPrs)
55
+ await hydratePollSummaryChecks(raw, repo);
52
56
  return { prs: rawPrs, viewerCanAdminister: result.data.repository.viewerCanAdminister };
53
57
  }
58
+ /**
59
+ * Read the stack's topology first so the hydrated page requests only as many
60
+ * entries as the stack holds: GitHub prices `first`, not the nodes returned.
61
+ */
54
62
  async function fetchStackSummary(opts, repo) {
55
63
  const anchor = opts.stackPrNumber;
56
- let after = null;
57
- let stackId = null;
58
- let stackNumber = 0;
59
- let stackSize = 0;
60
- let viewerCanAdminister = false;
61
- const entries = [];
62
- do {
63
- const response = (await graphqlWithRateLimit(POLL_STACK_SUMMARY_QUERY, {
64
- owner: repo.owner,
65
- repo: repo.name,
66
- anchor,
67
- after,
68
- }));
69
- const repository = response.data.repository;
70
- if (!repository)
71
- throw missingRepository(repo);
72
- viewerCanAdminister = repository.viewerCanAdminister;
73
- if (!repository.pullRequest) {
74
- throw new ShepherdError(`PR #${anchor} not found`, EXIT.UNAVAILABLE);
75
- }
76
- const stack = repository.pullRequest.stack;
77
- if (!stack) {
78
- throw new ShepherdError(`PR #${anchor} is not part of a native GitHub stack`, EXIT.UNAVAILABLE);
79
- }
80
- if (stackId !== null && stack.id !== stackId) {
81
- throw new ShepherdError("GitHub stack membership changed while it was being fetched; retry", EXIT.TEMPFAIL);
82
- }
83
- stackId = stack.id;
84
- stackNumber = stack.number;
85
- stackSize = stack.size;
86
- for (const entry of stack.entries.nodes) {
87
- if (!entry.pullRequest) {
88
- throw new ShepherdError("GitHub returned an incomplete pull-request stack entry", EXIT.TEMPFAIL);
89
- }
90
- entries.push({ position: entry.position, pullRequest: entry.pullRequest });
91
- }
92
- const pageInfo = stack.entries.pageInfo;
93
- if (pageInfo.hasNextPage && !pageInfo.endCursor) {
94
- throw new ShepherdError("GitHub stack pagination did not include an end cursor", EXIT.TEMPFAIL);
95
- }
96
- after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
97
- } while (after !== null);
98
- const unique = new Map();
99
- for (const entry of entries)
100
- unique.set(entry.pullRequest.number, entry);
101
- if (!unique.has(anchor) || unique.size !== stackSize) {
102
- throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
103
- }
104
- const ordered = [...unique.values()].sort((left, right) => left.position - right.position);
105
- const stackAncestry = [];
106
- for (let index = 1; index < ordered.length; index++) {
107
- const parent = ordered[index - 1].pullRequest;
108
- const child = ordered[index].pullRequest;
109
- if (parent.state !== "OPEN" || child.state !== "OPEN")
110
- continue;
111
- if (child.baseRefName === parent.headRefName && child.baseRefOid === parent.headRefOid) {
112
- continue;
113
- }
114
- stackAncestry.push({
115
- parentPr: parent.number,
116
- parentHeadRefName: parent.headRefName,
117
- parentHeadRefOid: parent.headRefOid,
118
- childPr: child.number,
119
- childBaseRefName: child.baseRefName,
120
- childBaseRefOid: child.baseRefOid,
121
- });
122
- }
64
+ const topology = await readStackTopology(anchor, repo);
65
+ const { stackNumber, stackSize, viewerCanAdminister, ordered } = await readStack(POLL_STACK_SUMMARY_QUERY, anchor, repo, { first: Math.min(topology.stackSize, MAX_STACK_ENTRIES_PER_PAGE) });
66
+ for (const pr of ordered)
67
+ await hydratePollSummaryChecks(pr, repo);
68
+ const stackAncestry = stackAncestryGaps(ordered);
123
69
  return {
124
70
  selection: { kind: "stack", anchor, stackNumber, stackSize },
125
- prs: await Promise.all(ordered.map((entry) => summarizePollSummaryPr(entry.pullRequest, repo, opts, viewerCanAdminister))),
71
+ prs: await Promise.all(ordered.map((pr) => summarizePollSummaryPr(pr, repo, opts, viewerCanAdminister))),
126
72
  ...(stackAncestry.length > 0 && { stackAncestry }),
127
73
  };
128
74
  }
129
75
  function deduplicate(values) {
130
76
  return [...new Set(values)];
131
77
  }
132
- function missingRepository(repo) {
133
- return new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
134
- }
@@ -14,8 +14,15 @@ export declare const BATCH_PR_PAGE_QUERY: string;
14
14
  export declare const PR_FINGERPRINT_QUERY: string;
15
15
  /** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
16
16
  export declare const POLL_SUMMARY_FRAGMENT: string;
17
+ /**
18
+ * Pages older status contexts for one commit with the same node selection as
19
+ * the compact summary, so hydrated and first-page nodes fingerprint alike.
20
+ */
21
+ export declare const POLL_SUMMARY_CHECK_PAGE_QUERY: string;
17
22
  /** Discovers and summarizes every entry in one native GitHub pull-request stack. */
18
23
  export declare const POLL_STACK_SUMMARY_QUERY: string;
24
+ /** Reads only native-stack membership and the refs that link adjacent entries. */
25
+ export declare const POLL_STACK_TOPOLOGY_QUERY: string;
19
26
  /** PR head fields plus a single review thread for `commit-suggestion`. */
20
27
  export declare const SUGGESTION_THREADS_QUERY: string;
21
28
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -16,10 +16,18 @@ export const BATCH_PR_QUERY = withSharedFragments(gql("batch-pr.gql"));
16
16
  export const BATCH_PR_PAGE_QUERY = gql("batch-pr-page.gql");
17
17
  /** Cheap PR fingerprint used to skip an unchanged BatchPr snapshot. */
18
18
  export const PR_FINGERPRINT_QUERY = withSharedFragments(gql("pr-fingerprint.gql"));
19
+ const POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT = gql("poll-summary-check-contexts.gql");
19
20
  /** Compact per-PR fields shared by explicit-list and native-stack summary queries. */
20
- export const POLL_SUMMARY_FRAGMENT = gql("poll-summary-fragment.gql");
21
+ export const POLL_SUMMARY_FRAGMENT = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-fragment.gql")}`;
22
+ /**
23
+ * Pages older status contexts for one commit with the same node selection as
24
+ * the compact summary, so hydrated and first-page nodes fingerprint alike.
25
+ */
26
+ export const POLL_SUMMARY_CHECK_PAGE_QUERY = `${POLL_SUMMARY_CHECK_CONTEXTS_FRAGMENT}\n${gql("poll-summary-check-page.gql")}`;
21
27
  /** Discovers and summarizes every entry in one native GitHub pull-request stack. */
22
28
  export const POLL_STACK_SUMMARY_QUERY = `${POLL_SUMMARY_FRAGMENT}\n${gql("poll-stack-summary.gql")}`;
29
+ /** Reads only native-stack membership and the refs that link adjacent entries. */
30
+ export const POLL_STACK_TOPOLOGY_QUERY = gql("poll-stack-topology.gql");
23
31
  /** PR head fields plus a single review thread for `commit-suggestion`. */
24
32
  export const SUGGESTION_THREADS_QUERY = gql("suggestion-threads.gql");
25
33
  /** Fetches additional comments for a single review thread when its nested connection paginates. */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Whether a removed merge-queue commit still belongs to the current PR head.
3
+ *
4
+ * A merge-commit queue lists that head as a parent. Squash and rebase queues
5
+ * build a one-parent commit on the base (or on the previous group commit), so
6
+ * the PR head is not an ancestor. Missing parents are unverifiable: GitHub
7
+ * keeps returning the latest removal after the synthetic commit is gone.
8
+ * A one-parent removal is stale when the current head's committer time is
9
+ * later than the removal, which is a push after the ejection.
10
+ */
11
+ export declare function queueRemovalAppliesToHead(input: {
12
+ parentOids: readonly string[] | null | undefined;
13
+ headOid: string;
14
+ headCommittedAtUnix?: number;
15
+ removedAtUnix?: number;
16
+ }): boolean;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Whether a removed merge-queue commit still belongs to the current PR head.
3
+ *
4
+ * A merge-commit queue lists that head as a parent. Squash and rebase queues
5
+ * build a one-parent commit on the base (or on the previous group commit), so
6
+ * the PR head is not an ancestor. Missing parents are unverifiable: GitHub
7
+ * keeps returning the latest removal after the synthetic commit is gone.
8
+ * A one-parent removal is stale when the current head's committer time is
9
+ * later than the removal, which is a push after the ejection.
10
+ */
11
+ export function queueRemovalAppliesToHead(input) {
12
+ const parents = input.parentOids ?? [];
13
+ if (parents.length === 0)
14
+ return false;
15
+ if (parents.includes(input.headOid))
16
+ return true;
17
+ if (parents.length > 1)
18
+ return false;
19
+ if (input.headCommittedAtUnix !== undefined &&
20
+ input.removedAtUnix !== undefined &&
21
+ input.removedAtUnix > 0 &&
22
+ input.headCommittedAtUnix > input.removedAtUnix) {
23
+ return false;
24
+ }
25
+ return true;
26
+ }
@@ -0,0 +1,34 @@
1
+ import type { PollSummaryStackAncestry } from "../types.mts";
2
+ import { type RepoInfo } from "./client.mts";
3
+ /** GitHub's `first` ceiling for one native-stack entries page. */
4
+ export declare const MAX_STACK_ENTRIES_PER_PAGE = 50;
5
+ /** Fields every native-stack read needs to validate membership and adjacent ancestry. */
6
+ export interface StackMemberRefs {
7
+ number: number;
8
+ state: string;
9
+ headRefName: string;
10
+ headRefOid: string;
11
+ baseRefName: string;
12
+ baseRefOid: string;
13
+ }
14
+ /** One complete, validated native-stack membership read. */
15
+ export interface StackRead<Pr extends StackMemberRefs> {
16
+ stackNumber: number;
17
+ stackSize: number;
18
+ viewerCanAdminister: boolean;
19
+ /** Every member, bottom-to-top, each observed in the same paged read. */
20
+ ordered: Pr[];
21
+ }
22
+ /**
23
+ * Page through one native stack and fail closed on anything short of the full
24
+ * membership: a missing repository, PR, or stack; a changed stack identity; a
25
+ * null entry; a page without a cursor; or fewer unique members than `size`.
26
+ */
27
+ export declare function readStack<Pr extends StackMemberRefs>(query: string, anchor: number, repo: RepoInfo, variables?: Record<string, unknown>): Promise<StackRead<Pr>>;
28
+ /** Membership and linking refs only — no per-PR CI or review hydration. */
29
+ export declare function readStackTopology(anchor: number, repo: RepoInfo): Promise<StackRead<StackMemberRefs>>;
30
+ /**
31
+ * Adjacent entries, both open, whose child base no longer matches the parent
32
+ * head. Both sides come from the same read, so each gap was observed together.
33
+ */
34
+ export declare function stackAncestryGaps(ordered: StackMemberRefs[]): PollSummaryStackAncestry[];
@@ -0,0 +1,92 @@
1
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
+ import { graphqlWithRateLimit } from "./client.mjs";
3
+ import { missingRepositoryError } from "./errors.mjs";
4
+ import { POLL_STACK_TOPOLOGY_QUERY } from "./queries.mjs";
5
+ /** GitHub's `first` ceiling for one native-stack entries page. */
6
+ export const MAX_STACK_ENTRIES_PER_PAGE = 50;
7
+ /**
8
+ * Page through one native stack and fail closed on anything short of the full
9
+ * membership: a missing repository, PR, or stack; a changed stack identity; a
10
+ * null entry; a page without a cursor; or fewer unique members than `size`.
11
+ */
12
+ export async function readStack(query, anchor, repo, variables = {}) {
13
+ let after = null;
14
+ let stackId = null;
15
+ let stackNumber = 0;
16
+ let stackSize = 0;
17
+ let viewerCanAdminister = false;
18
+ const entries = [];
19
+ do {
20
+ const response = await graphqlWithRateLimit(query, { owner: repo.owner, repo: repo.name, anchor, after, ...variables });
21
+ const repository = response.data.repository;
22
+ if (!repository)
23
+ throw missingRepositoryError(repo);
24
+ viewerCanAdminister = repository.viewerCanAdminister;
25
+ if (!repository.pullRequest) {
26
+ throw new ShepherdError(`PR #${anchor} not found`, EXIT.UNAVAILABLE);
27
+ }
28
+ const stack = repository.pullRequest.stack;
29
+ if (!stack) {
30
+ throw new ShepherdError(`PR #${anchor} is not part of a native GitHub stack`, EXIT.UNAVAILABLE);
31
+ }
32
+ if (stackId !== null && stack.id !== stackId) {
33
+ throw new ShepherdError("GitHub stack membership changed while it was being fetched; retry", EXIT.TEMPFAIL);
34
+ }
35
+ stackId = stack.id;
36
+ stackNumber = stack.number;
37
+ stackSize = stack.size;
38
+ for (const entry of stack.entries.nodes) {
39
+ if (!entry.pullRequest) {
40
+ throw new ShepherdError("GitHub returned an incomplete pull-request stack entry", EXIT.TEMPFAIL);
41
+ }
42
+ entries.push({ position: entry.position, pullRequest: entry.pullRequest });
43
+ }
44
+ const pageInfo = stack.entries.pageInfo;
45
+ if (pageInfo.hasNextPage && !pageInfo.endCursor) {
46
+ throw new ShepherdError("GitHub stack pagination did not include an end cursor", EXIT.TEMPFAIL);
47
+ }
48
+ if (pageInfo.hasNextPage && pageInfo.endCursor === after) {
49
+ throw new ShepherdError("GitHub stack pagination returned a repeated end cursor", EXIT.TEMPFAIL);
50
+ }
51
+ after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
52
+ } while (after !== null);
53
+ const unique = new Map();
54
+ for (const entry of entries)
55
+ unique.set(entry.pullRequest.number, entry);
56
+ if (!unique.has(anchor) || unique.size !== stackSize) {
57
+ throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
58
+ }
59
+ const ordered = [...unique.values()]
60
+ .sort((left, right) => left.position - right.position)
61
+ .map((entry) => entry.pullRequest);
62
+ return { stackNumber, stackSize, viewerCanAdminister, ordered };
63
+ }
64
+ /** Membership and linking refs only — no per-PR CI or review hydration. */
65
+ export function readStackTopology(anchor, repo) {
66
+ return readStack(POLL_STACK_TOPOLOGY_QUERY, anchor, repo);
67
+ }
68
+ /**
69
+ * Adjacent entries, both open, whose child base no longer matches the parent
70
+ * head. Both sides come from the same read, so each gap was observed together.
71
+ */
72
+ export function stackAncestryGaps(ordered) {
73
+ const gaps = [];
74
+ for (let index = 1; index < ordered.length; index++) {
75
+ const parent = ordered[index - 1];
76
+ const child = ordered[index];
77
+ if (parent.state !== "OPEN" || child.state !== "OPEN")
78
+ continue;
79
+ if (child.baseRefName === parent.headRefName && child.baseRefOid === parent.headRefOid) {
80
+ continue;
81
+ }
82
+ gaps.push({
83
+ parentPr: parent.number,
84
+ parentHeadRefName: parent.headRefName,
85
+ parentHeadRefOid: parent.headRefOid,
86
+ childPr: child.number,
87
+ childBaseRefName: child.baseRefName,
88
+ childBaseRefOid: child.baseRefOid,
89
+ });
90
+ }
91
+ return gaps;
92
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Append-only per-worktree markdown log.
3
3
  *
4
- * Log path: $PR_SHEPHERD_STATE_DIR/<owner>-<repo>/worktrees/<basename>-<sha8>.md
4
+ * Log path: $PR_SHEPHERD_STATE_DIR/<owner>/<repo>/worktrees/<basename>-<sha8>.md
5
5
  *
6
6
  * Always-on by default. Set PR_SHEPHERD_LOG_DISABLED=1 or CI=true to disable.
7
7
  * Write failures flip an internal disabled flag so the CLI never crashes because
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Append-only per-worktree markdown log.
3
3
  *
4
- * Log path: $PR_SHEPHERD_STATE_DIR/<owner>-<repo>/worktrees/<basename>-<sha8>.md
4
+ * Log path: $PR_SHEPHERD_STATE_DIR/<owner>/<repo>/worktrees/<basename>-<sha8>.md
5
5
  *
6
6
  * Always-on by default. Set PR_SHEPHERD_LOG_DISABLED=1 or CI=true to disable.
7
7
  * Write failures flip an internal disabled flag so the CLI never crashes because
@@ -9,8 +9,7 @@
9
9
  */
10
10
  import { appendFileSync, mkdirSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
- import { resolveStateBase } from "../state/base.mjs";
13
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
12
+ import { resolveRepoStateDir } from "../state/base.mjs";
14
13
  import { getWorktreeKey } from "../util/worktree.mjs";
15
14
  function computeDisabled() {
16
15
  if (process.env["PR_SHEPHERD_LOG_DISABLED"] === "1")
@@ -26,14 +25,9 @@ export function nextEntry() {
26
25
  return ++_entryCounter;
27
26
  }
28
27
  export function getLogFilePath(key) {
29
- const { owner, repo } = key;
30
- if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo)) {
31
- throw new Error(`Invalid repo key segments: ${owner}/${repo}`);
32
- }
33
- const base = resolveStateBase();
34
28
  // Worktree key injected at init time; fall back to "unknown" if not yet set.
35
29
  const wkey = _worktreeKey ?? "unknown";
36
- return join(base, `${owner}-${repo}`, "worktrees", `${wkey}.md`);
30
+ return join(resolveRepoStateDir(key), "worktrees", `${wkey}.md`);
37
31
  }
38
32
  let _worktreeKey = null;
39
33
  /**
@@ -44,9 +38,6 @@ export async function initLog(repoKey) {
44
38
  if (_disabled)
45
39
  return null;
46
40
  try {
47
- const { owner, repo } = repoKey;
48
- if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo))
49
- return null;
50
41
  _worktreeKey = await getWorktreeKey();
51
42
  const path = getLogFilePath(repoKey);
52
43
  mkdirSync(dirname(path), { recursive: true });
@@ -72,12 +63,8 @@ export function appendEntry(markdown) {
72
63
  }
73
64
  /** Resolve the log path without initializing (for the log-file subcommand). */
74
65
  export async function resolveLogPath(repoKey) {
75
- if (!SAFE_SEGMENT.test(repoKey.owner) || !SAFE_SEGMENT.test(repoKey.repo)) {
76
- throw new Error(`Invalid repo key segments: ${repoKey.owner}/${repoKey.repo}`);
77
- }
78
66
  const wkey = await getWorktreeKey();
79
- const base = resolveStateBase();
80
- return join(base, `${repoKey.owner}-${repoKey.repo}`, "worktrees", `${wkey}.md`);
67
+ return join(resolveRepoStateDir(repoKey), "worktrees", `${wkey}.md`);
81
68
  }
82
69
  /** Exposed for tests to reset module state. */
83
70
  export function _resetLogState() {
@@ -1,6 +1,6 @@
1
1
  export declare function resolveStateBase(): string;
2
2
  /**
3
- * `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/...parts`.
3
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/...parts`.
4
4
  * Owner, repo, PR number, and each extra part must be a safe path segment.
5
5
  */
6
6
  export declare function resolvePrStatePath(key: {
@@ -8,3 +8,20 @@ export declare function resolvePrStatePath(key: {
8
8
  repo: string;
9
9
  pr: number;
10
10
  }, ...parts: string[]): string;
11
+ /**
12
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/...parts`, beside the per-PR directories.
13
+ * Owner, repo, stack number, and each extra part must be a safe path segment.
14
+ */
15
+ export declare function resolveStackStatePath(key: {
16
+ owner: string;
17
+ repo: string;
18
+ stack: number;
19
+ }, ...parts: string[]): string;
20
+ /**
21
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>`.
22
+ * Owner and repo are separate segments so names that contain hyphens cannot collide.
23
+ */
24
+ export declare function resolveRepoStateDir(key: {
25
+ owner: string;
26
+ repo: string;
27
+ }): string;
@@ -1,29 +1,81 @@
1
- import { join } from "node:path";
1
+ import { execFileSync } from "node:child_process";
2
+ import { isAbsolute, join } from "node:path";
2
3
  import { tmpdir } from "node:os";
3
4
  import { SAFE_PR_NUMBER, SAFE_SEGMENT } from "../util/path-segment.mjs";
5
+ /** Read once per process; null when the platform or `getconf` offers no per-user temp dir. */
6
+ let darwinUserTempDir;
7
+ /**
8
+ * macOS's per-user temp dir, read from `confstr(_CS_DARWIN_USER_TEMP_DIR)` rather than
9
+ * `TMPDIR`. Sandboxed agent shells point `TMPDIR` at their own directory, so the same user's
10
+ * sandboxed CLI, unsandboxed CLI, and MCP server would otherwise keep separate state.
11
+ */
12
+ function readDarwinUserTempDir() {
13
+ if (process.platform !== "darwin")
14
+ return null;
15
+ try {
16
+ const dir = execFileSync("/usr/bin/getconf", ["DARWIN_USER_TEMP_DIR"], {
17
+ encoding: "utf8",
18
+ stdio: ["ignore", "pipe", "ignore"],
19
+ }).trim();
20
+ return isAbsolute(dir) ? dir : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
4
26
  export function resolveStateBase() {
5
27
  const envDir = process.env["PR_SHEPHERD_STATE_DIR"];
6
- return envDir ? envDir : join(tmpdir(), "pr-shepherd-state");
28
+ if (envDir)
29
+ return envDir;
30
+ if (darwinUserTempDir === undefined)
31
+ darwinUserTempDir = readDarwinUserTempDir();
32
+ return join(darwinUserTempDir ?? tmpdir(), "pr-shepherd-state");
7
33
  }
8
34
  /**
9
- * `$PR_SHEPHERD_STATE_DIR/<owner>-<repo>/<pr>/...parts`.
35
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/...parts`.
10
36
  * Owner, repo, PR number, and each extra part must be a safe path segment.
11
37
  */
12
38
  export function resolvePrStatePath(key, ...parts) {
13
- if (!SAFE_SEGMENT.test(key.owner)) {
14
- throw new Error(`Invalid state key segment "owner": ${key.owner}`);
15
- }
16
- if (!SAFE_SEGMENT.test(key.repo)) {
17
- throw new Error(`Invalid state key segment "repo": ${key.repo}`);
39
+ return resolveRepoStatePath(key, numberSegment("pr", key.pr), parts);
40
+ }
41
+ /**
42
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/stack-<number>/...parts`, beside the per-PR directories.
43
+ * Owner, repo, stack number, and each extra part must be a safe path segment.
44
+ */
45
+ export function resolveStackStatePath(key, ...parts) {
46
+ return resolveRepoStatePath(key, `stack-${numberSegment("stack", key.stack)}`, parts);
47
+ }
48
+ /**
49
+ * `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>`.
50
+ * Owner and repo are separate segments so names that contain hyphens cannot collide.
51
+ */
52
+ export function resolveRepoStateDir(key) {
53
+ assertOwnerRepo(key);
54
+ return join(resolveStateBase(), key.owner, key.repo);
55
+ }
56
+ function numberSegment(name, value) {
57
+ const segment = String(value);
58
+ if (!SAFE_PR_NUMBER.test(segment)) {
59
+ throw new Error(`Invalid state key segment "${name}": ${value}`);
18
60
  }
19
- const pr = String(key.pr);
20
- if (!SAFE_PR_NUMBER.test(pr)) {
21
- throw new Error(`Invalid state key segment "pr": ${key.pr}`);
61
+ return segment;
62
+ }
63
+ function assertRepoSegment(name, value) {
64
+ // `.` and `..` match SAFE_SEGMENT, but they are real path segments here and would escape the base.
65
+ if (!SAFE_SEGMENT.test(value) || value === "." || value === "..") {
66
+ throw new Error(`Invalid state key segment "${name}": ${value}`);
22
67
  }
68
+ }
69
+ function assertOwnerRepo(key) {
70
+ assertRepoSegment("owner", key.owner);
71
+ assertRepoSegment("repo", key.repo);
72
+ }
73
+ function resolveRepoStatePath(key, entry, parts) {
74
+ assertOwnerRepo(key);
23
75
  for (const part of parts) {
24
- if (!SAFE_SEGMENT.test(part)) {
76
+ if (!SAFE_SEGMENT.test(part) || part === "." || part === "..") {
25
77
  throw new Error(`Invalid state key segment: ${part}`);
26
78
  }
27
79
  }
28
- return join(resolveStateBase(), `${key.owner}-${key.repo}`, pr, ...parts);
80
+ return join(resolveRepoStateDir(key), entry, ...parts);
29
81
  }
@@ -4,7 +4,7 @@
4
4
  * Tracks how many caller-visible times each review thread has been dispatched to
5
5
  * the fix_code handler without being resolved. Body edits reset the count.
6
6
  *
7
- * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/fix-attempts.json`.
7
+ * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/fix-attempts.json`.
8
8
  */
9
9
  export interface FixAttemptsState {
10
10
  /** HEAD SHA at the time the counts were last written, retained for observability/compatibility. */
@@ -4,7 +4,7 @@
4
4
  * Tracks how many caller-visible times each review thread has been dispatched to
5
5
  * the fix_code handler without being resolved. Body edits reset the count.
6
6
  *
7
- * State lives in `$TMPDIR/pr-shepherd-state/<owner>-<repo>/<pr>/fix-attempts.json`.
7
+ * State lives in `$PR_SHEPHERD_STATE_DIR/<owner>/<repo>/<pr>/fix-attempts.json`.
8
8
  */
9
9
  import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
10
10
  import { randomUUID } from "node:crypto";