pr-shepherd 0.44.1 → 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 (95) 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/github/api-telemetry-aggregate.d.mts +32 -0
  43. package/bin/github/api-telemetry-aggregate.mjs +84 -0
  44. package/bin/github/api-telemetry.d.mts +13 -0
  45. package/bin/github/api-telemetry.mjs +128 -0
  46. package/bin/github/check-annotations.d.mts +14 -1
  47. package/bin/github/check-annotations.mjs +29 -2
  48. package/bin/github/errors.d.mts +2 -0
  49. package/bin/github/errors.mjs +2 -0
  50. package/bin/github/gql/batch-pr-page.gql +8 -0
  51. package/bin/github/gql/batch-pr.gql +8 -0
  52. package/bin/github/gql/check-run-annotations.gql +8 -0
  53. package/bin/github/gql/commit-check-contexts.gql +8 -0
  54. package/bin/github/gql/get-pr-body.gql +8 -0
  55. package/bin/github/gql/get-pr-head-sha.gql +8 -0
  56. package/bin/github/gql/pr-number-by-branch.gql +8 -0
  57. package/bin/github/gql/review-thread-comments.gql +8 -0
  58. package/bin/github/gql/suggestion-threads.gql +8 -0
  59. package/bin/github/graphql-http.mjs +63 -8
  60. package/bin/github/http-auth.d.mts +9 -1
  61. package/bin/github/http-auth.mjs +35 -14
  62. package/bin/github/http-intermediate.d.mts +9 -0
  63. package/bin/github/http-intermediate.mjs +20 -0
  64. package/bin/github/http-request.d.mts +1 -1
  65. package/bin/github/http-request.mjs +1 -1
  66. package/bin/github/http-utils.d.mts +6 -0
  67. package/bin/github/http-utils.mjs +10 -1
  68. package/bin/github/rest-http.d.mts +16 -1
  69. package/bin/github/rest-http.mjs +71 -8
  70. package/bin/github/rest-text.mjs +41 -5
  71. package/bin/index.mjs +2 -1
  72. package/bin/log/session.d.mts +11 -0
  73. package/bin/log/session.mjs +22 -0
  74. package/bin/mcp/server.mjs +16 -2
  75. package/bin/quota-warning.d.mts +2 -0
  76. package/bin/quota-warning.mjs +6 -0
  77. package/bin/state/graphql-quota-claims.d.mts +2 -0
  78. package/bin/state/graphql-quota-claims.mjs +61 -0
  79. package/bin/state/graphql-quota-policy.d.mts +17 -0
  80. package/bin/state/graphql-quota-policy.mjs +43 -0
  81. package/bin/state/graphql-quota-warnings.d.mts +6 -0
  82. package/bin/state/graphql-quota-warnings.mjs +105 -0
  83. package/bin/state/rest-cache.d.mts +48 -0
  84. package/bin/state/rest-cache.mjs +91 -0
  85. package/bin/types/api-usage.d.mts +31 -0
  86. package/bin/types/api-usage.mjs +1 -0
  87. package/bin/types/iterate.d.mts +4 -1
  88. package/bin/types/report.d.mts +2 -0
  89. package/bin/types.d.mts +1 -0
  90. package/bin/types.mjs +1 -0
  91. package/package.json +1 -1
  92. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  93. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  94. package/plugins/pr-shepherd/.mcp.json +1 -1
  95. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +14 -9
