pr-shepherd 0.51.1 → 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 (116) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +29 -18
  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 -3
  11. package/bin/cli/poll-summary-emitter.mjs +2 -3
  12. package/bin/cli/poll-summary-formatter.mjs +12 -3
  13. package/bin/cli/runner.d.mts +1 -0
  14. package/bin/cli/runner.mjs +3 -1
  15. package/bin/commands/check.mjs +14 -4
  16. package/bin/commands/clean.mjs +7 -13
  17. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  18. package/bin/commands/iterate/check-instructions.mjs +11 -7
  19. package/bin/commands/iterate/escalate.mjs +4 -13
  20. package/bin/commands/iterate/fix-code.d.mts +2 -0
  21. package/bin/commands/iterate/fix-code.mjs +20 -43
  22. package/bin/commands/iterate/helpers.d.mts +0 -1
  23. package/bin/commands/iterate/helpers.mjs +0 -15
  24. package/bin/commands/iterate/index.mjs +178 -11
  25. package/bin/commands/iterate/merge-state.mjs +35 -18
  26. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  27. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  28. package/bin/commands/iterate/parent-first.d.mts +25 -0
  29. package/bin/commands/iterate/parent-first.mjs +70 -0
  30. package/bin/commands/iterate/render.d.mts +1 -1
  31. package/bin/commands/iterate/render.mjs +7 -12
  32. package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
  33. package/bin/commands/iterate/stale-ancestry.mjs +40 -0
  34. package/bin/commands/iterate/stall.mjs +40 -3
  35. package/bin/commands/poll-progress.d.mts +5 -1
  36. package/bin/commands/poll-progress.mjs +7 -1
  37. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  38. package/bin/commands/poll-summary-instructions.mjs +151 -107
  39. package/bin/commands/poll-summary.mjs +32 -6
  40. package/bin/commands/poll.mjs +3 -1
  41. package/bin/commands/ready-delay.d.mts +9 -4
  42. package/bin/commands/ready-delay.mjs +34 -20
  43. package/bin/commands/shepherd-journal.mjs +4 -1
  44. package/bin/commands/stack-drain.d.mts +35 -0
  45. package/bin/commands/stack-drain.mjs +129 -0
  46. package/bin/commands/stack-layer-readiness.d.mts +7 -0
  47. package/bin/commands/stack-layer-readiness.mjs +35 -0
  48. package/bin/commands/stack-stall.d.mts +14 -0
  49. package/bin/commands/stack-stall.mjs +69 -0
  50. package/bin/commands/stack-work.d.mts +32 -0
  51. package/bin/commands/stack-work.mjs +39 -0
  52. package/bin/config/load.d.mts +2 -0
  53. package/bin/config/load.mjs +10 -0
  54. package/bin/config.json +1 -0
  55. package/bin/exit-codes.d.mts +2 -0
  56. package/bin/exit-codes.mjs +2 -0
  57. package/bin/github/batch-parsers.mjs +1 -0
  58. package/bin/github/batch-raw-types.d.mts +2 -0
  59. package/bin/github/errors.d.mts +5 -0
  60. package/bin/github/errors.mjs +4 -0
  61. package/bin/github/gql/batch-pr.gql +1 -0
  62. package/bin/github/gql/poll-stack-summary.gql +8 -2
  63. package/bin/github/gql/poll-stack-topology.gql +38 -0
  64. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  65. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  66. package/bin/github/gql/poll-summary-fragment.gql +53 -56
  67. package/bin/github/merge-queue-checks.mjs +10 -1
  68. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  69. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  70. package/bin/github/poll-summary-fingerprint.d.mts +8 -0
  71. package/bin/github/poll-summary-fingerprint.mjs +48 -0
  72. package/bin/github/poll-summary-projector.mjs +70 -16
  73. package/bin/github/poll-summary-queue-removal.d.mts +5 -0
  74. package/bin/github/poll-summary-queue-removal.mjs +25 -0
  75. package/bin/github/poll-summary-raw.d.mts +52 -31
  76. package/bin/github/poll-summary-readiness.d.mts +6 -0
  77. package/bin/github/poll-summary-readiness.mjs +25 -0
  78. package/bin/github/poll-summary-route.mjs +8 -5
  79. package/bin/github/poll-summary.d.mts +3 -0
  80. package/bin/github/poll-summary.mjs +21 -73
  81. package/bin/github/queries.d.mts +7 -0
  82. package/bin/github/queries.mjs +9 -1
  83. package/bin/github/queue-removal-freshness.d.mts +16 -0
  84. package/bin/github/queue-removal-freshness.mjs +26 -0
  85. package/bin/github/stack-read.d.mts +34 -0
  86. package/bin/github/stack-read.mjs +92 -0
  87. package/bin/log/log-file.d.mts +1 -1
  88. package/bin/log/log-file.mjs +4 -17
  89. package/bin/state/base.d.mts +18 -1
  90. package/bin/state/base.mjs +65 -13
  91. package/bin/state/fix-attempts.d.mts +1 -1
  92. package/bin/state/fix-attempts.mjs +1 -1
  93. package/bin/state/graphql-quota-warnings.mjs +2 -6
  94. package/bin/state/iterate-stall.d.mts +7 -15
  95. package/bin/state/iterate-stall.mjs +6 -64
  96. package/bin/state/ready-receipts.d.mts +45 -0
  97. package/bin/state/ready-receipts.mjs +86 -0
  98. package/bin/state/rest-cache.d.mts +1 -1
  99. package/bin/state/rest-cache.mjs +1 -1
  100. package/bin/state/stack-stall.d.mts +16 -0
  101. package/bin/state/stack-stall.mjs +12 -0
  102. package/bin/state/stall-state-store.d.mts +37 -0
  103. package/bin/state/stall-state-store.mjs +74 -0
  104. package/bin/types/escalate.d.mts +2 -3
  105. package/bin/types/github.d.mts +2 -0
  106. package/bin/types/iterate.d.mts +2 -1
  107. package/bin/types/merge-requirements.d.mts +19 -0
  108. package/bin/types/poll-summary.d.mts +17 -2
  109. package/bin/types/report.d.mts +2 -0
  110. package/package.json +2 -2
  111. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  112. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  113. package/plugins/pr-shepherd/.mcp.json +1 -1
  114. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
  115. package/bin/state/bot-cr-seen.d.mts +0 -51
  116. package/bin/state/bot-cr-seen.mjs +0 -100
