pr-shepherd 0.44.0 → 0.45.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 (100) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -4
  3. package/bin/checks/conclusions.d.mts +24 -1
  4. package/bin/checks/conclusions.mjs +39 -0
  5. package/bin/checks/triage.d.mts +3 -2
  6. package/bin/checks/triage.mjs +44 -15
  7. package/bin/cli/api-usage-formatter.d.mts +3 -0
  8. package/bin/cli/api-usage-formatter.mjs +36 -0
  9. package/bin/cli/error-format.d.mts +13 -0
  10. package/bin/cli/error-format.mjs +41 -0
  11. package/bin/cli/fix-formatter.mjs +12 -1
  12. package/bin/cli/iterate-activity-formatter.d.mts +2 -0
  13. package/bin/cli/iterate-activity-formatter.mjs +26 -0
  14. package/bin/cli/iterate-formatter.mjs +11 -28
  15. package/bin/cli/iterate-instructions.mjs +14 -1
  16. package/bin/cli/iterate-lean.mjs +1 -0
  17. package/bin/cli/mutate-formatter.mjs +2 -0
  18. package/bin/commands/check-annotations.d.mts +2 -1
  19. package/bin/commands/check-annotations.mjs +7 -7
  20. package/bin/commands/check.mjs +6 -4
  21. package/bin/commands/iterate/api-usage.d.mts +2 -0
  22. package/bin/commands/iterate/api-usage.mjs +42 -0
  23. package/bin/commands/iterate/check-instructions.d.mts +2 -2
  24. package/bin/commands/iterate/check-instructions.mjs +27 -8
  25. package/bin/commands/iterate/fix-code.mjs +42 -8
  26. package/bin/commands/iterate/index.mjs +6 -3
  27. package/bin/commands/iterate/merge-state.mjs +5 -2
  28. package/bin/commands/iterate/render.d.mts +1 -1
  29. package/bin/commands/iterate/render.mjs +9 -5
  30. package/bin/commands/iterate/run.d.mts +2 -0
  31. package/bin/commands/iterate/run.mjs +8 -0
  32. package/bin/commands/mark-files-as-viewed.mjs +8 -0
  33. package/bin/commands/poll-run.d.mts +2 -0
  34. package/bin/commands/poll-run.mjs +8 -0
  35. package/bin/commands/poll.d.mts +1 -2
  36. package/bin/commands/poll.mjs +29 -10
  37. package/bin/comments/rate-limit.d.mts +4 -0
  38. package/bin/comments/rate-limit.mjs +6 -0
  39. package/bin/config/load.d.mts +6 -1
  40. package/bin/config/load.mjs +33 -1
  41. package/bin/config.json +6 -1
  42. package/bin/exit-codes.d.mts +1 -1
  43. package/bin/exit-codes.mjs +1 -1
  44. package/bin/github/api-telemetry-aggregate.d.mts +32 -0
  45. package/bin/github/api-telemetry-aggregate.mjs +84 -0
  46. package/bin/github/api-telemetry.d.mts +13 -0
  47. package/bin/github/api-telemetry.mjs +128 -0
  48. package/bin/github/check-annotations.d.mts +14 -1
  49. package/bin/github/check-annotations.mjs +29 -2
  50. package/bin/github/errors.d.mts +8 -0
  51. package/bin/github/errors.mjs +29 -1
  52. package/bin/github/gql/batch-pr-page.gql +8 -0
  53. package/bin/github/gql/batch-pr.gql +8 -0
  54. package/bin/github/gql/check-run-annotations.gql +8 -0
  55. package/bin/github/gql/commit-check-contexts.gql +8 -0
  56. package/bin/github/gql/get-pr-body.gql +8 -0
  57. package/bin/github/gql/get-pr-head-sha.gql +8 -0
  58. package/bin/github/gql/pr-number-by-branch.gql +8 -0
  59. package/bin/github/gql/review-thread-comments.gql +8 -0
  60. package/bin/github/gql/suggestion-threads.gql +8 -0
  61. package/bin/github/graphql-http.mjs +66 -10
  62. package/bin/github/graphql-internal-retry.d.mts +2 -0
  63. package/bin/github/graphql-internal-retry.mjs +50 -0
  64. package/bin/github/graphql-response.mjs +11 -1
  65. package/bin/github/http-auth.d.mts +9 -1
  66. package/bin/github/http-auth.mjs +35 -14
  67. package/bin/github/http-intermediate.d.mts +9 -0
  68. package/bin/github/http-intermediate.mjs +20 -0
  69. package/bin/github/http-request.d.mts +1 -1
  70. package/bin/github/http-request.mjs +1 -1
  71. package/bin/github/http-utils.d.mts +6 -0
  72. package/bin/github/http-utils.mjs +10 -1
  73. package/bin/github/rest-http.d.mts +16 -1
  74. package/bin/github/rest-http.mjs +71 -8
  75. package/bin/github/rest-text.mjs +41 -5
  76. package/bin/index.mjs +2 -1
  77. package/bin/log/session.d.mts +11 -0
  78. package/bin/log/session.mjs +22 -0
  79. package/bin/mcp/server.mjs +16 -2
  80. package/bin/quota-warning.d.mts +2 -0
  81. package/bin/quota-warning.mjs +6 -0
  82. package/bin/state/graphql-quota-claims.d.mts +2 -0
  83. package/bin/state/graphql-quota-claims.mjs +61 -0
  84. package/bin/state/graphql-quota-policy.d.mts +17 -0
  85. package/bin/state/graphql-quota-policy.mjs +43 -0
  86. package/bin/state/graphql-quota-warnings.d.mts +6 -0
  87. package/bin/state/graphql-quota-warnings.mjs +105 -0
  88. package/bin/state/rest-cache.d.mts +48 -0
  89. package/bin/state/rest-cache.mjs +91 -0
  90. package/bin/types/api-usage.d.mts +31 -0
  91. package/bin/types/api-usage.mjs +1 -0
  92. package/bin/types/iterate.d.mts +4 -1
  93. package/bin/types/report.d.mts +2 -0
  94. package/bin/types.d.mts +1 -0
  95. package/bin/types.mjs +1 -0
  96. package/package.json +1 -1
  97. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  98. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  99. package/plugins/pr-shepherd/.mcp.json +1 -1
  100. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +14 -9