@@ -0,0 +1,32 @@
1
+ import type { ApiTelemetryEvent } from "./api-telemetry.mts";
2
+ import type { RateLimitInfo } from "./http-utils.mts";
3
+ export interface TelemetryAggregate {
4
+ eventCount: number;
5
+ credentialSources: Map<string, number>;
6
+ graphql: {
7
+ requestCount: number;
8
+ measuredQueryCost: number;
9
+ unmeasuredRequestCount: number;
10
+ nodeCount: number;
11
+ rateLimit?: RateLimitInfo;
12
+ };
13
+ rest: Map<string, {
14
+ requestCount: number;
15
+ rateLimit?: RateLimitInfo;
16
+ }>;
17
+ }
18
+ export interface SequencedApiTelemetryEvent extends ApiTelemetryEvent {
19
+ sequence: number;
20
+ }
21
+ export interface TelemetryStore {
22
+ events: SequencedApiTelemetryEvent[];
23
+ compacted: TelemetryAggregate;
24
+ eventCount: number;
25
+ clock: {
26
+ next: number;
27
+ };
28
+ }
29
+ export declare function emptyAggregate(): TelemetryAggregate;
30
+ export declare function aggregateEvents(events: SequencedApiTelemetryEvent[]): TelemetryAggregate;
31
+ export declare function mergeAggregate(target: TelemetryAggregate, source: TelemetryAggregate): void;
32
+ export declare function aggregateStore(active: TelemetryStore): TelemetryAggregate;
@@ -0,0 +1,84 @@
1
+ export function emptyAggregate() {
2
+ return {
3
+ eventCount: 0,
4
+ credentialSources: new Map(),
5
+ graphql: { requestCount: 0, measuredQueryCost: 0, unmeasuredRequestCount: 0, nodeCount: 0 },
6
+ rest: new Map(),
7
+ };
8
+ }
9
+ export function aggregateEvents(events) {
10
+ const aggregate = emptyAggregate();
11
+ for (const event of events) {
12
+ aggregate.eventCount += 1;
13
+ if (!aggregate.credentialSources.has(event.authSource)) {
14
+ aggregate.credentialSources.set(event.authSource, event.sequence);
15
+ }
16
+ if (event.kind === "GraphQL") {
17
+ aggregate.graphql.requestCount += 1;
18
+ if (event.rateLimit?.cost === undefined)
19
+ aggregate.graphql.unmeasuredRequestCount += 1;
20
+ else
21
+ aggregate.graphql.measuredQueryCost += event.rateLimit.cost;
22
+ aggregate.graphql.nodeCount += event.rateLimit?.nodeCount ?? 0;
23
+ if (event.rateLimit !== undefined) {
24
+ aggregate.graphql.rateLimit = selectAuthoritativeRateLimit(aggregate.graphql.rateLimit, event.rateLimit);
25
+ }
26
+ continue;
27
+ }
28
+ const resource = event.rateLimit?.resource ?? "unknown";
29
+ const group = aggregate.rest.get(resource) ?? { requestCount: 0 };
30
+ group.requestCount += 1;
31
+ if (event.rateLimit !== undefined) {
32
+ group.rateLimit = selectAuthoritativeRateLimit(group.rateLimit, event.rateLimit);
33
+ }
34
+ aggregate.rest.set(resource, group);
35
+ }
36
+ return aggregate;
37
+ }
38
+ export function mergeAggregate(target, source) {
39
+ target.eventCount += source.eventCount;
40
+ for (const [credentialSource, sequence] of source.credentialSources) {
41
+ const current = target.credentialSources.get(credentialSource);
42
+ if (current === undefined || sequence < current) {
43
+ target.credentialSources.set(credentialSource, sequence);
44
+ }
45
+ }
46
+ target.graphql.requestCount += source.graphql.requestCount;
47
+ target.graphql.measuredQueryCost += source.graphql.measuredQueryCost;
48
+ target.graphql.unmeasuredRequestCount += source.graphql.unmeasuredRequestCount;
49
+ target.graphql.nodeCount += source.graphql.nodeCount;
50
+ if (source.graphql.rateLimit !== undefined) {
51
+ target.graphql.rateLimit = selectAuthoritativeRateLimit(target.graphql.rateLimit, source.graphql.rateLimit);
52
+ }
53
+ for (const [resource, sourceGroup] of source.rest) {
54
+ const targetGroup = target.rest.get(resource) ?? { requestCount: 0 };
55
+ targetGroup.requestCount += sourceGroup.requestCount;
56
+ if (sourceGroup.rateLimit !== undefined) {
57
+ targetGroup.rateLimit = selectAuthoritativeRateLimit(targetGroup.rateLimit, sourceGroup.rateLimit);
58
+ }
59
+ target.rest.set(resource, targetGroup);
60
+ }
61
+ }
62
+ export function aggregateStore(active) {
63
+ const all = emptyAggregate();
64
+ mergeAggregate(all, active.compacted);
65
+ mergeAggregate(all, aggregateEvents(active.events));
66
+ return all;
67
+ }
68
+ function selectAuthoritativeRateLimit(current, candidate) {
69
+ if (current === undefined)
70
+ return { ...candidate };
71
+ if (candidate.resetAt !== current.resetAt) {
72
+ return candidate.resetAt > current.resetAt ? { ...candidate } : current;
73
+ }
74
+ if (candidate.remaining !== current.remaining) {
75
+ return candidate.remaining < current.remaining ? { ...candidate } : current;
76
+ }
77
+ if (candidate.used !== current.used) {
78
+ if (candidate.used === undefined)
79
+ return current;
80
+ if (current.used === undefined || candidate.used > current.used)
81
+ return { ...candidate };
82
+ }
83
+ return current;
84
+ }
@@ -0,0 +1,13 @@
1
+ import type { ApiUsage } from "../types.mts";
2
+ import type { RateLimitInfo } from "./http-utils.mts";
3
+ export interface ApiTelemetryEvent {
4
+ kind: "GraphQL" | "REST";
5
+ method: string;
6
+ authSource: string;
7
+ rateLimit?: RateLimitInfo;
8
+ }
9
+ /** Isolates a top-level CLI/MCP command while allowing nested iterate ticks to aggregate. */
10
+ export declare function withApiTelemetryScope<T>(fn: () => Promise<T>): Promise<T>;
11
+ export declare function recordApiTelemetry(event: ApiTelemetryEvent): void;
12
+ export declare function mergeGraphqlRateLimit(headerRateLimit: RateLimitInfo | null, data: unknown): RateLimitInfo | null;
13
+ export declare function summarizeApiTelemetry(): ApiUsage | undefined;
@@ -0,0 +1,128 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { aggregateEvents, aggregateStore, emptyAggregate, mergeAggregate, } from "./api-telemetry-aggregate.mjs";
3
+ const GRAPHQL_RATE_LIMIT_ALIAS = "_shepherdRateLimit";
4
+ const eventStorage = new AsyncLocalStorage();
5
+ function store() {
6
+ return eventStorage.getStore();
7
+ }
8
+ /** Isolates a top-level CLI/MCP command while allowing nested iterate ticks to aggregate. */
9
+ export function withApiTelemetryScope(fn) {
10
+ const parent = store();
11
+ const child = {
12
+ events: [],
13
+ compacted: emptyAggregate(),
14
+ eventCount: 0,
15
+ clock: parent?.clock ?? { next: 0 },
16
+ };
17
+ return eventStorage.run(child, async () => {
18
+ try {
19
+ return await fn();
20
+ }
21
+ finally {
22
+ mergeAggregate(child.compacted, aggregateEvents(child.events));
23
+ child.events = [];
24
+ if (parent !== undefined) {
25
+ mergeAggregate(parent.compacted, aggregateEvents(parent.events));
26
+ parent.events = [];
27
+ mergeAggregate(parent.compacted, child.compacted);
28
+ parent.eventCount += child.eventCount;
29
+ }
30
+ }
31
+ });
32
+ }
33
+ export function recordApiTelemetry(event) {
34
+ const active = store();
35
+ if (active === undefined)
36
+ return;
37
+ active.events.push({
38
+ ...event,
39
+ rateLimit: event.rateLimit ? { ...event.rateLimit } : undefined,
40
+ sequence: active.clock.next++,
41
+ });
42
+ active.eventCount += 1;
43
+ }
44
+ export function mergeGraphqlRateLimit(headerRateLimit, data) {
45
+ const payload = extractGraphqlRateLimit(data);
46
+ if (headerRateLimit === null && payload === null)
47
+ return null;
48
+ const resetAt = payload === null ? undefined : Date.parse(payload.resetAt) / 1000;
49
+ const fallback = payload !== null && Number.isFinite(resetAt)
50
+ ? {
51
+ limit: payload.limit,
52
+ used: payload.used,
53
+ remaining: payload.remaining,
54
+ resetAt: resetAt,
55
+ resource: "graphql",
56
+ }
57
+ : null;
58
+ const base = headerRateLimit ?? fallback;
59
+ if (base === null)
60
+ return null;
61
+ return {
62
+ ...base,
63
+ resource: base.resource ?? "graphql",
64
+ ...(payload !== null && Number.isFinite(payload.cost) && { cost: payload.cost }),
65
+ ...(payload !== null && Number.isFinite(payload.nodeCount) && { nodeCount: payload.nodeCount }),
66
+ };
67
+ }
68
+ export function summarizeApiTelemetry() {
69
+ const active = store();
70
+ if (active === undefined)
71
+ return undefined;
72
+ const selected = aggregateStore(active);
73
+ if (selected.eventCount === 0)
74
+ return undefined;
75
+ const credentialSources = [...selected.credentialSources.entries()]
76
+ .sort((left, right) => left[1] - right[1])
77
+ .map(([source]) => source);
78
+ const graphql = summarizeGraphql(selected.graphql);
79
+ const rest = summarizeRest(selected.rest);
80
+ return {
81
+ credentialSources,
82
+ ...(graphql !== undefined && { graphql }),
83
+ ...(rest.length > 0 && { rest }),
84
+ };
85
+ }
86
+ function summarizeGraphql(selected) {
87
+ const rateLimit = selected.rateLimit;
88
+ if (rateLimit === undefined)
89
+ return undefined;
90
+ return {
91
+ ...resourceUsage(rateLimit, selected.requestCount, "graphql"),
92
+ measuredQueryCost: selected.measuredQueryCost,
93
+ unmeasuredRequestCount: selected.unmeasuredRequestCount,
94
+ nodeCount: selected.nodeCount,
95
+ };
96
+ }
97
+ function summarizeRest(selected) {
98
+ return [...selected.entries()].flatMap(([resource, group]) => {
99
+ return group.rateLimit ? [resourceUsage(group.rateLimit, group.requestCount, resource)] : [];
100
+ });
101
+ }
102
+ function resourceUsage(rateLimit, requestCount, fallbackResource) {
103
+ return {
104
+ resource: rateLimit.resource ?? fallbackResource,
105
+ requestCount,
106
+ limit: rateLimit.limit,
107
+ ...(rateLimit.used !== undefined && { used: rateLimit.used }),
108
+ remaining: rateLimit.remaining,
109
+ resetAt: rateLimit.resetAt,
110
+ };
111
+ }
112
+ function extractGraphqlRateLimit(data) {
113
+ if (data === null || typeof data !== "object" || Array.isArray(data))
114
+ return null;
115
+ const value = data[GRAPHQL_RATE_LIMIT_ALIAS];
116
+ if (value === null || typeof value !== "object" || Array.isArray(value))
117
+ return null;
118
+ const record = value;
119
+ if (!Number.isFinite(record["cost"]) ||
120
+ !Number.isFinite(record["limit"]) ||
121
+ !Number.isFinite(record["nodeCount"]) ||
122
+ !Number.isFinite(record["remaining"]) ||
123
+ typeof record["resetAt"] !== "string" ||
124
+ !Number.isFinite(record["used"])) {
125
+ return null;
126
+ }
127
+ return record;
128
+ }
@@ -1,2 +1,15 @@
1
+ import { type StateKey } from "../state/rest-cache.mts";
1
2
  import type { CheckAnnotation } from "../types.mts";