package/bin/config.json CHANGED
@@ -1,4 +1,5 @@
1
1
  {
2
+ "cliCommand": ["pr-shepherd"],
2
3
  "botUsernames": [
3
4
  "chatgpt-connector",
4
5
  "openai-codex",
@@ -25,6 +25,8 @@ export declare const EXIT: Readonly<{
25
25
  readonly CLOSED: 14;
26
26
  /** Agent should run the emitted merge or queue command. */
27
27
  readonly MERGE: 15;
28
+ /** Aggregate stack has PRs to shepherd through one-PR sessions. */
29
+ readonly SHEPHERD: 16;
28
30
  /** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
29
31
  readonly USAGE: 64;
30
32
  /** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
@@ -24,6 +24,8 @@ export const EXIT = Object.freeze({
24
24
  CLOSED: 14,
25
25
  /** Agent should run the emitted merge or queue command. */
26
26
  MERGE: 15,
27
+ /** Aggregate stack has PRs to shepherd through one-PR sessions. */
28
+ SHEPHERD: 16,
27
29
  /** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
28
30
  USAGE: 64,
29
31
  /** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
@@ -125,6 +125,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
125
125
  null,
126
126
  },
127
127
  baseRefName: raw.baseRefName,
128
+ ...(raw.baseRefOid && { baseRefOid: raw.baseRefOid }),
128
129
  reviewRequests,
129
130
  latestReviews,
130
131
  reviewThreads,
@@ -30,6 +30,8 @@ export interface RawPr extends RawPrMergeFields {
30
30
  viewerPermission: string | null;
31
31
  } | null;
32
32
  baseRefName: string;
33
+ /** `PullRequest.baseRefOid`: the base commit GitHub recorded for this PR, not the branch's live tip. */
34
+ baseRefOid?: string;
33
35
  baseRef: import("./batch-raw-rules.mts").RawBaseRef | null;
34
36
  reviewRequests: {
35
37
  nodes: Array<{
@@ -10,6 +10,11 @@ export interface GitHubGraphQlError {
10
10
  }
11
11
  /** GitHub GraphQL engine crash: HTTP 200, `data: null`, INTERNAL type/code or message. */
12
12
  export declare function isRetryableGraphQlInternal(graphqlErrors?: GitHubGraphQlError[]): boolean;
13
+ /** HTTP 200 GraphQL response whose `repository` field resolved to null. */
14
+ export declare function missingRepositoryError(repo: {
15
+ owner: string;
16
+ name: string;
17
+ }): GitHubRequestError;
13
18
  export declare class GitHubRequestError extends ShepherdError {
14
19
  readonly status: number;
15
20
  readonly rateLimit?: RateLimitInfo;
@@ -47,6 +47,10 @@ function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
47
47
  return EXIT.NOPERM;
48
48
  return EXIT.UNAVAILABLE;
49
49
  }
50
+ /** HTTP 200 GraphQL response whose `repository` field resolved to null. */
51
+ export function missingRepositoryError(repo) {
52
+ return new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
53
+ }
50
54
  export class GitHubRequestError extends ShepherdError {
51
55
  status;
52
56
  rateLimit;
@@ -35,6 +35,7 @@ query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
35
35
  viewerPermission
36
36
  }
37
37
  baseRefName
38
+ baseRefOid
38
39
  isInMergeQueue
39
40
  ...PrMergePolicy
40
41
  autoMergeRequest {
@@ -1,4 +1,10 @@
1
- query PollStackSummary($owner: String!, $repo: String!, $anchor: Int!, $after: String) {
1
+ query PollStackSummary(
2
+ $owner: String!
3
+ $repo: String!
4
+ $anchor: Int!
5
+ $first: Int!
6
+ $after: String
7
+ ) {
2
8
  _shepherdRateLimit: rateLimit {
3
9
  cost
4
10
  limit
@@ -15,7 +21,7 @@ query PollStackSummary($owner: String!, $repo: String!, $anchor: Int!, $after: S
15
21
  number
16
22
  size
17
23
  baseRefName
18
- entries(first: 50, after: $after) {
24
+ entries(first: $first, after: $after) {
19
25
  pageInfo {
20
26
  hasNextPage
21
27
  endCursor
@@ -0,0 +1,38 @@
1
+ query PollStackTopology($owner: String!, $repo: String!, $anchor: Int!, $after: String) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
10
+ repository(owner: $owner, name: $repo) {
11
+ viewerCanAdminister
12
+ pullRequest(number: $anchor) {
13
+ stack {
14
+ id
15
+ number
16
+ size
17
+ baseRefName
18
+ entries(first: 50, after: $after) {
19
+ pageInfo {
20
+ hasNextPage
21
+ endCursor
22
+ }
23
+ nodes {
24
+ position
25
+ pullRequest {
26
+ number
27
+ state
28
+ headRefName
29
+ headRefOid
30
+ baseRefName
31
+ baseRefOid
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,34 @@
1
+ fragment PollSummaryCheckContexts on StatusCheckRollupContextConnection {
2
+ totalCount
3
+ pageInfo {
4
+ hasPreviousPage
5
+ startCursor
6
+ }
7
+ nodes {
8
+ __typename
9
+ ... on CheckRun {
10
+ id
11
+ name
12
+ status
13
+ conclusion
14
+ detailsUrl
15
+ annotations(first: 1) {
16
+ totalCount
17
+ }
18
+ checkSuite {
19
+ workflowRun {
20
+ databaseId
21
+ event
22
+ workflow {
23
+ databaseId
24
+ name
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ... on StatusContext {
30
+ context
31
+ state
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,23 @@
1
+ query PollSummaryCheckPage($owner: String!, $repo: String!, $oid: GitObjectID!, $before: String!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
10
+ repository(owner: $owner, name: $repo) {
11
+ object(oid: $oid) {
12
+ __typename
13
+ ... on Commit {
14
+ oid
15
+ statusCheckRollup {
16
+ contexts(last: 100, before: $before) {
17
+ ...PollSummaryCheckContexts
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
23
+ }
@@ -3,6 +3,7 @@ fragment PollSummaryPr on PullRequest {
3
3
  title
4
4
  url
5
5
  state
6
+ updatedAt
6
7
  isDraft
7
8
  viewerCanUpdate
8
9
  headRefName
@@ -11,6 +12,26 @@ fragment PollSummaryPr on PullRequest {
11
12
  baseRefOid
12
13
  mergeable
13
14
  mergeStateStatus
15
+ lifecycleEvents: timelineItems(
16
+ last: 1
17
+ itemTypes: [CLOSED_EVENT, REOPENED_EVENT, CONVERT_TO_DRAFT_EVENT, READY_FOR_REVIEW_EVENT]
18
+ ) {
19
+ nodes {
20
+ __typename
21
+ ... on ClosedEvent {
22
+ createdAt
23
+ }
24
+ ... on ReopenedEvent {
25
+ createdAt
26
+ }
27
+ ... on ConvertToDraftEvent {
28
+ createdAt
29
+ }
30
+ ... on ReadyForReviewEvent {
31
+ createdAt
32
+ }
33
+ }
34
+ }
14
35
  reviewDecision
15
36
  reviewRequests(last: 50) {
16
37
  nodes {
@@ -38,38 +59,39 @@ fragment PollSummaryPr on PullRequest {
38
59
  }
39
60
  }
40
61
  isInMergeQueue
62
+ mergeQueueAdditions: timelineItems(last: 1, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) {
63
+ nodes {
64
+ ... on AddedToMergeQueueEvent {
65
+ createdAt
66
+ }
67
+ }
68
+ }
69
+ mergeQueueRemovals: timelineItems(last: 1, itemTypes: [REMOVED_FROM_MERGE_QUEUE_EVENT]) {
70
+ nodes {
71
+ ... on RemovedFromMergeQueueEvent {
72
+ id
73
+ reason
74
+ createdAt
75
+ actor {
76
+ login
77
+ }
78
+ beforeCommit {
79
+ oid
80
+ parents(first: 100) {
81
+ nodes {
82
+ oid
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
41
89
  mergeQueueEntry {
42
90
  headCommit {
91
+ oid
43
92
  statusCheckRollup {
44
93
  contexts(last: 100) {
45
- totalCount
46
- pageInfo {
47
- hasPreviousPage
48
- }
49
- nodes {
50
- __typename
51
- ... on CheckRun {
52
- id
53
- name
54
- status
55
- conclusion
56
- detailsUrl
57
- checkSuite {
58
- workflowRun {
59
- databaseId
60
- event
61
- workflow {
62
- databaseId
63
- name
64
- }
65
- }
66
- }
67
- }
68
- ... on StatusContext {
69
- context
70
- state
71
- }
72
- }
94
+ ...PollSummaryCheckContexts
73
95
  }
74
96
  }
75
97
  }
@@ -161,36 +183,11 @@ fragment PollSummaryPr on PullRequest {
161
183
  commits(last: 1) {
162
184
  nodes {
163
185
  commit {
186
+ oid
187
+ committedDate
164
188
  statusCheckRollup {
165
189
  contexts(last: 100) {
166
- totalCount
167
- pageInfo {
168
- hasPreviousPage
169
- }
170
- nodes {
171
- __typename
172
- ... on CheckRun {
173
- id
174
- name
175
- status
176
- conclusion
177
- detailsUrl
178
- checkSuite {
179
- workflowRun {
180
- databaseId
181
- event
182
- workflow {
183
- databaseId
184
- name
185
- }
186
- }
187
- }
188
- }
189
- ... on StatusContext {
190
- context
191
- state
192
- }
193
- }
190
+ ...PollSummaryCheckContexts
194
191
  }
195
192
  }
196
193
  }
@@ -1,4 +1,6 @@
1
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
1
2
  import { graphql } from "./client.mjs";
3
+ import { queueRemovalAppliesToHead } from "./queue-removal-freshness.mjs";
2
4
  import { requireContextNodes } from "./batch-response.mjs";
3
5
  import { COMMIT_CHECK_CONTEXTS_QUERY } from "./queries.mjs";
4
6
  function omittedCursorError(oid) {
@@ -60,8 +62,15 @@ function currentRemovalCommit(raw) {
60
62
  if (addition && Date.parse(removal.createdAt) < Date.parse(addition.createdAt))
61
63
  return undefined;
62
64
  const parentOids = removal.beforeCommit.parents?.nodes.map((node) => node.oid);
63
- if (!parentOids?.includes(raw.headRefOid))
65
+ const headCommittedAt = raw.commits?.nodes[0]?.commit.committedDate;
66
+ if (!queueRemovalAppliesToHead({
67
+ parentOids,
68
+ headOid: raw.headRefOid,
69
+ ...(headCommittedAt && { headCommittedAtUnix: parseCreatedAt(headCommittedAt) }),
70
+ removedAtUnix: Math.floor(Date.parse(removal.createdAt) / 1000),
71
+ })) {
64
72
  return undefined;
73
+ }
65
74
  return removal.beforeCommit;
66
75
  }
67
76
  /** Hydrate all status contexts for the active or most recently removed queue commit. */
@@ -0,0 +1,12 @@
1
+ import { type RepoInfo } from "./client.mts";
2
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
3
+ /**
4
+ * Complete the compact summary's `last: 100` status-context windows in place.
5
+ *
6
+ * Without this, a PR with more than 100 contexts always reports incomplete
7
+ * checks, so it can never earn or match a READY receipt. Every summary fetch
8
+ * path runs this before anything fingerprints `commits`, so one-PR receipts and
9
+ * aggregate stack reads hash the same evidence. A window that cannot be
10
+ * completed stays incomplete, which already fails readiness closed.
11
+ */
12
+ export declare function hydratePollSummaryChecks(raw: RawSummaryPr, repo: RepoInfo): Promise<void>;
@@ -0,0 +1,55 @@
1
+ import { graphqlWithRateLimit } from "./client.mjs";
2
+ import { POLL_SUMMARY_CHECK_PAGE_QUERY } from "./queries.mjs";
3
+ /**
4
+ * Complete the compact summary's `last: 100` status-context windows in place.
5
+ *
6
+ * Without this, a PR with more than 100 contexts always reports incomplete
7
+ * checks, so it can never earn or match a READY receipt. Every summary fetch
8
+ * path runs this before anything fingerprints `commits`, so one-PR receipts and
9
+ * aggregate stack reads hash the same evidence. A window that cannot be
10
+ * completed stays incomplete, which already fails readiness closed.
11
+ */
12
+ export async function hydratePollSummaryChecks(raw, repo) {
13
+ const headCommit = raw.commits.nodes[0]?.commit;
14
+ if (headCommit)
15
+ await hydrateCommitContexts(headCommit, repo);
16
+ const queueCommit = raw.mergeQueueEntry?.headCommit;
17
+ if (queueCommit)
18
+ await hydrateCommitContexts(queueCommit, repo);
19
+ }
20
+ async function hydrateCommitContexts(commit, repo) {
21
+ const contexts = commit.statusCheckRollup?.contexts;
22
+ if (!contexts)
23
+ return;
24
+ const nodes = [...contexts.nodes];
25
+ let pageInfo = contexts.pageInfo;
26
+ while (pageInfo.hasPreviousPage && pageInfo.startCursor) {
27
+ const older = await fetchOlderContexts(commit.oid, pageInfo.startCursor, repo);
28
+ if (!older)
29
+ break;
30
+ nodes.unshift(...older.nodes);
31
+ pageInfo = older.pageInfo;
32
+ }
33
+ // A window that shifted between pages can repeat or skip contexts; the
34
+ // first page's total is the snapshot the hydrated list must account for.
35
+ const complete = !pageInfo.hasPreviousPage && nodes.length === contexts.totalCount;
36
+ commit.statusCheckRollup = {
37
+ contexts: {
38
+ totalCount: contexts.totalCount,
39
+ pageInfo: { hasPreviousPage: !complete },
40
+ nodes,
41
+ },
42
+ };
43
+ }
44
+ async function fetchOlderContexts(oid, before, repo) {
45
+ const { data } = await graphqlWithRateLimit(POLL_SUMMARY_CHECK_PAGE_QUERY, {
46
+ owner: repo.owner,
47
+ repo: repo.name,
48
+ oid,
49
+ before,
50
+ });
51
+ const object = data.repository?.object;
52
+ if (object?.__typename !== "Commit" || object.oid !== oid)
53
+ return null;
54
+ return object.statusCheckRollup?.contexts ?? null;
55
+ }
@@ -0,0 +1,8 @@
1
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
2
+ /**
3
+ * Bind receipts to the review and head-commit CI evidence shared by one-PR
4
+ * completion and stack reconciliation. Queue entry, queue checks, and GitHub's
5
+ * computed merge state are checked separately: including them here would
6
+ * invalidate a READY receipt merely because a ready PR entered the queue.
7
+ */
8
+ export declare function fingerprintRawSummaryPr(raw: RawSummaryPr): string | null;
@@ -0,0 +1,48 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Bind receipts to the review and head-commit CI evidence shared by one-PR
4
+ * completion and stack reconciliation. Queue entry, queue checks, and GitHub's
5
+ * computed merge state are checked separately: including them here would
6
+ * invalidate a READY receipt merely because a ready PR entered the queue.
7
+ */
8
+ export function fingerprintRawSummaryPr(raw) {
9
+ if (!raw.updatedAt || !raw.headRefOid || !raw.baseRefOid)
10
+ return null;
11
+ const evidence = {
12
+ number: raw.number,
13
+ state: raw.state,
14
+ lifecycleEvents: raw.lifecycleEvents,
15
+ isDraft: raw.isDraft,
16
+ headRefOid: raw.headRefOid,
17
+ baseRefOid: raw.baseRefOid,
18
+ baseRefName: raw.baseRefName,
19
+ reviewDecision: raw.reviewDecision,
20
+ reviewRequests: raw.reviewRequests,
21
+ latestReviews: raw.latestReviews,
22
+ comments: hideBodies(raw.comments),
23
+ reviews: hideBodies(raw.reviews),
24
+ reviewThreads: {
25
+ ...raw.reviewThreads,
26
+ nodes: raw.reviewThreads.nodes.map((thread) => ({
27
+ ...thread,
28
+ rootComments: thread.rootComments && hideBodies(thread.rootComments),
29
+ comments: hideBodies(thread.comments),
30
+ })),
31
+ },
32
+ commits: {
33
+ ...raw.commits,
34
+ nodes: raw.commits.nodes.map((node) => ({
35
+ ...node,
36
+ commit: { ...node.commit, committedDate: undefined },
37
+ })),
38
+ },
39
+ };
40
+ return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
41
+ }
42
+ /** Hidden bodies are not readiness evidence: bots keep editing hidden notices after a PR settles. */
43
+ function hideBodies(connection) {
44
+ return {
45
+ ...connection,
46
+ nodes: connection.nodes.map((node) => (node.isMinimized ? { ...node, body: undefined } : node)),
47
+ };
48
+ }
@@ -3,8 +3,12 @@ import { updateReadyDelay } from "../commands/ready-delay.mjs";
3
3
  import { formatPrUrl } from "../pr-reference.mjs";
4
4
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
5
5
  import { loadSeenMap } from "../state/seen-comments.mjs";
6
+ import { isReadyReceiptCurrent, readReadyReceipt } from "../state/ready-receipts.mjs";
6
7
  import { summarizePollSummaryChecks } from "./poll-summary-checks.mjs";
7
8
  import { summarizePollSummaryReview } from "./poll-summary-review.mjs";
9
+ import { fingerprintRawSummaryPr } from "./poll-summary-fingerprint.mjs";
10
+ import { currentQueueRemovalEvent } from "./poll-summary-queue-removal.mjs";
11
+ import { isCurrentSummaryReady } from "./poll-summary-readiness.mjs";
8
12
  import { normalizePollSummaryState, routePollSummary } from "./poll-summary-route.mjs";
9
13
  export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdminister = false) {
10
14
  const repoName = `${repo.owner}/${repo.name}`;
@@ -12,19 +16,24 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
12
16
  const checks = summarizePollSummaryChecks(raw);
13
17
  const review = await summarizePollSummaryReview(raw, seen, viewerCanAdminister);
14
18
  const blockingReviewerInProgress = detectBlockingReviewer(raw);
19
+ const removalEvent = currentQueueRemovalEvent(raw);
15
20
  let { action, reasons } = routePollSummary(raw, checks, review, opts);
16
21
  let remainingSeconds;
17
- if (raw.isDraft && blockingReviewerInProgress && action === "mark_ready") {
22
+ if (raw.isDraft &&
23
+ blockingReviewerInProgress &&
24
+ (action === "mark_ready" || reasons.includes("draft-auto-mark-ready-disabled"))) {
18
25
  action = "wait";
19
26
  reasons = ["blocking-reviewer-in-progress"];
20
27
  }
21
28
  const appearsReady = reasons.includes("appears-ready");
22
- const readyDelaySeconds = opts.readyDelaySeconds ?? (loadConfig().watch?.readyDelayMinutes ?? 10) * 60;
23
- const readyState = await updateReadyDelay(raw.number, appearsReady, readyDelaySeconds, repo.owner, repo.name, { retainElapsed: true });
24
- if (appearsReady && !readyState.shouldCancel) {
25
- action = "wait";
26
- reasons = ["ready-delay"];
27
- remainingSeconds = readyState.remainingSeconds;
29
+ if (opts.stackPrNumber === undefined) {
30
+ const readyDelaySeconds = opts.readyDelaySeconds ?? (loadConfig().watch?.readyDelayMinutes ?? 10) * 60;
31
+ const readyState = await updateReadyDelay(raw.number, appearsReady, readyDelaySeconds, repo.owner, repo.name, { headSha: raw.headRefOid });
32
+ if (appearsReady && !readyState.shouldCancel) {
33
+ action = "wait";
34
+ reasons = ["ready-delay"];
35
+ remainingSeconds = readyState.remainingSeconds;
36
+ }
28
37
  }
29
38
  const stack = raw.stack
30
39
  ? {
@@ -34,6 +43,32 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
34
43
  baseRefName: raw.stack.baseRefName,
35
44
  }
36
45
  : undefined;
46
+ const fingerprint = opts.stackPrNumber !== undefined ? fingerprintRawSummaryPr(raw) : null;
47
+ const receipt = fingerprint
48
+ ? await readReadyReceipt({ owner: repo.owner, repo: repo.name, pr: raw.number })
49
+ : null;
50
+ // A queued PR's target branch can advance as earlier queue entries merge.
51
+ // Keep the pre-enqueue base binding for the receipt comparison while the
52
+ // merge group itself supplies the current mergeability evidence.
53
+ const receiptFingerprint = raw.isInMergeQueue && receipt
54
+ ? fingerprintRawSummaryPr({ ...raw, baseRefOid: receipt.baseRefOid })
55
+ : fingerprint;
56
+ const currentReady = isCurrentSummaryReady(raw, checks, review, {
57
+ allowQueuedProgress: opts.stackPrNumber !== undefined && raw.isInMergeQueue,
58
+ });
59
+ const readyReceipt = receiptFingerprint !== null &&
60
+ isReadyReceiptCurrent(receipt, {
61
+ headRefOid: raw.headRefOid,
62
+ baseRefOid: raw.isInMergeQueue && receipt ? receipt.baseRefOid : raw.baseRefOid,
63
+ readinessFingerprint: receiptFingerprint,
64
+ status: currentReady ? "READY" : "PENDING",
65
+ isDraft: raw.isDraft,
66
+ });
67
+ const queueRemoval = removalEvent?.id && readyReceipt && receipt?.acknowledgedQueueRemovalId === removalEvent.id
68
+ ? undefined
69
+ : removalEvent
70
+ ? projectQueueRemoval(removalEvent)
71
+ : undefined;
37
72
  return {
38
73
  pr: raw.number,
39
74
  repo: repoName,
@@ -50,15 +85,29 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
50
85
  baseRefName: raw.baseRefName,
51
86
  ...(raw.isDraft && { isDraft: true }),
52
87
  ...(raw.isInMergeQueue && { isInMergeQueue: true }),
88
+ ...(queueRemoval && { queueRemoval }),
53
89
  ...(blockingReviewerInProgress && { blockingReviewerInProgress: true }),
54
90
  ...(remainingSeconds !== undefined && { remainingSeconds }),
55
91
  ...(Object.keys(checks).length > 0 && { checks }),
56
92
  ...(Object.keys(review).length > 0 && { review }),
57
93
  ...(stack && { stack }),
58
- ...(!["wait", "cancel"].includes(action) &&
59
- !(opts.stackPrNumber !== undefined && raw.stack && action === "merge") && {
60
- pollCommand: buildPollCommand(repoName, raw.number, opts),
61
- }),
94
+ ...(readyReceipt && { readyReceipt: true }),
95
+ ...((opts.stackPrNumber !== undefined && raw.state === "OPEN") ||
96
+ (!["wait", "cancel"].includes(action) &&
97
+ !(opts.stackPrNumber !== undefined && raw.stack && action === "merge"))
98
+ ? pollCommandFields(repoName, raw.number, raw.isDraft, opts)
99
+ : {}),
100
+ };
101
+ }
102
+ function projectQueueRemoval(removal) {
103
+ const removalTime = Date.parse(removal.createdAt);
104
+ const parents = removal.beforeCommit?.parents?.nodes.map((parent) => parent.oid) ?? [];
105
+ return {
106
+ reason: removal.reason,
107
+ createdAtUnix: Math.floor(removalTime / 1000),
108
+ ...(removal.actor?.login && { actor: removal.actor.login }),
109
+ ...(removal.beforeCommit?.oid && { beforeCommitOid: removal.beforeCommit.oid }),
110
+ beforeCommitParentOids: parents,
62
111
  };
63
112
  }
64
113
  function detectBlockingReviewer(raw) {
@@ -67,16 +116,21 @@ function detectBlockingReviewer(raw) {
67
116
  return ((raw.reviewRequests?.nodes ?? []).some((request) => matches(request.requestedReviewer)) ||
68
117
  (raw.latestReviews?.nodes ?? []).some((review) => review.state === "PENDING" && matches(review.author)));
69
118
  }
70
- function buildPollCommand(repo, pr, opts) {
71
- const args = [formatPrUrl(repo, pr), "--until-terminal"];
72
- if (opts.merge)
119
+ function pollCommandFields(repo, pr, isDraft, opts) {
120
+ const autoMarkReadyDisabled = opts.noAutoMarkReady || loadConfig().actions.autoMarkReady === false;
121
+ const boundedDraft = isDraft && autoMarkReadyDisabled;
122
+ const args = boundedDraft
123
+ ? [formatPrUrl(repo, pr), "--timeout", "1s", "--debounce", "0s", "--no-auto-mark-ready"]
124
+ : [formatPrUrl(repo, pr), "--until-terminal"];
125
+ if (opts.merge && opts.stackPrNumber === undefined)
73
126
  args.push("--merge");
74
127
  if (opts.readyDelaySeconds !== undefined)
75
128
  args.push("--ready-delay", `${opts.readyDelaySeconds}s`);
76
129
  if (opts.stallTimeoutSeconds !== undefined) {
77
130
  args.push("--stall-timeout", `${opts.stallTimeoutSeconds}s`);
78
131
  }
79
- if (opts.noAutoMarkReady)
132
+ if (opts.noAutoMarkReady && !boundedDraft)
80
133
  args.push("--no-auto-mark-ready");
81
- return buildPrShepherdCommand(args).text;
134
+ const pollCommand = buildPrShepherdCommand(args).text;
135
+ return boundedDraft ? { pollCommand, pollProbe: true } : { pollCommand };
82
136
  }
@@ -0,0 +1,5 @@
1
+ import type { RawSummaryPr } from "./poll-summary-raw.mts";
2
+ type QueueRemovalEvent = NonNullable<RawSummaryPr["mergeQueueRemovals"]>["nodes"][number];
3
+ /** Latest queue removal that still applies to this PR head, not an older attempt. */
4
+ export declare function currentQueueRemovalEvent(raw: RawSummaryPr): QueueRemovalEvent | null;
5
+ export {};
@@ -0,0 +1,25 @@
1
+ import { queueRemovalAppliesToHead } from "./queue-removal-freshness.mjs";
2
+ import { parseCreatedAt } from "./batch-parser-helpers.mjs";
3
+ /** Latest queue removal that still applies to this PR head, not an older attempt. */
4
+ export function currentQueueRemovalEvent(raw) {
5
+ const removal = raw.mergeQueueRemovals?.nodes[0];
6
+ if (!removal || raw.isInMergeQueue)
7
+ return null;
8
+ const removalTime = Date.parse(removal.createdAt);
9
+ if (!Number.isFinite(removalTime))
10
+ return null;
11
+ const addition = raw.mergeQueueAdditions?.nodes[0];
12
+ if (addition && Date.parse(addition.createdAt) > removalTime)
13
+ return null;
14
+ const parents = removal.beforeCommit?.parents?.nodes.map((parent) => parent.oid);
15
+ const headCommittedAt = raw.commits?.nodes[0]?.commit.committedDate;
16
+ if (!queueRemovalAppliesToHead({
17
+ parentOids: parents,
18
+ headOid: raw.headRefOid,
19
+ ...(headCommittedAt && { headCommittedAtUnix: parseCreatedAt(headCommittedAt) }),
20
+ removedAtUnix: Math.floor(removalTime / 1000),
21
+ })) {
22
+ return null;
23
+ }
24
+ return removal;
25
+ }