pr-shepherd 0.44.1 → 0.46.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -8
- package/bin/checks/conclusions.d.mts +8 -1
- package/bin/checks/conclusions.mjs +12 -0
- package/bin/checks/triage.d.mts +3 -2
- package/bin/checks/triage.mjs +44 -15
- package/bin/cli/api-usage-formatter.d.mts +3 -0
- package/bin/cli/api-usage-formatter.mjs +36 -0
- package/bin/cli/error-format.d.mts +13 -0
- package/bin/cli/error-format.mjs +41 -0
- package/bin/cli/fix-formatter.mjs +22 -5
- package/bin/cli/help-command-pages.d.mts +13 -0
- package/bin/cli/help-command-pages.mjs +13 -0
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +2 -0
- package/bin/cli/help.d.mts +14 -1
- package/bin/cli/help.mjs +2 -0
- package/bin/cli/iterate-activity-formatter.d.mts +2 -0
- package/bin/cli/iterate-activity-formatter.mjs +26 -0
- package/bin/cli/iterate-checks-formatter.d.mts +2 -0
- package/bin/cli/iterate-checks-formatter.mjs +55 -0
- package/bin/cli/iterate-formatter.mjs +17 -29
- package/bin/cli/iterate-instructions.mjs +14 -1
- package/bin/cli/iterate-lean.mjs +5 -0
- package/bin/cli/journal-extract-handler.d.mts +2 -0
- package/bin/cli/journal-extract-handler.mjs +48 -0
- package/bin/cli/mutate-formatter.mjs +2 -0
- package/bin/cli/safe-body-file.d.mts +14 -0
- package/bin/cli/safe-body-file.mjs +42 -0
- package/bin/cli-parser.mjs +7 -0
- package/bin/commands/check-annotations.d.mts +2 -1
- package/bin/commands/check-annotations.mjs +7 -7
- package/bin/commands/check-status.mjs +4 -4
- package/bin/commands/check.mjs +31 -9
- package/bin/commands/iterate/api-usage.d.mts +2 -0
- package/bin/commands/iterate/api-usage.mjs +35 -0
- package/bin/commands/iterate/check-instructions.d.mts +1 -1
- package/bin/commands/iterate/check-instructions.mjs +13 -18
- package/bin/commands/iterate/classify.mjs +2 -1
- package/bin/commands/iterate/escalate.mjs +63 -9
- package/bin/commands/iterate/fix-code.mjs +110 -73
- package/bin/commands/iterate/index.mjs +8 -5
- package/bin/commands/iterate/merge-state.mjs +5 -2
- package/bin/commands/iterate/render.mjs +13 -6
- package/bin/commands/iterate/run.d.mts +2 -0
- package/bin/commands/iterate/run.mjs +8 -0
- package/bin/commands/mark-files-as-viewed.mjs +8 -0
- package/bin/commands/poll-run.d.mts +2 -0
- package/bin/commands/poll-run.mjs +8 -0
- package/bin/commands/poll.d.mts +1 -2
- package/bin/commands/poll.mjs +26 -10
- package/bin/comments/rate-limit.d.mts +4 -0
- package/bin/comments/rate-limit.mjs +6 -0
- package/bin/comments/review-visibility.d.mts +1 -1
- package/bin/comments/review-visibility.mjs +3 -2
- package/bin/comments/thread-visibility.d.mts +1 -1
- package/bin/comments/thread-visibility.mjs +9 -3
- package/bin/config/load.d.mts +6 -1
- package/bin/config/load.mjs +33 -1
- package/bin/config.json +6 -1
- package/bin/github/api-telemetry-aggregate.d.mts +32 -0
- package/bin/github/api-telemetry-aggregate.mjs +84 -0
- package/bin/github/api-telemetry.d.mts +13 -0
- package/bin/github/api-telemetry.mjs +128 -0
- package/bin/github/check-annotations.d.mts +14 -1
- package/bin/github/check-annotations.mjs +29 -2
- package/bin/github/client.d.mts +1 -1
- package/bin/github/client.mjs +1 -1
- package/bin/github/errors.d.mts +2 -0
- package/bin/github/errors.mjs +2 -0
- package/bin/github/gql/batch-pr-page.gql +8 -0
- package/bin/github/gql/batch-pr.gql +8 -0
- package/bin/github/gql/check-run-annotations.gql +8 -0
- package/bin/github/gql/commit-check-contexts.gql +8 -0
- package/bin/github/gql/get-pr-body.gql +8 -0
- package/bin/github/gql/get-pr-head-sha.gql +8 -0
- package/bin/github/gql/pr-number-by-branch.gql +8 -0
- package/bin/github/gql/review-thread-comments.gql +8 -0
- package/bin/github/gql/suggestion-threads.gql +8 -0
- package/bin/github/graphql-http.mjs +63 -8
- package/bin/github/http-auth.d.mts +9 -1
- package/bin/github/http-auth.mjs +35 -14
- package/bin/github/http-intermediate.d.mts +9 -0
- package/bin/github/http-intermediate.mjs +20 -0
- package/bin/github/http-request.d.mts +1 -1
- package/bin/github/http-request.mjs +1 -1
- package/bin/github/http-utils.d.mts +6 -0
- package/bin/github/http-utils.mjs +10 -1
- package/bin/github/rest-http.d.mts +16 -1
- package/bin/github/rest-http.mjs +71 -8
- package/bin/github/rest-text.mjs +41 -5
- package/bin/index.mjs +2 -1
- package/bin/log/session.d.mts +11 -0
- package/bin/log/session.mjs +22 -0
- package/bin/mcp/server.mjs +16 -2
- package/bin/pr-reference.d.mts +1 -1
- package/bin/pr-reference.mjs +1 -1
- package/bin/quota-warning.d.mts +2 -0
- package/bin/quota-warning.mjs +6 -0
- package/bin/state/graphql-quota-claims.d.mts +2 -0
- package/bin/state/graphql-quota-claims.mjs +61 -0
- package/bin/state/graphql-quota-policy.d.mts +17 -0
- package/bin/state/graphql-quota-policy.mjs +43 -0
- package/bin/state/graphql-quota-warnings.d.mts +6 -0
- package/bin/state/graphql-quota-warnings.mjs +105 -0
- package/bin/state/rest-cache.d.mts +48 -0
- package/bin/state/rest-cache.mjs +91 -0
- package/bin/types/api-usage.d.mts +31 -0
- package/bin/types/api-usage.mjs +1 -0
- package/bin/types/escalate.d.mts +5 -3
- package/bin/types/iterate.d.mts +4 -1
- package/bin/types/report.d.mts +3 -1
- package/bin/types.d.mts +1 -0
- package/bin/types.mjs +1 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +24 -13
|
@@ -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) {
|
|
@@ -24,5 +24,5 @@ export declare function classifyReviewsForDisplay(reviews: Review[], seenMap: Ma
|
|
|
24
24
|
*
|
|
25
25
|
* Human-authored CR reviews continue to flow through the standard seen-gate.
|
|
26
26
|
*/
|
|
27
|
-
export declare function classifyChangesRequestedReviewsForDisplay(reviews: Review[], seenMap: Map<string, SeenMarker>, botUsernames: NormalizedBotUsernames): ReviewVisibility;
|
|
27
|
+
export declare function classifyChangesRequestedReviewsForDisplay(reviews: Review[], seenMap: Map<string, SeenMarker>, botUsernames: NormalizedBotUsernames, repeatBotReviews?: boolean): ReviewVisibility;
|
|
28
28
|
export {};
|
|
@@ -31,7 +31,7 @@ export function classifyReviewsForDisplay(reviews, seenMap) {
|
|
|
31
31
|
*
|
|
32
32
|
* Human-authored CR reviews continue to flow through the standard seen-gate.
|
|
33
33
|
*/
|
|
34
|
-
export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botUsernames) {
|
|
34
|
+
export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botUsernames, repeatBotReviews = true) {
|
|
35
35
|
const visible = [];
|
|
36
36
|
const toMarkSeen = [];
|
|
37
37
|
for (const review of reviews) {
|
|
@@ -39,7 +39,8 @@ export function classifyChangesRequestedReviewsForDisplay(reviews, seenMap, botU
|
|
|
39
39
|
const cls = classifyItem(review.id, review.body, seenMap);
|
|
40
40
|
if (isBot) {
|
|
41
41
|
if (cls === "unchanged") {
|
|
42
|
-
|
|
42
|
+
if (repeatBotReviews)
|
|
43
|
+
visible.push({ ...review, staleBotCr: true });
|
|
43
44
|
}
|
|
44
45
|
else if (cls === "edited") {
|
|
45
46
|
visible.push({ ...review, edited: true });
|
|
@@ -7,5 +7,5 @@ interface ThreadVisibility {
|
|
|
7
7
|
firstLookThreads: FirstLookThread[];
|
|
8
8
|
toMarkSeen: ReviewThread[];
|
|
9
9
|
}
|
|
10
|
-
export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames): ThreadVisibility;
|
|
10
|
+
export declare function classifyThreadVisibility(threads: ReviewThread[], seenMap: Map<string, SeenMarker>, botUsernames?: NormalizedBotUsernames, repeatableThreadIds?: ReadonlySet<string>): ThreadVisibility;
|
|
11
11
|
export {};
|
|
@@ -19,14 +19,15 @@ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
|
|
|
19
19
|
return null;
|
|
20
20
|
return { ...visible, firstLookStatus };
|
|
21
21
|
}
|
|
22
|
-
export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set()) {
|
|
22
|
+
export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set(), repeatableThreadIds) {
|
|
23
|
+
const shouldRepeat = (thread) => repeatableThreadIds?.has(thread.id) ?? true;
|
|
23
24
|
const unresolvedThreads = threads.filter((t) => !t.isResolved);
|
|
24
25
|
const activeThreads = unresolvedThreads
|
|
25
26
|
.filter((t) => !t.isOutdated && !t.isMinimized)
|
|
26
27
|
.flatMap((t) => {
|
|
27
28
|
if (threadEndedByShepherd(t))
|
|
28
29
|
return [];
|
|
29
|
-
if (isConfiguredBotAuthor(t, botUsernames))
|
|
30
|
+
if (isConfiguredBotAuthor(t, botUsernames) && shouldRepeat(t))
|
|
30
31
|
return [t];
|
|
31
32
|
const visible = classifyVisibleThread(t, seenMap);
|
|
32
33
|
return visible ? [visible] : [];
|
|
@@ -40,7 +41,12 @@ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Se
|
|
|
40
41
|
}
|
|
41
42
|
return t.isOutdated || t.isMinimized;
|
|
42
43
|
})
|
|
43
|
-
.
|
|
44
|
+
.flatMap((t) => {
|
|
45
|
+
const visible = classifyVisibleThread(t, seenMap);
|
|
46
|
+
if (visible)
|
|
47
|
+
return [visible];
|
|
48
|
+
return shouldRepeat(t) ? [t] : [];
|
|
49
|
+
});
|
|
44
50
|
const firstLookThreads = [
|
|
45
51
|
...threads.flatMap((t) => {
|
|
46
52
|
if (!t.isOutdated)
|
package/bin/config/load.d.mts
CHANGED
|
@@ -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
|
|
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: {
|
package/bin/config/load.mjs
CHANGED
|
@@ -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": {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
package/bin/github/client.d.mts
CHANGED
|
@@ -38,7 +38,7 @@ export declare function updatePullRequestBody(pullRequestId: string, body: strin
|
|
|
38
38
|
* Fetches PR state, `mergeable`, and `mergeStateStatus` via the REST API.
|
|
39
39
|
*
|
|
40
40
|
* Used when the GraphQL API returns `UNKNOWN` for mergeability or before a
|
|
41
|
-
* READY
|
|
41
|
+
* READY state. The same response carries state so a concurrent merge or
|
|
42
42
|
* close can supersede the earlier GraphQL snapshot without another request.
|
|
43
43
|
*/
|
|
44
44
|
export declare function getMergeableState(pr: number, owner: string, repo: string): Promise<{
|
package/bin/github/client.mjs
CHANGED
|
@@ -93,7 +93,7 @@ export async function updatePullRequestBody(pullRequestId, body) {
|
|
|
93
93
|
* Fetches PR state, `mergeable`, and `mergeStateStatus` via the REST API.
|
|
94
94
|
*
|
|
95
95
|
* Used when the GraphQL API returns `UNKNOWN` for mergeability or before a
|
|
96
|
-
* READY
|
|
96
|
+
* READY state. The same response carries state so a concurrent merge or
|
|
97
97
|
* close can supersede the earlier GraphQL snapshot without another request.
|
|
98
98
|
*/
|
|
99
99
|
export async function getMergeableState(pr, owner, repo) {
|
package/bin/github/errors.d.mts
CHANGED
|
@@ -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
|
package/bin/github/errors.mjs
CHANGED
|
@@ -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 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 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 {
|