2
- export declare function fetchCheckRunAnnotations(checkRunId: string): Promise<CheckAnnotation[]>;
3
+ export interface AnnotationCacheOptions {
4
+ stateKey: StateKey;
5
+ headSha?: string;
6
+ }
7
+ /**
8
+ * Fetches all inline annotations for a check run.
9
+ *
10
+ * When `cacheOpts` is provided, the result is cached by `checkRunId` — safe
11
+ * because callers only pass `cacheOpts` for COMPLETED check runs (a re-run
12
+ * mints a new check-run node id, so a COMPLETED run's annotations are
13
+ * immutable once fetched).
14
+ */
15
+ export declare function fetchCheckRunAnnotations(checkRunId: string, cacheOpts?: AnnotationCacheOptions): Promise<CheckAnnotation[]>;
@@ -1,11 +1,34 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { graphql } from "./client.mjs";
3
3
  import { CHECK_RUN_ANNOTATIONS_QUERY } from "./queries.mjs";
4
+ import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
4
5
  const ANNOTATIONS_PER_PAGE = 100;
5
6
  const MAX_ANNOTATION_PAGES = 10;
6
7
  const ANNOTATION_TEXT_MAX_CHARS = 4_000;
7
8
  const TRUNCATED_SUFFIX = "\n[truncated]";
