pr-shepherd 0.52.0 → 0.53.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.
Files changed (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +17 -12
  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 +22 -0
  10. package/bin/cli/iterate-lean.mjs +1 -0
  11. package/bin/cli/poll-summary-formatter.mjs +4 -3
  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/api-usage.mjs +2 -2
  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 -0
  20. package/bin/commands/iterate/fix-code.mjs +6 -1
  21. package/bin/commands/iterate/helpers.d.mts +0 -1
  22. package/bin/commands/iterate/helpers.mjs +0 -15
  23. package/bin/commands/iterate/index.mjs +48 -27
  24. package/bin/commands/iterate/merge-state.mjs +6 -4
  25. package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
  26. package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
  27. package/bin/commands/iterate/parent-first.d.mts +6 -7
  28. package/bin/commands/iterate/parent-first.mjs +10 -54
  29. package/bin/commands/iterate/render.d.mts +1 -1
  30. package/bin/commands/iterate/render.mjs +7 -12
  31. package/bin/commands/iterate/stale-ancestry.d.mts +1 -1
  32. package/bin/commands/iterate/stale-ancestry.mjs +10 -7
  33. package/bin/commands/iterate/stall.mjs +40 -3
  34. package/bin/commands/poll-progress.d.mts +5 -1
  35. package/bin/commands/poll-progress.mjs +7 -1
  36. package/bin/commands/poll-quota.mjs +2 -2
  37. package/bin/commands/poll-summary-instructions.d.mts +6 -1
  38. package/bin/commands/poll-summary-instructions.mjs +71 -122
  39. package/bin/commands/poll-summary.mjs +4 -2
  40. package/bin/commands/ready-delay.d.mts +9 -4
  41. package/bin/commands/ready-delay.mjs +34 -20
  42. package/bin/commands/shepherd-journal.mjs +4 -1
  43. package/bin/commands/stack-drain.d.mts +35 -0
  44. package/bin/commands/stack-drain.mjs +138 -0
  45. package/bin/commands/stack-layer-readiness.d.mts +6 -0
  46. package/bin/commands/stack-layer-readiness.mjs +34 -0
  47. package/bin/commands/stack-stall.d.mts +14 -0
  48. package/bin/commands/stack-stall.mjs +68 -0
  49. package/bin/commands/stack-work.d.mts +32 -0
  50. package/bin/commands/stack-work.mjs +38 -0
  51. package/bin/config/load.d.mts +2 -0
  52. package/bin/config/load.mjs +10 -0
  53. package/bin/config.json +1 -0
  54. package/bin/github/api-telemetry-aggregate.d.mts +1 -0
  55. package/bin/github/api-telemetry-aggregate.mjs +8 -2
  56. package/bin/github/api-telemetry.d.mts +4 -0
  57. package/bin/github/api-telemetry.mjs +12 -0
  58. package/bin/github/batch-parsers.mjs +1 -1
  59. package/bin/github/batch-raw-rules.d.mts +0 -3
  60. package/bin/github/batch-raw-types.d.mts +2 -0
  61. package/bin/github/errors.d.mts +5 -0
  62. package/bin/github/errors.mjs +4 -0
  63. package/bin/github/gql/batch-pr.gql +1 -0
  64. package/bin/github/gql/poll-stack-summary.gql +8 -2
  65. package/bin/github/gql/poll-stack-topology.gql +38 -0
  66. package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
  67. package/bin/github/gql/poll-summary-check-page.gql +23 -0
  68. package/bin/github/gql/poll-summary-fragment.gql +5 -62
  69. package/bin/github/gql/pr-merge-policy.gql +0 -3
  70. package/bin/github/graphql-http.mjs +6 -0
  71. package/bin/github/http-auth.d.mts +3 -0
  72. package/bin/github/http-auth.mjs +6 -0
  73. package/bin/github/http-intermediate.d.mts +1 -0
  74. package/bin/github/http-intermediate.mjs +3 -0
  75. package/bin/github/merge-queue-checks.mjs +10 -1
  76. package/bin/github/poll-summary-check-hydration.d.mts +12 -0
  77. package/bin/github/poll-summary-check-hydration.mjs +55 -0
  78. package/bin/github/poll-summary-fingerprint.mjs +24 -4
  79. package/bin/github/poll-summary-projector.mjs +8 -7
  80. package/bin/github/poll-summary-queue-removal.mjs +10 -1
  81. package/bin/github/poll-summary-raw.d.mts +19 -31
  82. package/bin/github/poll-summary-route.mjs +8 -5
  83. package/bin/github/poll-summary.mjs +16 -73
  84. package/bin/github/queries.d.mts +7 -0
  85. package/bin/github/queries.mjs +9 -1
  86. package/bin/github/queue-removal-freshness.d.mts +16 -0
  87. package/bin/github/queue-removal-freshness.mjs +26 -0
  88. package/bin/github/stack-read.d.mts +34 -0
  89. package/bin/github/stack-read.mjs +92 -0
  90. package/bin/log/log-file.d.mts +1 -1
  91. package/bin/log/log-file.mjs +4 -17
  92. package/bin/state/base.d.mts +18 -1
  93. package/bin/state/base.mjs +65 -13
  94. package/bin/state/fix-attempts.d.mts +1 -1
  95. package/bin/state/fix-attempts.mjs +1 -1
  96. package/bin/state/graphql-quota-policy.d.mts +7 -1
  97. package/bin/state/graphql-quota-policy.mjs +41 -14
  98. package/bin/state/graphql-quota-warnings.mjs +4 -6
  99. package/bin/state/iterate-stall.d.mts +7 -15
  100. package/bin/state/iterate-stall.mjs +6 -64
  101. package/bin/state/rest-cache.d.mts +1 -1
  102. package/bin/state/rest-cache.mjs +1 -1
  103. package/bin/state/stack-stall.d.mts +16 -0
  104. package/bin/state/stack-stall.mjs +12 -0
  105. package/bin/state/stall-state-store.d.mts +37 -0
  106. package/bin/state/stall-state-store.mjs +74 -0
  107. package/bin/types/api-usage.d.mts +2 -0
  108. package/bin/types/escalate.d.mts +1 -1
  109. package/bin/types/github.d.mts +1 -1
  110. package/bin/types/iterate.d.mts +2 -1
  111. package/bin/types/merge-requirements.d.mts +9 -0
  112. package/bin/types/poll-summary.d.mts +5 -2
  113. package/bin/types/report.d.mts +1 -1
  114. package/package.json +1 -1
  115. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  116. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  117. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -0,0 +1,14 @@
1
+ import type { PollSummaryItem, PollSummaryResult } from "../types.mts";
2
+ /**
3
+ * A `--stack` plan that can only wait never reruns its layers' one-PR sessions, so their own
4
+ * stall guards cannot fire. Escalate `stall-timeout` once that idle plan's fingerprint stays
5
+ * unchanged for `stallTimeoutSeconds`; any other plan, or `0`, clears the timer. When the enabled
6
+ * timer cannot be read or written, escalate `stall-state-unavailable` instead of restarting it.
7
+ */
8
+ export declare function applyStackStallGuard(planned: {
9
+ result: PollSummaryResult;
10
+ idle?: PollSummaryItem[];
11
+ }, repo: {
12
+ owner: string;
13
+ name: string;
14
+ }, stallTimeoutSeconds: number): Promise<PollSummaryResult>;
@@ -0,0 +1,68 @@
1
+ import { clearStackStallState, readStackStallState, writeStackStallState, } from "../state/stack-stall.mjs";
2
+ import { formatDurationApprox } from "./iterate/escalate.mjs";
3
+ import { summaryStatusSignature } from "./poll-summary-signature.mjs";
4
+ import { describeIdleLayers } from "./stack-drain.mjs";
5
+ /**
6
+ * A `--stack` plan that can only wait never reruns its layers' one-PR sessions, so their own
7
+ * stall guards cannot fire. Escalate `stall-timeout` once that idle plan's fingerprint stays
8
+ * unchanged for `stallTimeoutSeconds`; any other plan, or `0`, clears the timer. When the enabled
9
+ * timer cannot be read or written, escalate `stall-state-unavailable` instead of restarting it.
10
+ */
11
+ export async function applyStackStallGuard(planned, repo, stallTimeoutSeconds) {
12
+ const { result, idle } = planned;
13
+ if (result.selection.kind !== "stack")
14
+ return result;
15
+ const key = { owner: repo.owner, repo: repo.name, stack: result.selection.stackNumber };
16
+ if (!idle || stallTimeoutSeconds <= 0) {
17
+ await clearStackStallState(key);
18
+ return result;
19
+ }
20
+ const fingerprint = stackStallFingerprint(result);
21
+ const nowSeconds = Math.floor(Date.now() / 1000);
22
+ const read = await readStackStallState(key);
23
+ if (!read.ok)
24
+ return stackStateUnavailable(result, read.reason);
25
+ const stored = read.state;
26
+ if (!stored || stored.fingerprint !== fingerprint || stored.firstSeenAt > nowSeconds) {
27
+ const wrote = await writeStackStallState(key, { fingerprint, firstSeenAt: nowSeconds });
28
+ if (!wrote.ok)
29
+ return stackStateUnavailable(result, wrote.reason);
30
+ return result;
31
+ }
32
+ const ageSeconds = nowSeconds - stored.firstSeenAt;
33
+ if (ageSeconds < stallTimeoutSeconds)
34
+ return result;
35
+ return {
36
+ ...result,
37
+ reason: "actionable",
38
+ nextAction: "escalate",
39
+ stackMergeable: false,
40
+ instructions: [
41
+ `1. \`stall-timeout\`: the stack has not changed for ${formatDurationApprox(ageSeconds)} while no one-PR session could advance it: ${describeIdleLayers(idle)}. Stop and ask a human to unblock the waiting layers.`,
42
+ ],
43
+ };
44
+ }
45
+ function stackStateUnavailable(result, reason) {
46
+ return {
47
+ ...result,
48
+ reason: "actionable",
49
+ nextAction: "escalate",
50
+ stackMergeable: false,
51
+ instructions: [
52
+ `1. \`stall-state-unavailable\`: the stack stall timer could not be saved (${reason}). Stop and ask a human to fix the state directory before resuming.`,
53
+ ],
54
+ };
55
+ }
56
+ /** The status signature plus the per-layer evidence it omits, so a push resets the timer. */
57
+ function stackStallFingerprint(result) {
58
+ return JSON.stringify({
59
+ status: summaryStatusSignature(result),
60
+ layers: result.prs.map((item) => ({
61
+ pr: item.pr,
62
+ headRefOid: item.headRefOid,
63
+ isDraft: item.isDraft,
64
+ readyReceipt: item.readyReceipt,
65
+ reasons: item.reasons,
66
+ })),
67
+ });
68
+ }
@@ -0,0 +1,32 @@
1
+ import type { PollSummaryItem } from "../types.mts";
2
+ type ProbedLayer = PollSummaryItem & {
3
+ pollCommand: string;
4
+ pollProbe: true;
5
+ };
6
+ /**
7
+ * The one-PR hold reason for a `READY` draft that only the disabled setting keeps in draft. The
8
+ * probe re-reads the draft, so this phrase, not a bare `[WAIT]`, proves it is still clean.
9
+ */
10
+ export declare const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
11
+ /** How the stack selector routes the autonomous layers that still need work. */
12
+ export interface StackWork {
13
+ /** Layers that need a one-PR Shepherd session, with or without a known command. */
14
+ sessions: PollSummaryItem[];
15
+ /** Clean, unblocked drafts that the agent marks ready because the poll loop will not. */
16
+ markReady: ProbedLayer[];
17
+ /** Layers whose bounded probe could only report that they are still waiting. */
18
+ idle: ProbedLayer[];
19
+ }
20
+ /**
21
+ * Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
22
+ * mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A probed wait
23
+ * kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
24
+ * A stale-ancestry child keeps its session, which returns the ancestry repair.
25
+ */
26
+ export declare function splitStackWork(candidates: PollSummaryItem[], staleChildren: ReadonlySet<number>): StackWork;
27
+ /**
28
+ * The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
29
+ * first because only the one-PR session reads the full review context.
30
+ */
31
+ export declare function appendMarkReadyInstructions(instructions: string[], layers: ProbedLayer[]): void;
32
+ export {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The one-PR hold reason for a `READY` draft that only the disabled setting keeps in draft. The
3
+ * probe re-reads the draft, so this phrase, not a bare `[WAIT]`, proves it is still clean.
4
+ */
5
+ export const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
6
+ /**
7
+ * Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
8
+ * mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A probed wait
9
+ * kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
10
+ * A stale-ancestry child keeps its session, which returns the ancestry repair.
11
+ */
12
+ export function splitStackWork(candidates, staleChildren) {
13
+ const work = { sessions: [], markReady: [], idle: [] };
14
+ for (const item of candidates) {
15
+ if (!isProbed(item) || item.action !== "wait" || staleChildren.has(item.pr)) {
16
+ work.sessions.push(item);
17
+ }
18
+ else if (item.reasons.includes("draft-auto-mark-ready-disabled")) {
19
+ work.markReady.push(item);
20
+ }
21
+ else {
22
+ work.idle.push(item);
23
+ }
24
+ }
25
+ return work;
26
+ }
27
+ /**
28
+ * The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
29
+ * first because only the one-PR session reads the full review context.
30
+ */
31
+ export function appendMarkReadyInstructions(instructions, layers) {
32
+ for (const item of layers) {
33
+ instructions.push(`${instructions.length + 1}. Automatic mark-ready is disabled, so marking PR #${item.pr} ready for review is your step. Run \`${item.pollCommand}\` first. If it returns \`[WAIT]\` saying PR #${item.pr} stays in draft because ${AUTO_MARK_READY_DISABLED_HOLD}, run \`gh pr ready ${item.pr} -R ${item.repo}\`; otherwise complete its instructions and leave PR #${item.pr} in draft this round.`);
34
+ }
35
+ }
36
+ function isProbed(item) {
37
+ return item.pollProbe === true && item.pollCommand !== undefined;
38
+ }
@@ -15,6 +15,8 @@ interface PollConfig {
15
15
  export interface PrShepherdConfig {
16
16
  /** Optional user classification configuration; preserved for rule consumers. */
17
17
  classify?: unknown;
18
+ /** Argv prefix for every pr-shepherd command Shepherd emits, e.g. `["pnpm", "exec", "pr-shepherd"]`. */
19
+ cliCommand: string[];
18
20
  /** GitHub logins that should be treated as bots even when GitHub reports User/Unknown. */
19
21
  botUsernames: string[];
20
22
  /** Case-insensitive glob patterns for check/status context names Shepherd should ignore. */
@@ -76,6 +76,14 @@ function parseResolveOtherHumanThreads(value) {
76
76
  }
77
77
  throw new Error(`Invalid config: iterate.resolveOtherHumanThreads must be one of "none", "outdated", or "always", got ${JSON.stringify(value)}`);
78
78
  }
79
+ function parseCliCommand(value) {
80
+ if (!Array.isArray(value) ||
81
+ value.length === 0 ||
82
+ !value.every((arg) => typeof arg === "string" && arg.trim() !== "")) {
83
+ throw new Error("Invalid config: cliCommand must be a non-empty array of non-empty strings");
84
+ }
85
+ return value;
86
+ }
79
87
  function parseBotUsernames(value) {
80
88
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
81
89
  throw new Error(`Invalid config: botUsernames must be an array of strings`);
@@ -223,6 +231,7 @@ function parseGraphqlQuotaWarnings(value, pollIntervalSeconds) {
223
231
  }
224
232
  const KNOWN_CONFIG_KEYS = new Set([
225
233
  "classify",
234
+ "cliCommand",
226
235
  "botUsernames",
227
236
  "ignoreChecks",
228
237
  "iterate",
@@ -284,6 +293,7 @@ function warnUnknownConfigKeys(config) {
284
293
  const rawDefaults = builtins;
285
294
  function parseConfig(value, normalizeMergeArgs = true) {
286
295
  const config = value;
296
+ config.cliCommand = parseCliCommand(config.cliCommand);
287
297
  config.botUsernames = parseBotUsernames(config.botUsernames);
288
298
  config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
289
299
  config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
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",
@@ -9,6 +9,7 @@ export interface TelemetryAggregate {
9
9
  unmeasuredRequestCount: number;
10
10
  nodeCount: number;
11
11
  rateLimit?: RateLimitInfo;
12
+ credentialFingerprint?: string;
12
13
  };
13
14
  rest: Map<string, {
14
15
  requestCount: number;
@@ -21,7 +21,7 @@ export function aggregateEvents(events) {
21
21
  aggregate.graphql.measuredQueryCost += event.rateLimit.cost;
22
22
  aggregate.graphql.nodeCount += event.rateLimit?.nodeCount ?? 0;
23
23
  if (event.rateLimit !== undefined) {
24
- aggregate.graphql.rateLimit = selectAuthoritativeRateLimit(aggregate.graphql.rateLimit, event.rateLimit);
24
+ adoptGraphqlRateLimit(aggregate.graphql, event.rateLimit, event.credentialFingerprint);
25
25
  }
26
26
  continue;
27
27
  }
@@ -48,7 +48,7 @@ export function mergeAggregate(target, source) {
48
48
  target.graphql.unmeasuredRequestCount += source.graphql.unmeasuredRequestCount;
49
49
  target.graphql.nodeCount += source.graphql.nodeCount;
50
50
  if (source.graphql.rateLimit !== undefined) {
51
- target.graphql.rateLimit = selectAuthoritativeRateLimit(target.graphql.rateLimit, source.graphql.rateLimit);
51
+ adoptGraphqlRateLimit(target.graphql, source.graphql.rateLimit, source.graphql.credentialFingerprint);
52
52
  }
53
53
  for (const [resource, sourceGroup] of source.rest) {
54
54
  const targetGroup = target.rest.get(resource) ?? { requestCount: 0 };
@@ -65,6 +65,12 @@ export function aggregateStore(active) {
65
65
  mergeAggregate(all, aggregateEvents(active.events));
66
66
  return all;
67
67
  }
68
+ function adoptGraphqlRateLimit(graphql, candidate, fingerprint) {
69
+ const next = selectAuthoritativeRateLimit(graphql.rateLimit, candidate);
70
+ if (next !== graphql.rateLimit)
71
+ graphql.credentialFingerprint = fingerprint;
72
+ graphql.rateLimit = next;
73
+ }
68
74
  function selectAuthoritativeRateLimit(current, candidate) {
69
75
  if (current === undefined)
70
76
  return { ...candidate };
@@ -4,10 +4,14 @@ export interface ApiTelemetryEvent {
4
4
  kind: "GraphQL" | "REST";
5
5
  method: string;
6
6
  authSource: string;
7
+ /** Truncated SHA-256 of the credential that produced this request. */
8
+ credentialFingerprint?: string;
7
9
  rateLimit?: RateLimitInfo;
8
10
  }
9
11
  /** Isolates a top-level CLI/MCP command while allowing nested iterate ticks to aggregate. */
10
12
  export declare function withApiTelemetryScope<T>(fn: () => Promise<T>): Promise<T>;
11
13
  export declare function recordApiTelemetry(event: ApiTelemetryEvent): void;
12
14
  export declare function mergeGraphqlRateLimit(headerRateLimit: RateLimitInfo | null, data: unknown): RateLimitInfo | null;
15
+ /** Attach the credential fingerprint without adding it when this command has none. */
16
+ export declare function withGraphqlCredentialFingerprint<T extends object>(sample: T): T;
13
17
  export declare function summarizeApiTelemetry(): ApiUsage | undefined;
@@ -65,6 +65,18 @@ export function mergeGraphqlRateLimit(headerRateLimit, data) {
65
65
  ...(payload !== null && Number.isFinite(payload.nodeCount) && { nodeCount: payload.nodeCount }),
66
66
  };
67
67
  }
68
+ /** Fingerprint of the credential that owns the authoritative GraphQL rate-limit sample. */
69
+ function graphqlQuotaCredentialFingerprint() {
70
+ const active = store();
71
+ if (active === undefined)
72
+ return undefined;
73
+ return aggregateStore(active).graphql.credentialFingerprint;
74
+ }
75
+ /** Attach the credential fingerprint without adding it when this command has none. */
76
+ export function withGraphqlCredentialFingerprint(sample) {
77
+ const credentialFingerprint = graphqlQuotaCredentialFingerprint();
78
+ return credentialFingerprint === undefined ? sample : { ...sample, credentialFingerprint };
79
+ }
68
80
  export function summarizeApiTelemetry() {
69
81
  const active = store();
70
82
  if (active === undefined)
@@ -125,7 +125,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
125
125
  null,
126
126
  },
127
127
  baseRefName: raw.baseRefName,
128
- ...(raw.baseRef?.target?.oid && { baseRefOid: raw.baseRef.target.oid }),
128
+ ...(raw.baseRefOid && { baseRefOid: raw.baseRefOid }),
129
129
  reviewRequests,
130
130
  latestReviews,
131
131
  reviewThreads,
@@ -80,9 +80,6 @@ interface RawRuleParameters {
80
80
  }>;
81
81
  }
82
82
  export interface RawBaseRef {
83
- target?: {
84
- oid: string;
85
- } | null;
86
83
  branchProtectionRule: RawBranchProtectionRule | null;
87
84
  rules: {
88
85
  pageInfo?: {
@@ -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
+ }
@@ -88,39 +88,10 @@ fragment PollSummaryPr on PullRequest {
88
88
  }
89
89
  mergeQueueEntry {
90
90
  headCommit {
91
+ oid
91
92
  statusCheckRollup {
92
93
  contexts(last: 100) {
93
- totalCount
94
- pageInfo {
95
- hasPreviousPage
96
- }
97
- nodes {
98
- __typename
99
- ... on CheckRun {
100
- id
101
- name
102
- status
103
- conclusion
104
- detailsUrl
105
- annotations(first: 1) {
106
- totalCount
107
- }
108
- checkSuite {
109
- workflowRun {
110
- databaseId
111
- event
112
- workflow {
113
- databaseId
114
- name
115
- }
116
- }
117
- }
118
- }
119
- ... on StatusContext {
120
- context
121
- state
122
- }
123
- }
94
+ ...PollSummaryCheckContexts
124
95
  }
125
96
  }
126
97
  }
@@ -212,39 +183,11 @@ fragment PollSummaryPr on PullRequest {
212
183
  commits(last: 1) {
213
184
  nodes {
214
185
  commit {
186
+ oid
187
+ committedDate
215
188
  statusCheckRollup {
216
189
  contexts(last: 100) {
217
- totalCount
218
- pageInfo {
219
- hasPreviousPage
220
- }
221
- nodes {
222
- __typename
223
- ... on CheckRun {
224
- id
225
- name
226
- status
227
- conclusion
228
- detailsUrl
229
- annotations(first: 1) {
230
- totalCount
231
- }
232
- checkSuite {
233
- workflowRun {
234
- databaseId
235
- event
236
- workflow {
237
- databaseId
238
- name
239
- }
240
- }
241
- }
242
- }
243
- ... on StatusContext {
244
- context
245
- state
246
- }
247
- }
190
+ ...PollSummaryCheckContexts
248
191
  }
249
192
  }
250
193
  }
@@ -1,9 +1,6 @@
1
1
  fragment PrMergePolicy on PullRequest {
2
2
  isMergeQueueEnabled
3
3
  baseRef {
4
- target {
5
- oid
6
- }
7
4
  branchProtectionRule {
8
5
  requiresApprovingReviews
9
6
  requiredApprovingReviewCount
@@ -22,9 +22,11 @@ async function graphqlInner(query, vars, opts) {
22
22
  }));
23
23
  const t0 = performance.now();
24
24
  let authSource = "unknown";
25
+ let credentialFingerprint;
25
26
  const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => {
26
27
  const auth = await makeAuthHeaders();
27
28
  authSource = auth.source;
29
+ credentialFingerprint = auth.fingerprint;
28
30
  return fetch(url, {
29
31
  method: "POST",
30
32
  headers: auth.headers,
@@ -38,6 +40,7 @@ async function graphqlInner(query, vars, opts) {
38
40
  response,
39
41
  durationMs,
40
42
  authSource,
43
+ credentialFingerprint,
41
44
  }));
42
45
  const durationMs = Math.round(performance.now() - retryT0);
43
46
  const headerRateLimit = parseRateLimit(res.headers);
@@ -61,6 +64,7 @@ async function graphqlInner(query, vars, opts) {
61
64
  kind: "GraphQL",
62
65
  method: "POST",
63
66
  authSource,
67
+ credentialFingerprint,
64
68
  rateLimit: headerRateLimit ?? undefined,
65
69
  });
66
70
  throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, {
@@ -93,6 +97,7 @@ async function graphqlInner(query, vars, opts) {
93
97
  kind: "GraphQL",
94
98
  method: "POST",
95
99
  authSource,
100
+ credentialFingerprint,
96
101
  rateLimit: headerRateLimit ?? undefined,
97
102
  });
98
103
  throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
@@ -123,6 +128,7 @@ async function graphqlInner(query, vars, opts) {
123
128
  kind: "GraphQL",
124
129
  method: "POST",
125
130
  authSource,
131
+ credentialFingerprint,
126
132
  rateLimit: rateLimit ?? undefined,
127
133
  });
128
134
  const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
@@ -2,6 +2,8 @@ export type AuthSource = "GH_TOKEN" | "GITHUB_TOKEN" | "gh auth token" | "GITHUB
2
2
  export declare function _resetTokenCache(): void;
3
3
  export declare function hasCachedToken(): boolean;
4
4
  export declare function clearTokenCache(): void;
5
+ /** Truncated SHA-256. Callers persist this instead of the token or its full hash. */
6
+ export declare function credentialFingerprint(token: string): string;
5
7
  /**
6
8
  * `extra` lets callers layer additional headers (e.g. `If-None-Match` for
7
9
  * conditional REST requests) on top of the standard auth/version headers.
@@ -9,4 +11,5 @@ export declare function clearTokenCache(): void;
9
11
  export declare function makeAuthHeaders(extra?: Record<string, string>): Promise<{
10
12
  headers: Record<string, string>;
11
13
  source: AuthSource;
14
+ fingerprint: string;
12
15
  }>;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { execFile as execFileCb } from "node:child_process";
2
3
  import { promisify } from "node:util";
3
4
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
@@ -15,6 +16,10 @@ export function clearTokenCache() {
15
16
  _token = undefined;
16
17
  _tokenSource = undefined;
17
18
  }
19
+ /** Truncated SHA-256. Callers persist this instead of the token or its full hash. */
20
+ export function credentialFingerprint(token) {
21
+ return createHash("sha256").update(token).digest("hex").slice(0, 16);
22
+ }
18
23
  async function resolveToken() {
19
24
  if (_token && _tokenSource)
20
25
  return { token: _token, source: _tokenSource };
@@ -58,6 +63,7 @@ export async function makeAuthHeaders(extra) {
58
63
  const { token, source } = await resolveToken();
59
64
  return {
60
65
  source,
66
+ fingerprint: credentialFingerprint(token),
61
67
  headers: {
62
68
  Authorization: `Bearer ${token}`,
63
69
  Accept: "application/vnd.github+json",
@@ -6,4 +6,5 @@ export declare function recordIntermediateResponse(opts: {
6
6
  response: Response;
7
7
  durationMs: number;
8
8
  authSource: string;
9
+ credentialFingerprint?: string;
9
10
  }): void;
@@ -15,6 +15,9 @@ export function recordIntermediateResponse(opts) {
15
15
  kind: opts.kind === "GraphQL" ? "GraphQL" : "REST",
16
16
  method: opts.method,
17
17
  authSource: opts.authSource,
18
+ ...(opts.credentialFingerprint !== undefined && {
19
+ credentialFingerprint: opts.credentialFingerprint,
20
+ }),
18
21
  rateLimit,
19
22
  });
20
23
  }