@@ -27,6 +27,7 @@ export async function runCheck(opts) {
27
27
  if (prNumber === null) {
28
28
  throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
29
29
  }
30
+ const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
30
31
  const config = loadConfig();
31
32
  const paginateApprovedReviews = config.iterate.minimizeApprovals;
32
33
  const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
@@ -40,7 +41,7 @@ export async function runCheck(opts) {
40
41
  }
41
42
  const startupFailureChecks = result.checkSuitesComplete
42
43
  ? []
43
- : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber);
44
+ : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber, stateKey);
44
45
  const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
45
46
  const classifiedPrChecks = classifyChecks(allChecks);
46
47
  const latestRemoval = batchData.latestMergeQueueRemoval;
@@ -65,14 +66,15 @@ export async function runCheck(opts) {
65
66
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
66
67
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
67
68
  const ignored = classifiedChecks.filter((c) => c.category === "ignored");
68
- const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
69
- const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
69
+ const triagedBase = failing.length > 0 && !opts.skipTriage
70
+ ? await triageFailingChecks(failing, repo, stateKey)
71
+ : failing;
70
72
  const seenMap = await loadSeenMap(stateKey);
71
73
  const botUsernames = normalizeBotUsernames(config.botUsernames);
72
74
  const ruleSet = await loadRules(discoverRuleFiles(getEffectiveCwd()));
73
75
  const classifyIndex = buildClassifyIndex(ruleSet, batchData);
74
76
  const partition = partitionBatch(classifyIndex, batchData);
75
- const merged = await attachAndMergeCheckAnnotations({ passing, failing: triagedBase, skipped, filtered, ignored }, seenMap, prNumber);
77
+ const merged = await attachAndMergeCheckAnnotations({ passing, failing: triagedBase, skipped, filtered, ignored }, seenMap, prNumber, { stateKey, headSha: batchData.headRefOid });
76
78
  const ignoredAnnotated = merged.ignored.filter((c) => (c.annotations?.length ?? 0) > 0);
77
79
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized && !partition.suppressedCommentIds.has(c.id));
78
80
  const deniedRuleAutoResolveCommentIds = new Set(partition.ruleAutoResolveCommentIds.filter((id) => batchData.comments.find((comment) => comment.id === id)?.viewerCanMinimize !== true));
@@ -0,0 +1,2 @@
1
+ import type { IterateResult } from "../../types.mts";
2
+ export declare function attachApiUsage(result: IterateResult, persistWarning: boolean, preservePersistedWarning?: boolean): Promise<IterateResult>;
@@ -0,0 +1,42 @@
1
+ import { loadConfig } from "../../config/load.mjs";
2
+ import { summarizeApiTelemetry } from "../../github/api-telemetry.mjs";
3
+ import { evaluateWorktreeGraphqlQuotaWarning } from "../../state/graphql-quota-warnings.mjs";
4
+ import { buildQuotaAwareContinuation } from "../../quota-warning.mjs";
5
+ function shouldWarn(result) {
6
+ if (["wait", "mark_ready", "merge"].includes(result.action))
7
+ return true;
8
+ if (result.action !== "fix_code")
9
+ return false;
10
+ return !result.fix.instructions.some((instruction) => /stop polling|human direction/i.test(instruction));
11
+ }
12
+ export async function attachApiUsage(result, persistWarning, preservePersistedWarning = false) {
13
+ const apiUsage = summarizeApiTelemetry();
14
+ if (apiUsage === undefined)
15
+ return result;
16
+ let quotaWarning = preservePersistedWarning ? result.quotaWarning : undefined;
17
+ if (quotaWarning === undefined && apiUsage.graphql !== undefined && shouldWarn(result)) {
18
+ const [owner, repo] = result.repo.split("/");
19
+ if (owner && repo) {
20
+ quotaWarning = await evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, loadConfig().watch.graphqlQuotaWarnings, apiUsage.graphql, persistWarning);
21
+ }
22
+ }
23
+ const { quotaWarning: _deferredWarning, ...baseResult } = result;
24
+ const withWarning = {
25
+ ...baseResult,
26
+ apiUsage,
27
+ ...(quotaWarning !== undefined && { quotaWarning }),
28
+ };
29
+ if (quotaWarning === undefined || withWarning.action !== "fix_code")
30
+ return withWarning;
31
+ const instructions = [...withWarning.fix.instructions];
32
+ const completion = instructions.at(-1);
33
+ if (completion !== undefined) {
34
+ if (/\[FIX_CODE\].*non-terminal/i.test(completion)) {
35
+ instructions[instructions.length - 1] = buildQuotaAwareContinuation(quotaWarning, "`[FIX_CODE]` is non-terminal. After completing these steps,");
36
+ }
37
+ else if (/\[FIX_CODE\].*is conditional:.*if you did not change code,/i.test(completion)) {
38
+ instructions[instructions.length - 1] = completion.replace(/if you did not change code,(.*?)\s+and iterate again with the same options\./i, (_, before) => `if you did not change code,${before.trimEnd()}. ${buildQuotaAwareContinuation(quotaWarning, "").trimStart()}`);
39
+ }
40
+ }
41
+ return { ...withWarning, fix: { ...withWarning.fix, instructions } };
42
+ }
@@ -27,7 +27,7 @@ export declare function buildBehindBaseHintInstruction(baseBranch: string, hint:
27
27
  * command run unmodified is already correct for them. The pointer below is load-bearing:
28
28
  * without it, nothing in CLI output tells the agent that playbook exists.
29
29
  */
30
- export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand): string[];
30
+ export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand, pushAuthorized?: boolean): string[];
31
31
  /** Build the CI-triage pointer; the skill limits follow-up actions to included evidence. */