8
- export async function fetchCheckRunAnnotations(checkRunId) {
9
+ /**
10
+ * Some Checks-API publishers (e.g. SonarCloud) PATCH additional annotations
11
+ * onto an already-COMPLETED check run without minting a new check-run node
12
+ * ID, so "COMPLETED" is not a reliable immutability signal on its own. Bound
13
+ * the cache instead of trusting it forever, so a long-running poll session
14
+ * eventually revalidates.
15
+ */
16
+ const ANNOTATION_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
17
+ /**
18
+ * Fetches all inline annotations for a check run.
19
+ *
20
+ * When `cacheOpts` is provided, the result is cached by `checkRunId` — safe
21
+ * because callers only pass `cacheOpts` for COMPLETED check runs (a re-run
22
+ * mints a new check-run node id, so a COMPLETED run's annotations are
23
+ * immutable once fetched).
24
+ */
25
+ export async function fetchCheckRunAnnotations(checkRunId, cacheOpts) {
26
+ const cacheName = `annotations-${checkRunId}`;
27
+ if (cacheOpts) {
28
+ const cached = await loadDerived(cacheOpts.stateKey, cacheName);
29
+ if (cached && Date.now() - cached.storedAt < ANNOTATION_CACHE_MAX_AGE_MS)
30
+ return cached.value;
31
+ }
9
32
  let cursor = null;
10
33
  const nodes = [];
11
34
  for (let page = 1; page <= MAX_ANNOTATION_PAGES; page++) {
@@ -20,7 +43,11 @@ export async function fetchCheckRunAnnotations(checkRunId) {
20
43
  }
21
44
  cursor = result.pageInfo.endCursor;
22
45
  }
23
- return nodes.map((node) => toCheckAnnotation(checkRunId, node));
46
+ const annotations = nodes.map((node) => toCheckAnnotation(checkRunId, node));
47
+ if (cacheOpts) {
48
+ await storeDerived(cacheOpts.stateKey, cacheName, annotations, cacheOpts.headSha);
49
+ }
50
+ return annotations;
24
51
  }
25
52
  async function fetchAnnotationPage(checkRunId, cursor) {
26
53
  const res = await graphql(CHECK_RUN_ANNOTATIONS_QUERY, {
@@ -15,11 +15,13 @@ export declare class GitHubRequestError extends ShepherdError {
15
15
  readonly rateLimit?: RateLimitInfo;
16
16
  readonly retryAfterSeconds?: number;
17
17
  readonly graphqlErrors?: GitHubGraphQlError[];
18
+ readonly authSource?: string;
18
19
  constructor(message: string, opts: {
19
20
  status: number;
20
21
  rateLimit?: RateLimitInfo;
21
22
  retryAfterSeconds?: number;
22
23
  graphqlErrors?: GitHubGraphQlError[];
24
+ authSource?: string;
23
25
  /**
24
26
  * Bypasses status-based classification entirely — for callers that already
25
27
  * know the failure kind better than the HTTP status can express (e.g. a
@@ -52,6 +52,7 @@ export class GitHubRequestError extends ShepherdError {
52
52
  rateLimit;
53
53
  retryAfterSeconds;
54
54
  graphqlErrors;
55
+ authSource;
55
56
  constructor(message, opts) {
56
57
  super(message, opts.exitCodeOverride ??
57
58
  classifyStatus(opts.status, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
@@ -60,5 +61,6 @@ export class GitHubRequestError extends ShepherdError {
60
61
  this.rateLimit = opts.rateLimit;
61
62
  this.retryAfterSeconds = opts.retryAfterSeconds;
62
63
  this.graphqlErrors = opts.graphqlErrors;
64
+ this.authSource = opts.authSource;
63
65
  }
64
66
  }
@@ -18,6 +18,14 @@ query BatchPrPage(
18
18
  $includeChecks: Boolean!
19
19
  $checksCursor: String
20
20
  ) {
21
+ _shepherdRateLimit: rateLimit {
22
+ cost
23
+ limit
24
+ nodeCount
25
+ remaining
26
+ resetAt
27
+ used
28
+ }
21
29
  repository(owner: $owner, name: $repo) {
22
30
  pullRequest(number: $pr) {
23
31
  reviewThreads(last: 100, before: $threadsCursor) @include(if: $includeThreads) {
@@ -1,6 +1,14 @@
1
1
  # First page only. Extra pages use batch-pr-page.gql so follow-ups do not
2
2
  # re-select every connection (GraphQL points scale with possible nodes).
3
3
  query BatchPr($owner: String!, $repo: String!, $pr: Int!) {
4
+ _shepherdRateLimit: rateLimit {
5
+ cost
6
+ limit
7
+ nodeCount
8
+ remaining
9
+ resetAt
10
+ used
11
+ }
4
12
  repository(owner: $owner, name: $repo) {
5
13
  viewerPermission
6
14
  viewerCanAdminister
@@ -1,4 +1,12 @@
1
1
  query CheckRunAnnotations($id: ID!, $cursor: String) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
2
10
  node(id: $id) {
3
11
  __typename
4
12
  ... on CheckRun {
@@ -1,4 +1,12 @@
1
1
  query CommitCheckContexts($owner: String!, $repo: String!, $oid: String!, $cursor: String) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
2
10
  repository(owner: $owner, name: $repo) {
3
11
  object(expression: $oid) {
4
12
  __typename
@@ -1,4 +1,12 @@
1
1
  query GetPrBody($owner: String!, $repo: String!, $pr: Int!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
2
10
  repository(owner: $owner, name: $repo) {
3
11
  pullRequest(number: $pr) {
4
12
  id
@@ -1,4 +1,12 @@
1
1
  query GetPrHeadSha($owner: String!, $repo: String!, $pr: Int!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
2
10
  repository(owner: $owner, name: $repo) {
3
11
  pullRequest(number: $pr) {
4
12
  headRefOid
@@ -1,4 +1,12 @@
1
1
  query PrNumberByBranch($owner: String!, $repo: String!, $branch: String!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
2
10
  repository(owner: $owner, name: $repo) {
3
11
  pullRequests(headRefName: $branch, states: OPEN, first: 1) {
4
12
  nodes {
@@ -1,4 +1,12 @@
1
1
  query ReviewThreadComments($threadId: ID!, $commentsCursor: String) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
2
10
  node(id: $threadId) {
3
11
  __typename
4
12
  ... on PullRequestReviewThread {
@@ -1,4 +1,12 @@
1
1
  query SuggestionThreads($owner: String!, $repo: String!, $pr: Int!, $threadIds: [ID!]!) {
2
+ _shepherdRateLimit: rateLimit {
3
+ cost
4
+ limit
5
+ nodeCount
6
+ remaining
7
+ resetAt
8
+ used
9
+ }
2
10
  repository(owner: $owner, name: $repo) {
3
11
  pullRequest(number: $pr) {
4
12
  headRefOid
@@ -1,11 +1,14 @@
1
+ /* eslint-disable max-lines */
1
2
  import { appendEntry, nextEntry } from "../log/log-file.mjs";
2
3
  import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
4
  import { GitHubRequestError } from "./errors.mjs";
4
5
  import { withGraphQlInternalRetry } from "./graphql-internal-retry.mjs";
5
6
  import { formatGraphQlErrors, parseGraphQlPayload } from "./graphql-response.mjs";
6
- import { makeHeaders } from "./http-auth.mjs";
7
+ import { makeAuthHeaders } from "./http-auth.mjs";
7
8
  import { requestWithTokenRetry } from "./http-request.mjs";
8
9
  import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
10
+ import { mergeGraphqlRateLimit, recordApiTelemetry } from "./api-telemetry.mjs";
11
+ import { recordIntermediateResponse } from "./http-intermediate.mjs";
9
12
  const BASE_URL = "https://api.github.com";
10
13
  async function graphqlInner(query, vars, opts) {
11
14
  const url = `${BASE_URL}/graphql`;
@@ -18,13 +21,26 @@ async function graphqlInner(query, vars, opts) {
18
21
  body: { query, variables: vars },
19
22
  }));
20
23
  const t0 = performance.now();
21
- const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
24
+ let authSource = "unknown";
25
+ const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => {
26
+ const auth = await makeAuthHeaders();
27
+ authSource = auth.source;
28
+ return fetch(url, {
29
+ method: "POST",
30
+ headers: auth.headers,
31
+ body: JSON.stringify({ query, variables: vars }),
32
+ });
33
+ }, t0, (response, durationMs) => recordIntermediateResponse({
34
+ n,
35
+ kind: "GraphQL",
22
36
  method: "POST",
23
- headers: await makeHeaders(),
24
- body: JSON.stringify({ query, variables: vars }),
25
- }), t0, (status, durationMs) => appendEntry(formatResponseEntry({ n, kind: "GraphQL", method: "POST", url, status, durationMs })));
37
+ url,
38
+ response,
39
+ durationMs,
40
+ authSource,
41
+ }));
26
42
  const durationMs = Math.round(performance.now() - retryT0);
27
- const rateLimit = parseRateLimit(res.headers);
43
+ const headerRateLimit = parseRateLimit(res.headers);
28
44
  const retryAfterSeconds = parseRetryAfter(res.headers);
29
45
  if (!res.ok) {
30
46
  const body = await res.text();
@@ -37,8 +53,22 @@ async function graphqlInner(query, vars, opts) {
37
53
  durationMs,
38
54
  textBody: redactToken(body),
39
55
  attempt: attempt > 1 ? attempt : undefined,
56
+ authSource,
57
+ rateLimit: headerRateLimit ?? undefined,
58
+ retryAfterSeconds,
40
59
  }));
41
- throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
60
+ recordApiTelemetry({
61
+ kind: "GraphQL",
62
+ method: "POST",
63
+ authSource,
64
+ rateLimit: headerRateLimit ?? undefined,
65
+ });
66
+ throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, {
67
+ status: res.status,
68
+ rateLimit: headerRateLimit ?? undefined,
69
+ retryAfterSeconds,
70
+ authSource,
71
+ });
42
72
  }
