pr-shepherd 0.55.2 → 0.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -2
- package/bin/api.d.mts +5 -0
- package/bin/api.mjs +12 -1
- package/bin/checks/job-log.d.mts +9 -0
- package/bin/checks/job-log.mjs +30 -0
- package/bin/checks/jobs-types.d.mts +14 -0
- package/bin/checks/jobs-types.mjs +7 -0
- package/bin/checks/related-jobs.d.mts +11 -0
- package/bin/checks/related-jobs.mjs +32 -0
- package/bin/checks/triage-budget.d.mts +17 -0
- package/bin/checks/triage-budget.mjs +49 -0
- package/bin/checks/triage.d.mts +6 -3
- package/bin/checks/triage.mjs +89 -61
- package/bin/cli/api-usage-formatter.mjs +2 -2
- package/bin/cli/fix-formatter.mjs +2 -0
- package/bin/cli/iterate-checks-formatter.mjs +2 -0
- package/bin/cli/related-jobs-format.d.mts +3 -0
- package/bin/cli/related-jobs-format.mjs +16 -0
- package/bin/commands/check-execution-context.d.mts +12 -0
- package/bin/commands/check-execution-context.mjs +38 -0
- package/bin/commands/check-fingerprint.mjs +14 -8
- package/bin/commands/check-unreported.d.mts +3 -2
- package/bin/commands/check-unreported.mjs +4 -4
- package/bin/commands/check.d.mts +2 -1
- package/bin/commands/check.mjs +21 -6
- package/bin/commands/iterate/check-instructions.d.mts +6 -3
- package/bin/commands/iterate/check-instructions.mjs +6 -5
- package/bin/commands/iterate/escalate.mjs +3 -0
- package/bin/commands/iterate/fix-code.mjs +6 -2
- package/bin/commands/iterate/helpers.mjs +1 -0
- package/bin/commands/iterate/index.mjs +20 -8
- package/bin/commands/iterate/render.mjs +11 -3
- package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
- package/bin/commands/iterate/stale-ancestry.mjs +3 -2
- package/bin/commands/poll-quota.d.mts +2 -0
- package/bin/commands/poll-quota.mjs +18 -25
- package/bin/commands/poll-rate-limit-wait.mjs +13 -8
- package/bin/commands/ready-delay.d.mts +2 -0
- package/bin/commands/ready-delay.mjs +18 -0
- package/bin/commands/resolve-mutate.mjs +8 -9
- package/bin/github/batch-raw-types.d.mts +2 -0
- package/bin/github/batch-receipt-evidence.d.mts +5 -0
- package/bin/github/batch-receipt-evidence.mjs +61 -0
- package/bin/github/batch.d.mts +4 -0
- package/bin/github/batch.mjs +42 -8
- package/bin/github/errors.d.mts +3 -0
- package/bin/github/errors.mjs +15 -6
- package/bin/github/gql/batch-pr-page.gql +1 -0
- package/bin/github/gql/batch-pr.gql +1 -0
- package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
- package/bin/github/gql/reply-thread-comments.gql +25 -0
- package/bin/github/gql/reply-thread-transcripts.gql +31 -0
- package/bin/github/merge-queue-checks.d.mts +2 -1
- package/bin/github/merge-queue-checks.mjs +18 -8
- package/bin/github/merge-target-rules.d.mts +2 -1
- package/bin/github/merge-target-rules.mjs +4 -4
- package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
- package/bin/github/poll-summary-annotation-probe.mjs +7 -0
- package/bin/github/poll-summary-mergeability.d.mts +11 -0
- package/bin/github/poll-summary-mergeability.mjs +23 -0
- package/bin/github/poll-summary.mjs +7 -2
- package/bin/github/queries.d.mts +5 -0
- package/bin/github/queries.mjs +5 -0
- package/bin/github/rate-limit-kind.d.mts +13 -0
- package/bin/github/rate-limit-kind.mjs +25 -0
- package/bin/github/reply-thread-transcripts.d.mts +3 -0
- package/bin/github/reply-thread-transcripts.mjs +89 -0
- package/bin/github/rest-http.mjs +1 -0
- package/bin/github/rest-text.d.mts +2 -1
- package/bin/github/rest-text.mjs +5 -2
- package/bin/github/thread-comments.d.mts +5 -1
- package/bin/github/thread-comments.mjs +30 -6
- package/bin/mcp/server.mjs +32 -5
- package/bin/quota-warning.mjs +2 -2
- package/bin/reporters/agent.mjs +1 -0
- package/bin/threads/transcript.d.mts +1 -0
- package/bin/threads/transcript.mjs +4 -1
- package/bin/types/check-classification.d.mts +10 -0
- package/bin/types/report.d.mts +4 -1
- package/package.json +1 -1
- 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 +3 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +2 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Full reference: [docs/README.md](docs/README.md). Feature matrix: [docs/features
|
|
|
27
27
|
|
|
28
28
|
`pr-shepherd` moves deterministic PR orchestration into a local MCP server, with a CLI for shells and CI. Both interfaces fetch the same GitHub state, emit raw-enough context, and return a numbered plan for the calling agent to follow.
|
|
29
29
|
|
|
30
|
-
The MCP server exposes
|
|
30
|
+
The MCP server exposes `iterate`, `apply`, `build_suggestion_patches`, `extract_journal`, and `get_journal`. `apply` accepts ordered review mutations, file-view mutations, and journal entries; the deprecated singular suggestion tool remains temporarily as an adapter. PR-targeted MCP calls require a repository-qualified `pr`: a GitHub PR URL or `owner/repo#N`; the explicit repository is the target for GitHub I/O, even when it differs from the local checkout. `extract_journal` takes a Markdown body string and performs no I/O. The CLI and programmatic API also retain bare-number and current-branch PR discovery. The shipped skills are thin dispatchers for those tools.
|
|
31
31
|
|
|
32
32
|
Each tick returns exactly one action:
|
|
33
33
|
|
|
@@ -122,7 +122,7 @@ Grok:
|
|
|
122
122
|
/pr-shepherd 42
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
MCP clients call `iterate` once per tick, then use `apply` for review/file/journal mutations and `build_suggestion_patches` for anchored suggestions.
|
|
125
|
+
MCP clients call `iterate` once per tick, then use `apply` for review/file/journal mutations and `build_suggestion_patches` for anchored suggestions. To read journal entries, call `extract_journal` with a body already in hand or `get_journal` with a repository-qualified PR reference. `iterate` returns the same structured action data as the CLI, including its review mutation arguments. The client owns recurrence, so this works consistently in Codex, Claude Code, Grok, and any other stdio MCP client.
|
|
126
126
|
|
|
127
127
|
The CLI remains useful for shell workflows. Its canonical polling form is:
|
|
128
128
|
|
|
@@ -189,6 +189,10 @@ complete Markdown list item with LF line endings. It fails closed for malformed
|
|
|
189
189
|
containers and ignores journal-shaped examples hidden in Markdown constructs. The full journal API,
|
|
190
190
|
including append and reconciliation helpers, is documented in [docs/api.md](docs/api.md).
|
|
191
191
|
|
|
192
|
+
MCP clients can call `extract_journal({ body: prBody })` for the same pure result without writing a
|
|
193
|
+
file, or `get_journal({ pr: "owner/repo#123" })` to fetch a PR body through GraphQL and extract it.
|
|
194
|
+
Both return the typed extraction JSON in `structuredContent` and `content`; neither mutates the PR.
|
|
195
|
+
|
|
192
196
|
For shell automation that already has a PR body, use the equivalent local-only command:
|
|
193
197
|
|
|
194
198
|
```sh
|
package/bin/api.d.mts
CHANGED
|
@@ -2,6 +2,7 @@ import { type JournalResult } from "./commands/journal/index.mts";
|
|
|
2
2
|
import { type MarkFilesAsViewedResult } from "./commands/mark-files-as-viewed.mts";
|
|
3
3
|
import type { ResolveResult } from "./comments/resolve.mts";
|
|
4
4
|
import type { BuildSuggestionPatchesResult, CommitSuggestionResult, IterateCommandOptions, IterateResult, PollSummaryResult } from "./types.mts";
|
|
5
|
+
import { type ShepherdJournalExtraction } from "./journal/index.mts";
|
|
5
6
|
export interface CreatePrShepherdOptions {
|
|
6
7
|
/** Working directory used for git, config, and classification-rule lookups. */
|
|
7
8
|
cwd?: string;
|
|
@@ -81,12 +82,16 @@ export interface BuildSuggestionPatchesInput {
|
|
|
81
82
|
pr?: PrReference;
|
|
82
83
|
suggestions: SuggestionPatchInput[];
|
|
83
84
|
}
|
|
85
|
+
export interface GetJournalInput {
|
|
86
|
+
pr: PrReference;
|
|
87
|
+
}
|
|
84
88
|
export interface PrShepherd {
|
|
85
89
|
iterate(input?: SingleIterateInput): Promise<IterateResult>;
|
|
86
90
|
iterate(input: AggregateIterateInput): Promise<PollSummaryResult>;
|
|
87
91
|
iterate(input: IterateInput): Promise<IterateResult | PollSummaryResult>;
|
|
88
92
|
apply(input: ApplyInput): Promise<ApplyResult>;
|
|
89
93
|
buildSuggestionPatches(input: BuildSuggestionPatchesInput): Promise<BuildSuggestionPatchesResult>;
|
|
94
|
+
getJournal(input: GetJournalInput): Promise<ShepherdJournalExtraction>;
|
|
90
95
|
/** Compatibility adapter; prefer buildSuggestionPatches. */
|
|
91
96
|
buildSuggestionPatch(input: BuildSuggestionPatchInput): Promise<CommitSuggestionResult>;
|
|
92
97
|
}
|
package/bin/api.mjs
CHANGED
|
@@ -10,7 +10,8 @@ import { runMarkFilesAsViewed, } from "./commands/mark-files-as-viewed.mjs";
|
|
|
10
10
|
import { runResolveMutate } from "./commands/resolve-mutate.mjs";
|
|
11
11
|
import { runWithExecutionCwd } from "./execution-context.mjs";
|
|
12
12
|
import { parsePrReference, normalizeRepositoryIdentity, resolveParsedPrTarget, } from "./pr-reference.mjs";
|
|
13
|
-
import { getRepoInfo } from "./github/client.mjs";
|
|
13
|
+
import { getPullRequestBody, getRepoInfo } from "./github/client.mjs";
|
|
14
|
+
import { extractShepherdJournal } from "./journal/index.mjs";
|
|
14
15
|
/** Raised before any API mutation when an input cannot be validated. */
|
|
15
16
|
export class PrShepherdValidationError extends Error {
|
|
16
17
|
constructor(message) {
|
|
@@ -50,6 +51,16 @@ export function createPrShepherd(options = {}) {
|
|
|
50
51
|
}
|
|
51
52
|
return Object.freeze({
|
|
52
53
|
iterate,
|
|
54
|
+
getJournal(input) {
|
|
55
|
+
return runWithExecutionCwd(cwd, async () => {
|
|
56
|
+
const { prNumber, targetRepository } = resolvePrReference(input.pr);
|
|
57
|
+
if (!prNumber)
|
|
58
|
+
throw new PrShepherdValidationError("getJournal requires a PR reference");
|
|
59
|
+
const { owner, name } = targetRepository ?? (await getRepoInfo());
|
|
60
|
+
const { body } = await getPullRequestBody(prNumber, owner, name);
|
|
61
|
+
return extractShepherdJournal(body);
|
|
62
|
+
});
|
|
63
|
+
},
|
|
53
64
|
apply(input) {
|
|
54
65
|
return runWithExecutionCwd(cwd, async () => {
|
|
55
66
|
validateApplyInput(input);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RepoInfo } from "../github/client.mts";
|
|
2
|
+
import { type StateKey } from "../state/rest-cache.mts";
|
|
3
|
+
import type { TriageBudget } from "./triage-budget.mts";
|
|
4
|
+
/**
|
|
5
|
+
* `cacheable` gates the cross-tick cache — only set once the matched job has
|
|
6
|
+
* a terminal conclusion, so an in-progress job's (possibly partial) log
|
|
7
|
+
* never gets frozen into the cache.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fetchJobLogExcerpt(jobId: number, repo: RepoInfo, stateKey?: StateKey, cacheable?: boolean, budget?: TriageBudget): Promise<string | undefined>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { restText } from "../github/http.mjs";
|
|
2
|
+
import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
|
|
3
|
+
import { buildLogExcerpt } from "./log-excerpt.mjs";
|
|
4
|
+
/**
|
|
5
|
+
* `cacheable` gates the cross-tick cache — only set once the matched job has
|
|
6
|
+
* a terminal conclusion, so an in-progress job's (possibly partial) log
|
|
7
|
+
* never gets frozen into the cache.
|
|
8
|
+
*/
|
|
9
|
+
export async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false, budget) {
|
|
10
|
+
const cacheName = `joblog-v2-${jobId}`;
|
|
11
|
+
if (stateKey && cacheable) {
|
|
12
|
+
const cached = await loadDerived(stateKey, cacheName);
|
|
13
|
+
if (cached)
|
|
14
|
+
return cached.value ?? undefined;
|
|
15
|
+
}
|
|
16
|
+
const { owner, name } = repo;
|
|
17
|
+
try {
|
|
18
|
+
if (!budget?.canScheduleOptional())
|
|
19
|
+
return undefined;
|
|
20
|
+
const excerpt = buildLogExcerpt(await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`, (rateLimit) => budget?.observe(rateLimit)));
|
|
21
|
+
if (stateKey && cacheable) {
|
|
22
|
+
await storeDerived(stateKey, cacheName, excerpt ?? null);
|
|
23
|
+
}
|
|
24
|
+
return excerpt;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
budget?.observeError(error);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ActionsJob {
|
|
2
|
+
id?: number;
|
|
3
|
+
name: string;
|
|
4
|
+
workflow_name?: string;
|
|
5
|
+
conclusion: string | null;
|
|
6
|
+
run_attempt?: number;
|
|
7
|
+
steps?: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
number: number;
|
|
10
|
+
conclusion: string | null;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
/** Name of the first step with a non-success, non-skipped, non-neutral conclusion. */
|
|
14
|
+
export declare function pickFailedStep(job: ActionsJob): string | undefined;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Name of the first step with a non-success, non-skipped, non-neutral conclusion. */
|
|
2
|
+
export function pickFailedStep(job) {
|
|
3
|
+
return job.steps?.find((s) => s.conclusion !== null &&
|
|
4
|
+
s.conclusion !== "success" &&
|
|
5
|
+
s.conclusion !== "skipped" &&
|
|
6
|
+
s.conclusion !== "neutral")?.name;
|
|
7
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RepoInfo } from "../github/client.mts";
|
|
2
|
+
import type { StateKey } from "../state/rest-cache.mts";
|
|
3
|
+
import type { RelatedFailedJob } from "../types/check-classification.mts";
|
|
4
|
+
import { type ActionsJob } from "./jobs-types.mts";
|
|
5
|
+
import type { TriageBudget } from "./triage-budget.mts";
|
|
6
|
+
/**
|
|
7
|
+
* Other failed jobs in the same workflow run, excluding the matched job and any
|
|
8
|
+
* job already surfaced as its own failing check (`surfacedNames`).
|
|
9
|
+
*/
|
|
10
|
+
export declare function pickRelatedFailedJobs(jobs: ActionsJob[], matchedJobId: number | undefined, surfacedNames: ReadonlySet<string>): ActionsJob[];
|
|
11
|
+
export declare function fetchRelatedJobs(jobs: ActionsJob[], repo: RepoInfo, stateKey?: StateKey, budget?: TriageBudget): Promise<RelatedFailedJob[]>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { fetchJobLogExcerpt } from "./job-log.mjs";
|
|
2
|
+
import { pickFailedStep } from "./jobs-types.mjs";
|
|
3
|
+
/** Max sibling failed jobs reported (and log-fetched) per workflow run. */
|
|
4
|
+
const MAX_RELATED_JOBS = 5;
|
|
5
|
+
const FAILED_JOB_CONCLUSIONS = new Set(["failure", "timed_out"]);
|
|
6
|
+
/**
|
|
7
|
+
* Other failed jobs in the same workflow run, excluding the matched job and any
|
|
8
|
+
* job already surfaced as its own failing check (`surfacedNames`).
|
|
9
|
+
*/
|
|
10
|
+
export function pickRelatedFailedJobs(jobs, matchedJobId, surfacedNames) {
|
|
11
|
+
return jobs
|
|
12
|
+
.filter((j) => j.conclusion !== null &&
|
|
13
|
+
FAILED_JOB_CONCLUSIONS.has(j.conclusion) &&
|
|
14
|
+
(j.id === undefined || j.id !== matchedJobId) &&
|
|
15
|
+
!surfacedNames.has(j.name))
|
|
16
|
+
.slice(0, MAX_RELATED_JOBS);
|
|
17
|
+
}
|
|
18
|
+
export async function fetchRelatedJobs(jobs, repo, stateKey, budget) {
|
|
19
|
+
const logs = await Promise.all(jobs.map((job) => job.id === undefined
|
|
20
|
+
? undefined
|
|
21
|
+
: fetchJobLogExcerpt(job.id, repo, stateKey, job.conclusion !== null, budget)));
|
|
22
|
+
return jobs.map((job, i) => {
|
|
23
|
+
const failedStep = pickFailedStep(job);
|
|
24
|
+
const logExcerpt = logs[i];
|
|
25
|
+
return {
|
|
26
|
+
name: job.name,
|
|
27
|
+
conclusion: (job.conclusion ?? "").toUpperCase(),
|
|
28
|
+
...(failedStep !== undefined && { failedStep }),
|
|
29
|
+
...(logExcerpt !== undefined && { logExcerpt }),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RateLimitInfo } from "../github/client.mts";
|
|
2
|
+
/** One optional Actions-enrichment budget for the entire PR snapshot. */
|
|
3
|
+
export declare class TriageBudget {
|
|
4
|
+
private exhausted;
|
|
5
|
+
private secondaryError;
|
|
6
|
+
private omitted;
|
|
7
|
+
private omissionReported;
|
|
8
|
+
get canSchedule(): boolean;
|
|
9
|
+
get primaryExhausted(): boolean;
|
|
10
|
+
/** Call only when a needed optional request is about to be scheduled. */
|
|
11
|
+
canScheduleOptional(): boolean;
|
|
12
|
+
observe(rateLimit?: RateLimitInfo): void;
|
|
13
|
+
observeError(error: unknown): void;
|
|
14
|
+
throwIfSecondary(): void;
|
|
15
|
+
reportOmissionIfNeeded(): void;
|
|
16
|
+
private stopForPrimaryLimit;
|
|
17
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { pollRateLimitRetryAfterMs } from "../commands/poll-quota.mjs";
|
|
2
|
+
/** One optional Actions-enrichment budget for the entire PR snapshot. */
|
|
3
|
+
export class TriageBudget {
|
|
4
|
+
exhausted = false;
|
|
5
|
+
secondaryError;
|
|
6
|
+
omitted = false;
|
|
7
|
+
omissionReported = false;
|
|
8
|
+
get canSchedule() {
|
|
9
|
+
return !this.exhausted && this.secondaryError === undefined;
|
|
10
|
+
}
|
|
11
|
+
get primaryExhausted() {
|
|
12
|
+
return this.exhausted;
|
|
13
|
+
}
|
|
14
|
+
/** Call only when a needed optional request is about to be scheduled. */
|
|
15
|
+
canScheduleOptional() {
|
|
16
|
+
if (this.canSchedule)
|
|
17
|
+
return true;
|
|
18
|
+
if (this.exhausted)
|
|
19
|
+
this.omitted = true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
observe(rateLimit) {
|
|
23
|
+
if (rateLimit?.remaining === 0)
|
|
24
|
+
this.stopForPrimaryLimit();
|
|
25
|
+
}
|
|
26
|
+
observeError(error) {
|
|
27
|
+
const retry = pollRateLimitRetryAfterMs(error);
|
|
28
|
+
if (retry?.kind === "secondary") {
|
|
29
|
+
this.secondaryError ??= error;
|
|
30
|
+
}
|
|
31
|
+
else if (retry?.kind === "primary") {
|
|
32
|
+
this.stopForPrimaryLimit();
|
|
33
|
+
this.omitted = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throwIfSecondary() {
|
|
37
|
+
if (this.secondaryError !== undefined)
|
|
38
|
+
throw this.secondaryError;
|
|
39
|
+
}
|
|
40
|
+
reportOmissionIfNeeded() {
|
|
41
|
+
if (!this.omitted || this.omissionReported || this.secondaryError !== undefined)
|
|
42
|
+
return;
|
|
43
|
+
this.omissionReported = true;
|
|
44
|
+
process.stderr.write("pr-shepherd: REST core quota is exhausted; optional Actions job and log enrichment is incomplete\n");
|
|
45
|
+
}
|
|
46
|
+
stopForPrimaryLimit() {
|
|
47
|
+
this.exhausted = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/bin/checks/triage.d.mts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { CheckRun, ClassifiedCheck, TriagedCheck } from "../types.mts";
|
|
2
2
|
import type { RepoInfo } from "../github/client.mts";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
export declare function
|
|
3
|
+
import type { StateKey } from "../state/rest-cache.mts";
|
|
4
|
+
import { TriageBudget } from "./triage-budget.mts";
|
|
5
|
+
export declare function triageFailingChecks(failingChecks: ClassifiedCheck[], repo: RepoInfo, stateKey?: StateKey, budget?: TriageBudget,
|
|
6
|
+
/** Non-failing checks (ignored, filtered, superseded, …) whose jobs must not resurface as related jobs. */
|
|
7
|
+
otherChecks?: ClassifiedCheck[]): Promise<TriagedCheck[]>;
|
|
8
|
+
export declare function fetchStartupFailureChecks(repo: RepoInfo, headSha: string, prNumber: number, stateKey?: StateKey, budget?: TriageBudget): Promise<CheckRun[]>;
|
package/bin/checks/triage.mjs
CHANGED
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
|
-
import { restWithRateLimit
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { restWithRateLimit } from "../github/http.mjs";
|
|
3
|
+
import { fetchJobLogExcerpt } from "./job-log.mjs";
|
|
4
|
+
import { pickFailedStep } from "./jobs-types.mjs";
|
|
5
|
+
import { pickRelatedFailedJobs, fetchRelatedJobs } from "./related-jobs.mjs";
|
|
6
|
+
import { TriageBudget } from "./triage-budget.mjs";
|
|
7
|
+
import { mapPool } from "../util/pool.mjs";
|
|
5
8
|
const STARTUP_FAILURE_STATUS = "startup_failure";
|
|
6
|
-
export function triageFailingChecks(failingChecks, repo, stateKey)
|
|
9
|
+
export async function triageFailingChecks(failingChecks, repo, stateKey, budget = new TriageBudget(),
|
|
10
|
+
/** Non-failing checks (ignored, filtered, superseded, …) whose jobs must not resurface as related jobs. */
|
|
11
|
+
otherChecks = []) {
|
|
7
12
|
const jobsCache = new Map();
|
|
8
|
-
|
|
13
|
+
const surfaced = surfacedNamesByRun([...failingChecks, ...otherChecks]);
|
|
14
|
+
const relatedOwners = firstCheckPerRun(failingChecks);
|
|
15
|
+
const checks = await mapPool(failingChecks, 4, (check) => triageCheck(check, repo, jobsCache, stateKey, budget, relatedOwners.has(check) ? surfaced.get(check.runId ?? "") : undefined));
|
|
16
|
+
budget.throwIfSecondary();
|
|
17
|
+
budget.reportOmissionIfNeeded();
|
|
18
|
+
return checks;
|
|
9
19
|
}
|
|
10
|
-
async function triageCheck(check, repo, jobsCache, stateKey) {
|
|
20
|
+
async function triageCheck(check, repo, jobsCache, stateKey, budget, relatedSurfaced) {
|
|
11
21
|
if (check.runId === null || check.conclusion === "STARTUP_FAILURE") {
|
|
12
22
|
return { ...check };
|
|
13
23
|
}
|
|
14
|
-
|
|
24
|
+
if (!jobsCache.has(check.runId) && !budget?.canScheduleOptional()) {
|
|
25
|
+
return { ...check };
|
|
26
|
+
}
|
|
27
|
+
const jobs = await fetchJobs(check.runId, repo, jobsCache, stateKey, budget);
|
|
15
28
|
const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
|
|
16
29
|
const runAttempt = jobs ? pickRunAttempt(jobs) : undefined;
|
|
17
30
|
const logExcerpt = check.conclusion !== "CANCELLED" && jobInfo?.jobId
|
|
18
|
-
? await fetchJobLogExcerpt(jobInfo.jobId, repo, stateKey, jobInfo.jobConclusion != null)
|
|
31
|
+
? await fetchJobLogExcerpt(jobInfo.jobId, repo, stateKey, jobInfo.jobConclusion != null, budget)
|
|
19
32
|
: undefined;
|
|
33
|
+
const relatedJobs = jobs && relatedSurfaced && check.conclusion !== "CANCELLED"
|
|
34
|
+
? await fetchRelatedJobs(pickRelatedFailedJobs(jobs, jobInfo?.jobId, relatedSurfaced), repo, stateKey, budget)
|
|
35
|
+
: [];
|
|
20
36
|
return {
|
|
21
37
|
...check,
|
|
22
38
|
...(runAttempt !== undefined && { runAttempt }),
|
|
@@ -24,40 +40,79 @@ async function triageCheck(check, repo, jobsCache, stateKey) {
|
|
|
24
40
|
...(jobInfo?.jobName !== undefined && { jobName: jobInfo.jobName }),
|
|
25
41
|
...(jobInfo?.failedStep !== undefined && { failedStep: jobInfo.failedStep }),
|
|
26
42
|
...(logExcerpt !== undefined && { logExcerpt }),
|
|
43
|
+
...(relatedJobs.length > 0 && { relatedJobs }),
|
|
27
44
|
};
|
|
28
45
|
}
|
|
29
|
-
|
|
46
|
+
function surfacedNamesByRun(checks) {
|
|
47
|
+
const byRun = new Map();
|
|
48
|
+
for (const check of checks) {
|
|
49
|
+
if (check.runId === null)
|
|
50
|
+
continue;
|
|
51
|
+
const names = byRun.get(check.runId) ?? new Set();
|
|
52
|
+
names.add(check.name);
|
|
53
|
+
byRun.set(check.runId, names);
|
|
54
|
+
}
|
|
55
|
+
return byRun;
|
|
56
|
+
}
|
|
57
|
+
/** Sibling failed jobs are reported once per run, on its first failing check. */
|
|
58
|
+
function firstCheckPerRun(checks) {
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const first = new Set();
|
|
61
|
+
for (const check of checks) {
|
|
62
|
+
if (check.runId === null || seen.has(check.runId))
|
|
63
|
+
continue;
|
|
64
|
+
if (check.conclusion === "CANCELLED" || check.conclusion === "STARTUP_FAILURE")
|
|
65
|
+
continue;
|
|
66
|
+
seen.add(check.runId);
|
|
67
|
+
first.add(check);
|
|
68
|
+
}
|
|
69
|
+
return first;
|
|
70
|
+
}
|
|
71
|
+
export async function fetchStartupFailureChecks(repo, headSha, prNumber, stateKey, budget = new TriageBudget()) {
|
|
30
72
|
try {
|
|
31
|
-
return await fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey);
|
|
73
|
+
return await fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey, budget);
|
|
32
74
|
}
|
|
33
75
|
catch (err) {
|
|
76
|
+
budget.observeError(err);
|
|
77
|
+
budget.throwIfSecondary();
|
|
78
|
+
if (budget.primaryExhausted)
|
|
79
|
+
return [];
|
|
34
80
|
const msg = err instanceof Error ? err.message : String(err);
|
|
35
81
|
process.stderr.write(`pr-shepherd: startup-failure run fetch failed for PR #${prNumber} at ${headSha} (ignored): ${msg}\n`);
|
|
36
82
|
return [];
|
|
37
83
|
}
|
|
38
84
|
}
|
|
39
|
-
async function fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey) {
|
|
85
|
+
async function fetchStartupFailureChecksUncached(repo, headSha, prNumber, stateKey, budget) {
|
|
40
86
|
const { owner, name } = repo;
|
|
41
87
|
const perPage = 100;
|
|
42
88
|
const MAX_RUN_PAGES = 10;
|
|
43
89
|
const checks = [];
|
|
44
90
|
for (let page = 1; page <= MAX_RUN_PAGES; page++) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
91
|
+
if (!budget?.canScheduleOptional())
|
|
92
|
+
break;
|
|
93
|
+
let result;
|
|
94
|
+
try {
|
|
95
|
+
result = await restWithRateLimit("GET", `/repos/${owner}/${name}/actions/runs?head_sha=${encodeURIComponent(headSha)}&status=${STARTUP_FAILURE_STATUS}&per_page=${perPage}&page=${page}`, undefined, stateKey
|
|
96
|
+
? {
|
|
97
|
+
conditional: {
|
|
98
|
+
key: stateKey,
|
|
99
|
+
name: `runs-startupfailure-${headSha}-p${page}`,
|
|
100
|
+
headSha,
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
: undefined);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
budget?.observeError(error);
|
|
107
|
+
if (budget?.primaryExhausted)
|
|
108
|
+
break;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
const { data, rateLimit } = result;
|
|
112
|
+
budget?.observe(rateLimit);
|
|
54
113
|
checks.push(...data.workflow_runs
|
|
55
114
|
.filter((run) => runBelongsToPr(run, prNumber, headSha))
|
|
56
115
|
.map(workflowRunToCheckRun));
|
|
57
|
-
if (rateLimit?.remaining === 0) {
|
|
58
|
-
process.stderr.write(`pr-shepherd: REST rate limit remaining is 0 while listing startup-failure runs for ${headSha} — detection may be incomplete\n`);
|
|
59
|
-
break;
|
|
60
|
-
}
|
|
61
116
|
if (data.workflow_runs.length < perPage)
|
|
62
117
|
break;
|
|
63
118
|
if (page === MAX_RUN_PAGES) {
|
|
@@ -95,15 +150,15 @@ function pickRunAttempt(jobs) {
|
|
|
95
150
|
function runBelongsToPr(run, prNumber, headSha) {
|
|
96
151
|
return (run.pull_requests ?? []).some((pr) => pr.number === prNumber && (pr.head?.sha ?? headSha) === headSha);
|
|
97
152
|
}
|
|
98
|
-
function fetchJobs(runId, repo, cache, stateKey) {
|
|
153
|
+
function fetchJobs(runId, repo, cache, stateKey, budget) {
|
|
99
154
|
const cached = cache.get(runId);
|
|
100
155
|
if (cached)
|
|
101
156
|
return cached;
|
|
102
|
-
const promise = fetchJobsUncached(runId, repo, stateKey);
|
|
157
|
+
const promise = fetchJobsUncached(runId, repo, stateKey, budget);
|
|
103
158
|
cache.set(runId, promise);
|
|
104
159
|
return promise;
|
|
105
160
|
}
|
|
106
|
-
async function fetchJobsUncached(runId, repo, stateKey) {
|
|
161
|
+
async function fetchJobsUncached(runId, repo, stateKey, budget) {
|
|
107
162
|
const { owner, name } = repo;
|
|
108
163
|
const perPage = 100;
|
|
109
164
|
const MAX_JOB_PAGES = 20; // 2000 jobs max
|
|
@@ -111,6 +166,8 @@ async function fetchJobsUncached(runId, repo, stateKey) {
|
|
|
111
166
|
const allJobs = [];
|
|
112
167
|
try {
|
|
113
168
|
for (let page = 1;; page++) {
|
|
169
|
+
if (!budget?.canScheduleOptional())
|
|
170
|
+
break;
|
|
114
171
|
if (++pagesFetched > MAX_JOB_PAGES) {
|
|
115
172
|
process.stderr.write(`pr-shepherd: job pagination cap (${MAX_JOB_PAGES * 100} jobs) reached for run ${runId} — triage may be incomplete\n`);
|
|
116
173
|
break;
|
|
@@ -118,17 +175,15 @@ async function fetchJobsUncached(runId, repo, stateKey) {
|
|
|
118
175
|
const { data, rateLimit } = await restWithRateLimit("GET", `/repos/${owner}/${name}/actions/runs/${runId}/jobs?filter=latest&per_page=${perPage}&page=${page}`, undefined, stateKey
|
|
119
176
|
? { conditional: { key: stateKey, name: `jobs-run-${runId}-p${page}` } }
|
|
120
177
|
: undefined);
|
|
178
|
+
budget?.observe(rateLimit);
|
|
121
179
|
allJobs.push(...data.jobs);
|
|
122
|
-
if (rateLimit?.remaining === 0) {
|
|
123
|
-
process.stderr.write(`pr-shepherd: REST rate limit remaining is 0 while listing jobs for run ${runId} — triage may be incomplete\n`);
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
180
|
if (data.jobs.length < perPage)
|
|
127
181
|
break;
|
|
128
182
|
}
|
|
129
183
|
}
|
|
130
|
-
catch {
|
|
131
|
-
|
|
184
|
+
catch (error) {
|
|
185
|
+
budget?.observeError(error);
|
|
186
|
+
return budget?.primaryExhausted ? allJobs : undefined;
|
|
132
187
|
}
|
|
133
188
|
return allJobs;
|
|
134
189
|
}
|
|
@@ -140,10 +195,7 @@ function pickJobInfo(jobs, checkName) {
|
|
|
140
195
|
matchedJobs[0];
|
|
141
196
|
if (!job)
|
|
142
197
|
return undefined;
|
|
143
|
-
const failedStep = job
|
|
144
|
-
s.conclusion !== "success" &&
|
|
145
|
-
s.conclusion !== "skipped" &&
|
|
146
|
-
s.conclusion !== "neutral")?.name;
|
|
198
|
+
const failedStep = pickFailedStep(job);
|
|
147
199
|
return {
|
|
148
200
|
...(job.id !== undefined && { jobId: job.id }),
|
|
149
201
|
workflowName: job.workflow_name,
|
|
@@ -152,27 +204,3 @@ function pickJobInfo(jobs, checkName) {
|
|
|
152
204
|
jobConclusion: job.conclusion,
|
|
153
205
|
};
|
|
154
206
|
}
|
|
155
|
-
/**
|
|
156
|
-
* `cacheable` gates the cross-tick cache — only set once the matched job has
|
|
157
|
-
* a terminal conclusion, so an in-progress job's (possibly partial) log
|
|
158
|
-
* never gets frozen into the cache.
|
|
159
|
-
*/
|
|
160
|
-
async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false) {
|
|
161
|
-
const cacheName = `joblog-v2-${jobId}`;
|
|
162
|
-
if (stateKey && cacheable) {
|
|
163
|
-
const cached = await loadDerived(stateKey, cacheName);
|
|
164
|
-
if (cached)
|
|
165
|
-
return cached.value ?? undefined;
|
|
166
|
-
}
|
|
167
|
-
const { owner, name } = repo;
|
|
168
|
-
try {
|
|
169
|
-
const excerpt = buildLogExcerpt(await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`));
|
|
170
|
-
if (stateKey && cacheable) {
|
|
171
|
-
await storeDerived(stateKey, cacheName, excerpt ?? null);
|
|
172
|
-
}
|
|
173
|
-
return excerpt;
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
@@ -31,9 +31,9 @@ function recommendation(warning) {
|
|
|
31
31
|
return "- Recommendation: keep polling pr-shepherd at the cadence above; both GraphQL and REST core are below their warning thresholds, so do not shift work between them. Do not substitute `gh pr checks` or `gh pr watch`";
|
|
32
32
|
}
|
|
33
33
|
if (warning.resource === "core") {
|
|
34
|
-
return "- Recommendation: keep polling pr-shepherd at the cadence above; do not add incidental REST `gh
|
|
34
|
+
return "- Recommendation: keep polling pr-shepherd at the cadence above; do not add incidental REST `gh api repos/OWNER/REPO/pulls/PR` calls while REST core is low. Do not substitute `gh pr checks` or `gh pr watch`";
|
|
35
35
|
}
|
|
36
|
-
return "- Recommendation: keep polling pr-shepherd at the cadence above; for incidental PR
|
|
36
|
+
return "- Recommendation: keep polling pr-shepherd at the cadence above; for incidental PR reads use explicit REST endpoints such as `gh api repos/OWNER/REPO/pulls/PR`; do not substitute `gh pr checks` or `gh pr watch`";
|
|
37
37
|
}
|
|
38
38
|
export function formatApiUsage(usage) {
|
|
39
39
|
if (usage === undefined)
|
|
@@ -6,6 +6,7 @@ import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag
|
|
|
6
6
|
import { BODY_TRUNCATE_MAX_CHARS } from "./body-truncate.mjs";
|
|
7
7
|
import { numberInstructions } from "./iterate-instructions.mjs";
|
|
8
8
|
import { renderCheckAnnotation, renderProtectedRun } from "./fix-formatter-extra.mjs";
|
|
9
|
+
import { renderRelatedJobLines } from "./related-jobs-format.mjs";
|
|
9
10
|
import { isFailingAgentCheck } from "../checks/conclusions.mjs";
|
|
10
11
|
import { renderMergeCommand } from "../commands/iterate/merge.mjs";
|
|
11
12
|
import { partitionFixThreads } from "../commands/iterate/fix-instruction-threads.mjs";
|
|
@@ -81,6 +82,7 @@ export function formatFixCodeResult(header, result, opts = {}) {
|
|
|
81
82
|
lines.push(` > ${ch.summary}`);
|
|
82
83
|
if (ch.logExcerpt)
|
|
83
84
|
lines.push(indentBlockquote(ch.logExcerpt, " "));
|
|
85
|
+
lines.push(...renderRelatedJobLines(ch.relatedJobs));
|
|
84
86
|
}
|
|
85
87
|
if (ch.rerunCommand && ch.runId && !seenRerunRunIds.has(ch.runId)) {
|
|
86
88
|
seenRerunRunIds.add(ch.runId);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderRelatedJobLines } from "./related-jobs-format.mjs";
|
|
1
2
|
import { renderCheckAnnotation } from "./fix-formatter-extra.mjs";
|
|
2
3
|
export function formatRelevantChecks(checks) {
|
|
3
4
|
if (checks.length === 0)
|
|
@@ -14,6 +15,7 @@ function formatRelevantCheck(check) {
|
|
|
14
15
|
const lines = [`- \`${workflow}${job}\` [conclusion: ${check.conclusion}]`];
|
|
15
16
|
appendCheckFields(lines, check);
|
|
16
17
|
appendLogExcerpt(lines, check.logExcerpt);
|
|
18
|
+
lines.push(...renderRelatedJobLines(check.relatedJobs));
|
|
17
19
|
appendAnnotations(lines, check.annotations, check.logExcerpt);
|
|
18
20
|
return lines;
|
|
19
21
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Shared text rendering of sibling failed jobs under a failing check bullet. */
|
|
2
|
+
export function renderRelatedJobLines(jobs, indent = " ") {
|
|
3
|
+
if (!jobs || jobs.length === 0)
|
|
4
|
+
return [];
|
|
5
|
+
const lines = [`${indent}Other failed jobs in this run:`];
|
|
6
|
+
for (const job of jobs) {
|
|
7
|
+
lines.push(`${indent}- \`${job.name}\` [conclusion: ${job.conclusion}]`);
|
|
8
|
+
if (job.failedStep)
|
|
9
|
+
lines.push(`${indent} > failed step: ${job.failedStep}`);
|
|
10
|
+
if (job.logExcerpt) {
|
|
11
|
+
for (const line of job.logExcerpt.split("\n"))
|
|
12
|
+
lines.push(`${indent} > ${line}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return lines;
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RepoInfo } from "../github/client.mts";
|
|
2
|
+
import { readStackTopology } from "../github/stack-read.mts";
|
|
3
|
+
import type { RawSummaryPr } from "../github/poll-summary-raw.mts";
|
|
4
|
+
/** Fresh for each iterate tick; never persisted or exposed in CLI output. */
|
|
5
|
+
export declare function createCheckExecutionContext(readyDelaySeconds?: number): {
|
|
6
|
+
readStackTopology(pr: number, repo: RepoInfo): ReturnType<typeof readStackTopology>;
|
|
7
|
+
wantsReceiptSummary(pr: number, repo: RepoInfo): Promise<boolean>;
|
|
8
|
+
setReceiptSummary(raw: RawSummaryPr | null): void;
|
|
9
|
+
getReceiptSummary(): RawSummaryPr | null;
|
|
10
|
+
invalidateReceiptSummary(): void;
|
|
11
|
+
};
|
|
12
|
+
export type CheckExecutionContext = ReturnType<typeof createCheckExecutionContext>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readStackTopology } from "../github/stack-read.mjs";
|
|
2
|
+
import { readReadyReceipt } from "../state/ready-receipts.mjs";
|
|
3
|
+
import { readyDelayElapsed } from "./ready-delay.mjs";
|
|
4
|
+
/** Fresh for each iterate tick; never persisted or exposed in CLI output. */
|
|
5
|
+
export function createCheckExecutionContext(readyDelaySeconds) {
|
|
6
|
+
const topologies = new Map();
|
|
7
|
+
let receiptSummary = null;
|
|
8
|
+
let receiptSummaryInvalidated = false;
|
|
9
|
+
return {
|
|
10
|
+
readStackTopology(pr, repo) {
|
|
11
|
+
const key = `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}#${pr}`;
|
|
12
|
+
let pending = topologies.get(key);
|
|
13
|
+
if (!pending) {
|
|
14
|
+
pending = readStackTopology(pr, repo);
|
|
15
|
+
topologies.set(key, pending);
|
|
16
|
+
}
|
|
17
|
+
return pending;
|
|
18
|
+
},
|
|
19
|
+
async wantsReceiptSummary(pr, repo) {
|
|
20
|
+
if (readyDelaySeconds === undefined)
|
|
21
|
+
return false;
|
|
22
|
+
const key = { owner: repo.owner, repo: repo.name, pr };
|
|
23
|
+
if (await readReadyReceipt(key))
|
|
24
|
+
return true;
|
|
25
|
+
return readyDelayElapsed(pr, repo.owner, repo.name, readyDelaySeconds);
|
|
26
|
+
},
|
|
27
|
+
setReceiptSummary(raw) {
|
|
28
|
+
receiptSummary = raw;
|
|
29
|
+
},
|
|
30
|
+
getReceiptSummary() {
|
|
31
|
+
return receiptSummaryInvalidated ? null : receiptSummary;
|
|
32
|
+
},
|
|
33
|
+
invalidateReceiptSummary() {
|
|
34
|
+
receiptSummaryInvalidated = true;
|
|
35
|
+
receiptSummary = null;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|