32
32
  export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
33
- export declare function buildFixCompletionInstruction(checks: AgentCheck[], requiresRemoteUpdateAuthorization?: boolean, hasShaGatedReviewMutations?: boolean): string;
33
+ export declare function buildFixCompletionInstruction(checks: AgentCheck[], requiresRemoteUpdateAuthorization?: boolean, hasShaGatedReviewMutations?: boolean, pushAuthorized?: boolean): string;
@@ -36,7 +36,7 @@ export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
36
36
  * command run unmodified is already correct for them. The pointer below is load-bearing:
37
37
  * without it, nothing in CLI output tells the agent that playbook exists.
38
38
  */
39
- export function buildResolveCommandInstruction(resolveCommand) {
39
+ export function buildResolveCommandInstruction(resolveCommand, pushAuthorized = false) {
40
40
  if (!resolveCommand.hasMutations)
41
41
  return [];
42
42
  const instructions = [];
@@ -44,7 +44,9 @@ export function buildResolveCommandInstruction(resolveCommand) {
44
44
  instructions.push("Run the generated thread IDs unchanged. A latest comment beginning `<!-- pr-shepherd -->` is an established Shepherd reply; a marked viewer-authored human thread is emitted resolve-only, not for another reply.");
45
45
  }
46
46
  if (resolveCommand.requiresHeadSha) {
47
- instructions.push("If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, do not run this command until an authorized push updates the remote PR head; then replace `$HEAD_SHA` with that pushed commit SHA.");
47
+ instructions.push(pushAuthorized
48
+ ? "If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, commit and push to the PR head branch first, then replace `$HEAD_SHA` with the pushed commit SHA."
49
+ : "If you did not change code, replace `$HEAD_SHA` with `$(git rev-parse HEAD)`, which must equal the current remote PR head. If you changed code, do not run this command until an authorized push updates the remote PR head; then replace `$HEAD_SHA` with that pushed commit SHA.");
48
50
  }
49
51
  if (resolveCommand.requiresDismissMessage) {
50
52
  instructions.push("Replace `$DISMISS_MESSAGE` with one sentence describing what changed.");
@@ -58,32 +60,49 @@ export function buildFailingCheckInstructions(checks) {
58
60
  return [];
59
61
  const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
60
62
  const hasTriageable = checks.some((c) => c.runId || c.detailsUrl);
63
+ const hasRerunAuthorized = checks.some((c) => c.rerunCommand);
61
64
  const instructions = [];
62
65
  if (hasTriageable) {
63
66
  instructions.push('Triage every failure under `## Failing checks`. See "CI failure triage" in the pr-shepherd skill for read-only inspection rules.');
64
67
  }
68
+ if (hasRerunAuthorized) {
69
+ instructions.push('A `[rerun authorized]` check includes a `rerun:` command. See "CI failure triage" in the pr-shepherd skill for which conclusions warrant a rerun versus a code fix.');
70
+ }
65
71
  if (hasBare) {
66
72
  instructions.push("For each `(no runId)` failure, escalate to a human because no log or URL is available.");
67
73
  }
68
74
  return instructions;
69
75
  }
70
- export function buildFixCompletionInstruction(checks, requiresRemoteUpdateAuthorization = false, hasShaGatedReviewMutations = false) {
76
+ export function buildFixCompletionInstruction(checks, requiresRemoteUpdateAuthorization = false, hasShaGatedReviewMutations = false, pushAuthorized = false) {
71
77
  if (requiresRemoteUpdateAuthorization) {
78
+ if (pushAuthorized) {
79
+ return "`[FIX_CODE]` is non-terminal: resolve the conflicts, commit, push to the PR head branch, then iterate again with the same options.";
80
+ }
72
81
  return "`[FIX_CODE]` requires a human handoff for an authorized push after conflict resolution. Shepherd cannot verify the Git credential's push authorization. Stop polling after committing, and resume only after the remote PR head changes.";
73
82
  }
74
83
  const hasUninspectableFailure = checks.some((check) => !check.runId && !check.detailsUrl);
75
- const hasCiAuthorizationHandoff = checks.some((check) => check.conclusion === "CANCELLED" ||
76
- check.conclusion === "STARTUP_FAILURE" ||
77
- (check.runId === null && Boolean(check.detailsUrl)) ||
78
- (check.runId !== null && !check.logExcerpt?.trim()));
79
84
  if (hasUninspectableFailure) {
80
85
  return "`[FIX_CODE]` requires a human handoff for an uninspectable failing check. Stop polling after escalating, and resume only after human direction.";
81
86
  }
82
- if (hasCiAuthorizationHandoff) {
87
+ // Checks that would otherwise need a workflow-run mutation (rerun) to move forward.
88
+ // `rerunCommand` is only populated when the viewer's repository role grants Actions
89
+ // rerun capability (see canRerunWorkflows) — unauthorized checks stay a terminal handoff.
90
+ const ciHandoffChecks = checks.filter((check) => check.conclusion === "CANCELLED" ||
91
+ check.conclusion === "STARTUP_FAILURE" ||
92
+ (check.runId === null && Boolean(check.detailsUrl)) ||
93
+ (check.runId !== null && !check.logExcerpt?.trim()));
94
+ const hasUnauthorizedCiHandoff = ciHandoffChecks.some((check) => !check.rerunCommand);
95
+ if (hasUnauthorizedCiHandoff) {
83
96
  return "`[FIX_CODE]` requires a human handoff for a failing check with no authorized follow-up action. Stop polling after escalating, and resume only after human direction.";
84
97
  }
85
98
  if (hasShaGatedReviewMutations) {
99
+ if (pushAuthorized) {
100
+ return "`[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run the review mutations using the pushed commit SHA and iterate again with the same options; if you did not change code, complete the authorized review mutations and iterate again with the same options.";
101
+ }
86
102
  return "`[FIX_CODE]` is conditional: if you changed code, stop after committing and resume only after an authorized push changes the remote PR head; if you did not change code, complete the authorized review mutations and iterate again with the same options.";
87
103
  }
104
+ if (ciHandoffChecks.some((check) => check.rerunCommand)) {
105
+ return "`[FIX_CODE]` is non-terminal. Run any warranted reruns for `[rerun authorized]` checks (or apply code fixes for real failures), then iterate again with the same options to continue.";
106
+ }
88
107
  return "`[FIX_CODE]` is non-terminal. After completing these steps, iterate again with the same options to continue.";
89
108
  }
@@ -11,6 +11,7 @@ import { applyStallGuard } from "./stall.mjs";
11
11
  import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
12
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
13
13
  import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
14
+ import { canRerunWorkflows, canPushToHead } from "../../checks/conclusions.mjs";
14
15
  import { loadConfig } from "../../config/load.mjs";
15
16
  import { formatPrUrl } from "../../pr-reference.mjs";
16
17
  function nextFixAttempts(stored, headSha, threads) {
@@ -144,14 +145,39 @@ export async function handleFixCode(ctx) {
144
145
  };
145
146
  }
146
147
  await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
147
- // GitHub does not expose a per-run viewer capability for cancellation. Fail closed:
148
- // do not issue or recommend an Actions mutation based only on repository role.
148
+ // GitHub does not expose a per-run viewer capability for cancellation, so Shepherd never
149
+ // issues or recommends a cancellation regardless of repository role. A rerun is different:
150
+ // GitHub's Actions rerun API requires actions:write, which rides with WRITE+ repo access, so
151
+ // repositoryPermission is a proxy for account-level rerun capability (see canRerunWorkflows) —
152
+ // it does not confirm the credential executing `gh` has that scope; an unauthorized rerun
153
+ // simply fails when the agent runs it, the same residual risk as every other CLI-recommended
154
+ // git/gh mutation in this codebase.
149
155
  const cancelled = [];
150
156
  const baseLookup = validateBaseBranch(report.baseBranch);
151
157
  const threads = report.threads.actionable.map(toAgentThread);
152
158
  const resolutionOnlyThreads = report.threads.resolutionOnly;
153
159
  const actionableComments = report.comments.actionable.map(toAgentComment);
154
- const failingAgentChecks = toAgentChecks(failingChecks);
160
+ const rerunAuthorized = canRerunWorkflows(report.viewerAuthorization);
161
+ // A workflow run can only be rerun once it has fully completed; a runId still present among
162
+ // in-progress checks (a sibling job from the same run) is not yet eligible.
163
+ const inProgressWorkflowRunIds = new Set(report.checks.inProgress.flatMap((c) => (c.runId !== null ? [c.runId] : [])));
164
+ // Confirmed GitHub Actions provenance for a runId: `source: "startup_failure"` checks are
165
+ // fetched directly from the Actions REST API (always genuine), while `source: "check_run"`
166
+ // checks need `workflowName` — derived from the same `checkSuite.workflowRun.workflow` GraphQL
167
+ // path as the run's numeric ID — because a third-party GitHub App's CheckRun can carry a
168
+ // details-URL-parsed runId that merely looks like an Actions run number.
169
+ const actionsRunIds = new Set(failingChecks.flatMap((c) => c.runId !== null && (c.source === "startup_failure" || c.workflowName !== undefined)
170
+ ? [c.runId]
171
+ : []));
172
+ const failingAgentChecks = toAgentChecks(failingChecks).map((c) => rerunAuthorized &&
173
+ c.runId &&
174
+ actionsRunIds.has(c.runId) &&
175
+ // ACTION_REQUIRED means the run is paused pending manual workflow approval; rerunning does
176
+ // not grant that approval, so no rerun command applies.
177
+ c.conclusion !== "ACTION_REQUIRED" &&
178
+ !inProgressWorkflowRunIds.has(c.runId)
179
+ ? { ...c, rerunCommand: `gh run rerun ${c.runId} -R ${report.repo}` }
180
+ : c);
155
181
  const checks = [
156
182
  ...failingAgentChecks,
157
183
  ...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
@@ -160,16 +186,24 @@ export async function handleFixCode(ctx) {
160
186
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
161
187
  const isBehind = report.mergeStatus.status === "BEHIND";
162
188
  const { behindBaseHint } = loadConfig().iterate;
189
+ // Whether GitHub reports the viewer can push to the PR head branch (own-repo write
190
+ // access, or a fork with viewerCanEditFiles/head-repo write for a fork PR). Drives
191
+ // whether the fix instructions push autonomously or hand off for an authorized push —
192
+ // see canPushToHead.
193
+ const pushAuthorized = canPushToHead(report.viewerAuthorization);
163
194
  // Only surface in-progress runs when a push is plausible — resolution-only and
164
195
  // summary-only iterations have no path to a push, so listing runs would prompt
165
196
  // unnecessary cancellation.
166
197
  const inProgressRunIds = [];
167
198
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
168
199
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
169
- // Conflict resolution necessarily changes the branch head, but Shepherd cannot verify
170
- // authorization for the local Git credential that would publish it. Defer review mutations
171
- // until an authorized push updates the PR and a fresh iteration can rebuild SHA-safe commands.
172
- const { resolveCommand, resolveOnlyCommand } = hasConflicts
200
+ // Conflict resolution necessarily changes the branch head. When the viewer cannot push,
201
+ // Shepherd cannot verify authorization for the local Git credential that would publish
202
+ // it, so review mutations are deferred until an authorized push updates the PR and a
203
+ // fresh iteration can rebuild SHA-safe commands. When the viewer can push, the agent
204
+ // pushes as part of this fix_code tick, so mutations can be built normally (SHA-gated
205
+ // like any other push-requiring tick).
206
+ const { resolveCommand, resolveOnlyCommand } = hasConflicts && !pushAuthorized
173
207
  ? buildResolveCommand([], [], [], [], [], prReference, botUsernames, [], undefined, [])
174
208
  : buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, failingAgentChecks, prReference, botUsernames, ruleAutoResolveThreadIds, report.viewerAuthorization, allThreads);
175
209
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
@@ -202,7 +236,7 @@ export async function handleFixCode(ctx) {
202
236
  }
203
237
  const firstLookThreads = report.threads.firstLook;
204
238
  const firstLookComments = report.comments.firstLook;
205
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true);
239
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, pushAuthorized);
206
240
  const prospectiveResult = {
207
241
  ...base,
208
242
  baseBranch: baseLookup.branch,
@@ -14,7 +14,11 @@ import { checksWithActionableAnnotations } from "../check-annotations.mjs";
14
14
  import { buildReadyMergeResult, handleActiveMergeState } from "./merge-state.mjs";
15
15
  import { buildIterateBase } from "./base.mjs";
16
16
  import { markReadyIfAuthorized } from "./mark-ready.mjs";
17
- export async function runIterate(opts) {
17
+ import { withIterateApiUsage } from "./run.mjs";
18
+ export function runIterate(opts) {
19
+ return withIterateApiUsage(opts, () => runIterateCore(opts));
20
+ }
21
+ async function runIterateCore(opts) {
18
22
  const config = loadConfig();
19
23
  const botUsernames = normalizeBotUsernames(config.botUsernames);
20
24
  const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
@@ -48,8 +52,7 @@ export async function runIterate(opts) {
48
52
  seen: report.reviewSummaries,
49
53
  edited: report.editedSummaries,
50
54
  }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly], report.ruleAutoResolveReviewSummaryIds);
51
- // Already-seen review summaries have no new content to surface — minimize them
52
- // in-process so they never register as agent-facing actionable work (#313).
55
+ // Minimize already-seen review summaries in-process so they never become agent-facing work.
53
56
  // GitHub can still return a null/error/rate-limit result per ID without
54
57
  // throwing (autoMinimizeComments reports this via `errors`, not a rejection);
55
58
  // any ID it did not confirm minimized falls back into the agent-facing set so
@@ -7,7 +7,10 @@ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
7
7
  return null;
8
8
  const queue = Boolean(report.mergeStatus.mergeRequirements?.mergeQueue?.required ||
9
9
  report.mergeStatus.mergeRequirements?.mergeQueue?.enabled);
10
- if (!queue && report.viewerAuthorization?.viewerCanEnableAutoMerge === true) {
10
+ // A merge queue is enrolled the same way auto-merge is granted: enabling
11
+ // auto-merge on a queue-required PR is what adds it to the queue, so
12
+ // viewerCanEnableAutoMerge authorizes both the plain-merge and enqueue paths.
13
+ if (report.viewerAuthorization?.viewerCanEnableAutoMerge === true) {
11
14
  return {
12
15
  ...base,
13
16
  action: "merge",
@@ -16,7 +19,7 @@ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
16
19
  repo: report.repo,
17
20
  nodeId: report.nodeId,
18
21
  headSha: report.headSha ?? "unknown",
19
- queue: false,
22
+ queue,
20
23
  }),
21
24
  };
22
25
  }
@@ -2,4 +2,4 @@ import type { AgentThread, AgentComment, AgentCheck, Review, ResolveCommand, Fir
2
2
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
3
3
  export declare function renderResolveCommand(rc: ResolveCommand): string;
4
4
  export declare function buildFixInstructions(threads: AgentThread[], actionableComments: AgentComment[], checks: AgentCheck[], changesRequestedReviews: Review[], baseBranch: string, resolveCommand: ResolveCommand, hasConflicts: boolean, prReference: string | number, cancelledCount: number, firstLookThreads?: FirstLookThread[], firstLookComments?: FirstLookComment[], firstLookSummaries?: Review[], editedSummaries?: Review[], inProgressRunIds?: string[], resolutionOnlyThreads?: ReviewThread[], resolveOnlyCommand?: ResolveCommand, behindBaseHint?: string, // iterate.behindBaseHint — see buildBehindBaseHintInstruction
5
- isBehind?: boolean, viewerCanUpdate?: boolean): string[];
5
+ isBehind?: boolean, viewerCanUpdate?: boolean, pushAuthorized?: boolean): string[];
@@ -11,7 +11,7 @@ export function renderResolveCommand(rc) {
11
11
  return renderShellCommand(parts);
12
12
  }
13
13
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prReference, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
14
- isBehind = false, viewerCanUpdate = false) {
14
+ isBehind = false, viewerCanUpdate = false, pushAuthorized = false) {
15
15
  const instructions = [];
16
16
  const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
17
17
  const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
@@ -81,10 +81,14 @@ isBehind = false, viewerCanUpdate = false) {
81
81
  const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
82
82
  const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
83
83
  if (hasConflicts) {
84
- instructions.push(`Commit any remaining conflict-resolution changes${mutationSuffix}.`);
84
+ instructions.push(pushAuthorized
85
+ ? `Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`
86
+ : `Commit any remaining conflict-resolution changes${mutationSuffix}.`);
85
87
  }
86
88
  else if (hasNonConflictHints) {
87
- instructions.push("If you changed code, commit any remaining changes, then stop and hand off for a push whose authorization is established outside Shepherd; do not run the remaining review mutations or iterate until the remote PR head changes. Shepherd cannot verify the Git credential's push authorization. If you did not change code, do not commit and continue with the remaining steps.");
89
+ instructions.push(pushAuthorized
90
+ ? "If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate again with the same options. If you did not change code, do not commit and continue with the remaining steps."
91
+ : "If you changed code, commit any remaining changes, then stop and hand off for a push whose authorization is established outside Shepherd; do not run the remaining review mutations or iterate until the remote PR head changes. Shepherd cannot verify the Git credential's push authorization. If you did not change code, do not commit and continue with the remaining steps.");
88
92
  }
89
93
  if (viewerCanUpdate &&
90
94
  (hasReviewMutations ||
@@ -96,7 +100,7 @@ isBehind = false, viewerCanUpdate = false) {
96
100
  }
97
101
  if (resolveOnlyCommand?.hasMutations)
98
102
  instructions.push("Run the `resolve-only:` command shown above.");
99
- instructions.push(...buildResolveCommandInstruction(resolveCommand));
100
- instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha));
103
+ instructions.push(...buildResolveCommandInstruction(resolveCommand, pushAuthorized));
104
+ instructions.push(buildFixCompletionInstruction(failingChecks, hasConflicts, resolveCommand.requiresHeadSha, pushAuthorized));
101
105
  return instructions;
102
106
  }
@@ -0,0 +1,2 @@
1
+ import type { IterateCommandOptions, IterateResult } from "../../types.mts";
2
+ export declare function withIterateApiUsage(opts: IterateCommandOptions, runCore: () => Promise<IterateResult>): Promise<IterateResult>;
@@ -0,0 +1,8 @@
1
+ import { withApiTelemetryScope } from "../../github/api-telemetry.mjs";
2
+ import { attachApiUsage } from "./api-usage.mjs";
3
+ export function withIterateApiUsage(opts, runCore) {
4
+ return withApiTelemetryScope(async () => {
5
+ const result = await runCore();
6
+ return attachApiUsage(result, opts.deferQuotaWarning !== true);
7
+ });
8
+ }
@@ -3,6 +3,14 @@ import { graphql, getCurrentPrNumber, getRepoInfo } from "../github/client.mjs";
3
3
  import { paginateForward } from "../github/pagination.mjs";
4
4
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
5
5
  const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
6
+ _shepherdRateLimit: rateLimit {
7
+ cost
8
+ limit
9
+ nodeCount
10
+ remaining
11
+ resetAt
12
+ used
13
+ }
6
14
  repository(owner: $owner, name: $repo) {
7
15
  pullRequest(number: $pr) {
8
16
  id
@@ -0,0 +1,2 @@
1
+ import type { IterateResult } from "../types.mts";
2
+ export declare function withPollApiUsage(runCore: () => Promise<IterateResult>, preservePersistedWarning: boolean): Promise<IterateResult>;
@@ -0,0 +1,8 @@
1
+ import { withApiTelemetryScope } from "../github/api-telemetry.mjs";
2
+ import { attachApiUsage } from "./iterate/api-usage.mjs";
3
+ export function withPollApiUsage(runCore, preservePersistedWarning) {
4
+ return withApiTelemetryScope(async () => {
5
+ const result = await runCore();
6
+ return attachApiUsage(result, true, preservePersistedWarning);
7
+ });
8
+ }
@@ -1,5 +1,5 @@
1
1
  import type { IterateCommandOptions, IterateResult } from "../types.mts";
2
- interface PollCommandOptions extends IterateCommandOptions {
2
+ export interface PollCommandOptions extends IterateCommandOptions {
3
3
  intervalSeconds: number;
4
4
  timeoutSeconds: number;
5
5
  /** Settle window after first FIX_CODE before returning. Default 60. 0 disables. */
@@ -9,4 +9,3 @@ interface PollCommandOptions extends IterateCommandOptions {
9
9
  }
10
10
  /** @deprecated Hidden implementation for the legacy `poll` alias. */
11
11
  export declare function runPoll(opts: PollCommandOptions): Promise<IterateResult>;
12
- export {};
@@ -1,5 +1,6 @@
1
1
  import { runIterate } from "./iterate/index.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
+ import { withPollApiUsage } from "./poll-run.mjs";
3
4
  const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
4
5
  function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
5
6
  if (verbose) {
@@ -61,7 +62,10 @@ function writeDebounceProgress(tick, elapsedMs, remainingMs) {
61
62
  process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] FIX_CODE — debounce ${remainingSeconds}s remaining\n`);
62
63
  }
63
64
  /** @deprecated Hidden implementation for the legacy `poll` alias. */
64
- export async function runPoll(opts) {
65
+ export function runPoll(opts) {
66
+ return withPollApiUsage(() => runPollCore(opts), opts.untilTerminal === true);
67
+ }
68
+ async function runPollCore(opts) {
65
69
  const { intervalSeconds, timeoutSeconds, debounceSeconds: debounceSecondsOpt, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
66
70
  const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
67
71
  const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
@@ -74,11 +78,8 @@ export async function runPoll(opts) {
74
78
  const quietStatus = quietStatusOpt === true;
75
79
  const untilTerminal = untilTerminalOpt === true;
76
80
  let lastWaitSignature = null;
77
- // When prNumber is omitted, iterateOpts.prNumber starts undefined and each tick would otherwise
78
- // re-infer the PR from the current branch. That inference query only matches OPEN PRs, so once
79
- // the monitored PR merges it returns nothing and runIterate throws instead of reporting the
80
- // terminal CANCEL/merged result. Pin the PR resolved by the first tick so later ticks target it
81
- // directly and never re-run branch discovery.
81
+ let pendingQuotaWarning;
82
+ // Pin the PR resolved by the first tick; branch inference only matches OPEN PRs.
82
83
  let prNumber = opts.prNumber;
83
84
  let debounceUntil = null;
84
85
  while (true) {
@@ -88,10 +89,31 @@ export async function runPoll(opts) {
88
89
  ...iterateOpts,
89
90
  prNumber,
90
91
  persistSeen: debounceSeconds === 0 || pastDebounce,
92
+ // Until-terminal must persist before deciding to return.
93
+ deferQuotaWarning: !untilTerminal,
91
94
  });
92
95
  prNumber ??= lastResult.pr;
96
+ if (lastResult.quotaWarning !== undefined)
97
+ pendingQuotaWarning = lastResult.quotaWarning;
98
+ if (untilTerminal &&
99
+ pendingQuotaWarning !== undefined &&
100
+ !(lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) &&
101
+ !(debounceUntil !== null && !pastDebounce)) {
102
+ const terminalOrHandoff = ["cancel", "escalate"].includes(lastResult.action) ||
103
+ (lastResult.action === "fix_code" &&
104
+ lastResult.fix.instructions.some((instruction) => /stop polling|human direction/i.test(instruction)));
105
+ if (terminalOrHandoff) {
106
+ const { quotaWarning: _quotaWarning, ...withoutQuotaWarning } = lastResult;
107
+ lastResult = withoutQuotaWarning;
108
+ }
109
+ else if (lastResult.quotaWarning === undefined) {
110
+ lastResult = { ...lastResult, quotaWarning: pendingQuotaWarning };
111
+ }
112
+ break;
113
+ }
93
114
  if (lastResult.action === "wait" && !pastDebounce) {
94
- debounceUntil = null;
115
+ if (pendingQuotaWarning === undefined)
116
+ debounceUntil = null;
95
117
  const elapsedMs = Date.now() - start;
96
118
  if (!untilTerminal) {
97
119
  const remainingMs = timeoutMs - elapsedMs;
@@ -126,9 +148,6 @@ export async function runPoll(opts) {
126
148
  writeDebounceProgress(tick, Date.now() - start, remainingMs);
127
149
  await sleep(Math.min(intervalMs, remainingMs));
128
150
  }
129
- // Always continue so a tick that *started* before debounceUntil but *finished*
130
- // after it cannot return with persistSeen: false. The next loop iteration
131
- // sees pastDebounce and persists.
132
151
  continue;
133
152
  }
134
153
  break;
@@ -2,15 +2,19 @@ export interface ResolveRateLimitStop {
2
2
  message: string;
3
3
  retryAfterSeconds?: number;
4
4
  limit?: number;
5
+ used?: number;
5
6
  remaining?: number;
6
7
  resetAt?: number;
8
+ resource?: string;
7
9
  }
8
10
  export declare function rateLimitFromError(err: unknown, fallbackMessage: string): ResolveRateLimitStop | null;
9
11
  export declare function rateLimitFromGraphQlResult(messages: string[], meta: {
10
12
  rateLimit?: {
11
13
  remaining?: unknown;
12
14
  limit?: unknown;
15
+ used?: unknown;
13
16
  resetAt?: unknown;
17
+ resource?: unknown;
14
18
  };
15
19
  retryAfterSeconds?: unknown;
16
20
  stopOnZeroRemaining?: boolean;
@@ -26,15 +26,21 @@ function buildRateLimitStop(message, meta) {
26
26
  const retryAfterSeconds = finiteNumber(meta.retryAfterSeconds);
27
27
  const remaining = finiteNumber(meta.rateLimit?.remaining);
28
28
  const limit = finiteNumber(meta.rateLimit?.limit);
29
+ const used = finiteNumber(meta.rateLimit?.used);
29
30
  const resetAt = finiteNumber(meta.rateLimit?.resetAt);
31
+ const resource = typeof meta.rateLimit?.resource === "string" ? meta.rateLimit.resource : undefined;
30
32
  if (retryAfterSeconds !== undefined)
31
33
  stop.retryAfterSeconds = retryAfterSeconds;
32
34
  if (limit !== undefined)
33
35
  stop.limit = limit;
36
+ if (used !== undefined)
37
+ stop.used = used;
34
38
  if (remaining !== undefined)
35
39
  stop.remaining = remaining;
36
40
  if (resetAt !== undefined)
37
41
  stop.resetAt = resetAt;
42
+ if (resource !== undefined)
43
+ stop.resource = resource;
38
44
  return stop;
39
45
  }
40
46
  function finiteNumber(value) {
@@ -1,6 +1,10 @@
1
1
  declare const MINIMIZE_COMMENTS_POLICIES: readonly ["all", "bots", "users", "none"];
2
2
  export type MinimizeCommentsPolicy = (typeof MINIMIZE_COMMENTS_POLICIES)[number];
3
- interface PrShepherdConfig {
3
+ export interface GraphqlQuotaWarningBand {
4
+ remainingPercent: number;
5
+ pollIntervalMinutes: number;
6
+ }
7
+ export interface PrShepherdConfig {
4
8
  /** Optional user classification configuration; preserved for rule consumers. */
5
9
  classify?: unknown;
6
10
  /** GitHub logins that should be treated as bots even when GitHub reports User/Unknown. */
@@ -30,6 +34,7 @@ interface PrShepherdConfig {
30
34
  };
31
35
  watch: {
32
36
  readyDelayMinutes: number;
37
+ graphqlQuotaWarnings: GraphqlQuotaWarningBand[];
33
38
  };
34
39
  resolve: {
35
40
  shaPoll: {
@@ -115,6 +115,37 @@ function parseMergeCommandArgs(value) {
115
115
  }
116
116
  return strategies.length === 0 ? [...value, "--merge"] : [...value];
117
117
  }
118
+ function parseGraphqlQuotaWarnings(value) {
119
+ if (!Array.isArray(value)) {
120
+ throw new Error("Invalid config: watch.graphqlQuotaWarnings must be an array");
121
+ }
122
+ const seen = new Set();
123
+ const parsed = value.map((item, index) => {
124
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
125
+ throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}] must be an object`);
126
+ }
127
+ const record = item;
128
+ const remainingPercent = record["remainingPercent"];
129
+ const pollIntervalMinutes = record["pollIntervalMinutes"];
130
+ if (typeof remainingPercent !== "number" ||
131
+ !Number.isInteger(remainingPercent) ||
132
+ remainingPercent < 1 ||
133
+ remainingPercent > 100) {
134
+ throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}].remainingPercent must be an integer from 1 to 100`);
135
+ }
136
+ if (typeof pollIntervalMinutes !== "number" ||
137
+ !Number.isFinite(pollIntervalMinutes) ||
138
+ pollIntervalMinutes <= 0) {
139
+ throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}].pollIntervalMinutes must be a positive number`);
140
+ }
141
+ if (seen.has(remainingPercent)) {
142
+ throw new Error(`Invalid config: watch.graphqlQuotaWarnings has duplicate remainingPercent ${remainingPercent}`);
143
+ }
144
+ seen.add(remainingPercent);
145
+ return { remainingPercent, pollIntervalMinutes };
146
+ });
147
+ return parsed.sort((left, right) => right.remainingPercent - left.remainingPercent);
148
+ }
118
149
  const KNOWN_CONFIG_KEYS = new Set([
119
150
  "classify",
120
151
  "botUsernames",
@@ -135,7 +166,7 @@ const KNOWN_NESTED_KEYS = {
135
166
  "minimizeComments",
136
167
  "behindBaseHint",
137
168
  ]),
138
- watch: new Set(["readyDelayMinutes"]),
169
+ watch: new Set(["readyDelayMinutes", "graphqlQuotaWarnings"]),
139
170
  resolve: new Set(["shaPoll"]),
140
171
  checks: new Set(["ciTriggerEvents"]),
141
172
  mergeStatus: new Set(["blockingReviewerLogins"]),
@@ -228,6 +259,7 @@ export function loadConfig() {
228
259
  config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
229
260
  if (config.merge)
230
261
  config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
262
+ config.watch.graphqlQuotaWarnings = parseGraphqlQuotaWarnings(config.watch.graphqlQuotaWarnings);
231
263
  config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
232
264
  configCache.set(cwd, config);
233
265
  return config;
package/bin/config.json CHANGED
@@ -22,7 +22,12 @@
22
22
  "behindBaseHint": ""
23
23
  },
24
24
  "watch": {
25
- "readyDelayMinutes": 10
25
+ "readyDelayMinutes": 10,
26
+ "graphqlQuotaWarnings": [
27
+ { "remainingPercent": 30, "pollIntervalMinutes": 2 },
28
+ { "remainingPercent": 20, "pollIntervalMinutes": 5 },
29
+ { "remainingPercent": 10, "pollIntervalMinutes": 10 }
30
+ ]
26
31
  },
27
32
  "resolve": {
28
33
  "shaPoll": {
@@ -35,7 +35,7 @@ export declare const EXIT: Readonly<{
35
35
  readonly UNAVAILABLE: 69;
36
36
  /** Unexpected/unclassified internal error — the fallback. */
37
37
  readonly SOFTWARE: 70;
38
- /** Retryable GitHub failure: 429, 5xx, rate limit exhausted, `Retry-After` present. */
38
+ /** Retryable GitHub failure: 429, 5xx, rate limit exhausted, Retry-After, GraphQL INTERNAL. */
39
39
  readonly TEMPFAIL: 75;
40
40
  /** GitHub 401/403 — missing token or insufficient PAT scopes. */
41
41
  readonly NOPERM: 77;