43
73
  let parsed;
44
74
  try {
@@ -55,13 +85,27 @@ async function graphqlInner(query, vars, opts) {
55
85
  durationMs,
56
86
  textBody: `Invalid JSON response${detail}`,
57
87
  attempt: attempt > 1 ? attempt : undefined,
88
+ authSource,
89
+ rateLimit: headerRateLimit ?? undefined,
90
+ retryAfterSeconds,
58
91
  }));
92
+ recordApiTelemetry({
93
+ kind: "GraphQL",
94
+ method: "POST",
95
+ authSource,
96
+ rateLimit: headerRateLimit ?? undefined,
97
+ });
59
98
  throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
60
99
  status: res.status,
61
- rateLimit: rateLimit ?? undefined,
100
+ rateLimit: headerRateLimit ?? undefined,
62
101
  retryAfterSeconds,
102
+ authSource,
63
103
  });
64
104
  }
105
+ const parsedData = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
106
+ ? parsed["data"]
107
+ : undefined;
108
+ const rateLimit = mergeGraphqlRateLimit(headerRateLimit, parsedData);
65
109
  appendEntry(formatResponseEntry({
66
110
  n,
67
111
  kind: "GraphQL",
@@ -71,7 +115,16 @@ async function graphqlInner(query, vars, opts) {
71
115
  durationMs,
72
116
  body: parsed,
73
117
  attempt: attempt > 1 ? attempt : undefined,
118
+ authSource,
119
+ rateLimit: rateLimit ?? undefined,
120
+ retryAfterSeconds,
74
121
  }));
122
+ recordApiTelemetry({
123
+ kind: "GraphQL",
124
+ method: "POST",
125
+ authSource,
126
+ rateLimit: rateLimit ?? undefined,
127
+ });
75
128
  const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
76
129
  if (payload.data == null) {
77
130
  const detail = formatGraphQlErrors(payload.errors);
@@ -80,6 +133,7 @@ async function graphqlInner(query, vars, opts) {
80
133
  rateLimit: rateLimit ?? undefined,
81
134
  retryAfterSeconds,
82
135
  graphqlErrors: payload.errors,
136
+ authSource,
83
137
  });
84
138
  }
85
139
  if (payload.errors?.length && !opts.allowPartialData) {
@@ -88,6 +142,7 @@ async function graphqlInner(query, vars, opts) {
88
142
  rateLimit: rateLimit ?? undefined,
89
143
  retryAfterSeconds,
90
144
  graphqlErrors: payload.errors,
145
+ authSource,
91
146
  });
92
147
  }
93
148
  if (payload.errors?.length) {
@@ -1,4 +1,12 @@
1
+ export type AuthSource = "GH_TOKEN" | "GITHUB_TOKEN" | "gh auth token" | "GITHUB_PERSONAL_ACCESS_TOKEN";
1
2
  export declare function _resetTokenCache(): void;
2
3
  export declare function hasCachedToken(): boolean;
3
4
  export declare function clearTokenCache(): void;
4
- export declare function makeHeaders(): Promise<Record<string, string>>;
5
+ /**
6
+ * `extra` lets callers layer additional headers (e.g. `If-None-Match` for
7
+ * conditional REST requests) on top of the standard auth/version headers.
8
+ */
9
+ export declare function makeAuthHeaders(extra?: Record<string, string>): Promise<{
10
+ headers: Record<string, string>;
11
+ source: AuthSource;
12
+ }>;