pr-shepherd 0.8.0 → 0.9.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 +122 -20
- package/bin/checks/classify.mjs +2 -1
- package/bin/checks/triage.mjs +55 -45
- package/bin/cli/args.mjs +9 -3
- package/bin/cli/fix-formatter.mjs +37 -8
- package/bin/cli/formatters.mjs +40 -5
- package/bin/cli/handlers.mjs +4 -3
- package/bin/cli/iterate-formatter.mjs +32 -11
- package/bin/cli/iterate-lean.mjs +98 -0
- package/bin/cli-parser.iterate-fixtures.mjs +11 -4
- package/bin/commands/check-status.mjs +7 -4
- package/bin/commands/check.mjs +40 -3
- package/bin/commands/iterate/classify.mjs +5 -25
- package/bin/commands/iterate/escalate.mjs +2 -2
- package/bin/commands/iterate/fix-code.mjs +11 -7
- package/bin/commands/iterate/helpers.mjs +8 -5
- package/bin/commands/iterate/index.mjs +20 -12
- package/bin/commands/iterate/render.mjs +20 -11
- package/bin/commands/iterate/stall.mjs +1 -1
- package/bin/commands/resolve-instructions.mjs +9 -3
- package/bin/commands/resolve.mjs +37 -17
- package/bin/commands/status.mjs +1 -1
- package/bin/config.json +3 -6
- package/bin/github/batch-parsers.mjs +2 -0
- package/bin/github/gql/batch-pr.gql +2 -0
- package/bin/reporters/agent.mjs +8 -13
- package/bin/reporters/check-instructions.mjs +9 -14
- package/bin/reporters/text.mjs +27 -18
- package/bin/state/seen-comments.mjs +76 -0
- package/bin/types/report.mjs +0 -1
- package/package.json +1 -1
- package/bin/commands/iterate/steps.mjs +0 -31
package/bin/commands/resolve.mjs
CHANGED
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `shepherd resolve [PR] [flags]`
|
|
3
|
-
*
|
|
4
|
-
* Two modes:
|
|
5
|
-
*
|
|
6
|
-
* Fetch mode (--fetch or no mutation flags):
|
|
7
|
-
* Auto-resolves outdated threads and returns all active threads,
|
|
8
|
-
* visible comments, and CHANGES_REQUESTED reviews for LLM triage.
|
|
9
|
-
* Sonnet reads this output, applies code fixes, pushes, then calls
|
|
10
|
-
* resolve in mutation mode to resolve/minimize/dismiss by ID.
|
|
11
|
-
*
|
|
12
|
-
* Mutation mode (--resolve-thread-ids, --minimize-comment-ids, --dismiss-review-ids):
|
|
13
|
-
* Resolves/minimizes/dismisses by ID. Optionally verifies the push
|
|
14
|
-
* has landed on GitHub before mutating (--require-sha).
|
|
15
|
-
*/
|
|
16
1
|
import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
17
2
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
18
3
|
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
@@ -20,6 +5,7 @@ import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mj
|
|
|
20
5
|
import { loadConfig } from "../config/load.mjs";
|
|
21
6
|
import { parseSuggestion } from "../suggestions/parse.mjs";
|
|
22
7
|
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
8
|
+
import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
|
|
23
9
|
/**
|
|
24
10
|
* Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
|
|
25
11
|
*/
|
|
@@ -31,12 +17,26 @@ export async function runResolveFetch(opts) {
|
|
|
31
17
|
}
|
|
32
18
|
// Always bypass cache for resolve — we need fresh data before mutating.
|
|
33
19
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
20
|
+
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
34
21
|
const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
|
|
35
22
|
const visibleComments = data.comments.filter((c) => !c.isMinimized);
|
|
36
|
-
//
|
|
23
|
+
// First-look: collect items that would normally be hidden and check seen markers.
|
|
24
|
+
const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
|
|
25
|
+
const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
26
|
+
const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
27
|
+
const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
|
|
28
|
+
const seenSet = await loadSeenSet(stateKey);
|
|
29
|
+
const unseenOutdated = outdatedCandidates.filter((t) => !seenSet.has(t.id));
|
|
30
|
+
const unseenResolved = resolvedCandidates.filter((t) => !seenSet.has(t.id));
|
|
31
|
+
const unseenMinimizedThreads = minimizedThreadCandidates.filter((t) => !seenSet.has(t.id));
|
|
32
|
+
const unseenMinimizedComments = minimizedCommentCandidates.filter((c) => !seenSet.has(c.id));
|
|
33
|
+
// Auto-resolve outdated (same as before — fires regardless of first-look status).
|
|
37
34
|
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
35
|
+
const autoResolvedIds = new Set();
|
|
38
36
|
if (outdated.length > 0) {
|
|
39
|
-
const { errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
37
|
+
const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
38
|
+
for (const id of resolvedIds)
|
|
39
|
+
autoResolvedIds.add(id);
|
|
40
40
|
if (errors.length > 0) {
|
|
41
41
|
process.stderr.write(`pr-shepherd: auto-resolve outdated threads failed (continuing): ${errors.join(", ")}\n`);
|
|
42
42
|
}
|
|
@@ -50,10 +50,30 @@ export async function runResolveFetch(opts) {
|
|
|
50
50
|
thread.suggestion = suggestion;
|
|
51
51
|
return thread;
|
|
52
52
|
});
|
|
53
|
+
const firstLookThreads = [
|
|
54
|
+
...unseenOutdated.map((t) => ({
|
|
55
|
+
...t,
|
|
56
|
+
firstLookStatus: "outdated",
|
|
57
|
+
autoResolved: autoResolvedIds.has(t.id),
|
|
58
|
+
})),
|
|
59
|
+
...unseenResolved.map((t) => ({ ...t, firstLookStatus: "resolved" })),
|
|
60
|
+
...unseenMinimizedThreads.map((t) => ({ ...t, firstLookStatus: "minimized" })),
|
|
61
|
+
];
|
|
62
|
+
const firstLookComments = unseenMinimizedComments.map((c) => ({
|
|
63
|
+
...c,
|
|
64
|
+
firstLookStatus: "minimized",
|
|
65
|
+
}));
|
|
66
|
+
// Mark first-look items as seen (best-effort — markSeen never throws).
|
|
67
|
+
await Promise.allSettled([
|
|
68
|
+
...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
|
|
69
|
+
...firstLookComments.map((c) => markSeen(stateKey, c.id)),
|
|
70
|
+
]);
|
|
53
71
|
const result = {
|
|
54
72
|
prNumber,
|
|
55
73
|
actionableThreads,
|
|
74
|
+
firstLookThreads,
|
|
56
75
|
actionableComments: visibleComments,
|
|
76
|
+
firstLookComments,
|
|
57
77
|
changesRequestedReviews: data.changesRequestedReviews,
|
|
58
78
|
reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
|
|
59
79
|
commitSuggestionsEnabled: cfg.actions.commitSuggestions,
|
package/bin/commands/status.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function deriveVerdict(s) {
|
|
|
102
102
|
s.reviewDecision !== "CHANGES_REQUESTED") {
|
|
103
103
|
return "READY";
|
|
104
104
|
}
|
|
105
|
-
if (s.mergeStateStatus === "BLOCKED")
|
|
105
|
+
if (s.mergeStateStatus === "BLOCKED" || s.mergeStateStatus === "HAS_HOOKS")
|
|
106
106
|
return "BLOCKED";
|
|
107
107
|
if (s.mergeStateStatus === "DIRTY")
|
|
108
108
|
return "CONFLICTS";
|
package/bin/config.json
CHANGED
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
"cooldownSeconds": 30,
|
|
4
4
|
"fixAttemptsPerThread": 3,
|
|
5
5
|
"stallTimeoutMinutes": 30,
|
|
6
|
-
"
|
|
7
|
-
"bots": true,
|
|
8
|
-
"humans": true,
|
|
9
|
-
"approvals": false
|
|
10
|
-
}
|
|
6
|
+
"minimizeApprovals": false
|
|
11
7
|
},
|
|
12
8
|
"watch": {
|
|
13
9
|
"interval": "4m",
|
|
@@ -24,7 +20,8 @@
|
|
|
24
20
|
"fetchReviewSummaries": true
|
|
25
21
|
},
|
|
26
22
|
"checks": {
|
|
27
|
-
"ciTriggerEvents": ["pull_request", "pull_request_target"]
|
|
23
|
+
"ciTriggerEvents": ["pull_request", "pull_request_target"],
|
|
24
|
+
"logTailLines": 200
|
|
28
25
|
},
|
|
29
26
|
"mergeStatus": {
|
|
30
27
|
"blockingReviewerLogins": ["copilot"]
|
|
@@ -19,6 +19,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
19
19
|
startLine: comment?.startLine ?? null,
|
|
20
20
|
author: comment?.author?.login ?? "unknown",
|
|
21
21
|
body: comment?.body ?? "",
|
|
22
|
+
url: comment?.url ?? "",
|
|
22
23
|
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
23
24
|
};
|
|
24
25
|
});
|
|
@@ -27,6 +28,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
27
28
|
isMinimized: c.isMinimized,
|
|
28
29
|
author: c.author?.login ?? "unknown",
|
|
29
30
|
body: c.body,
|
|
31
|
+
url: c.url,
|
|
30
32
|
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
31
33
|
}));
|
|
32
34
|
const changesRequestedReviews = rawReviewNodes.map((r) => ({
|
package/bin/reporters/agent.mjs
CHANGED
|
@@ -8,38 +8,33 @@
|
|
|
8
8
|
* The original domain types are preserved for check command output.
|
|
9
9
|
*/
|
|
10
10
|
export function toAgentThread(t) {
|
|
11
|
-
return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body };
|
|
11
|
+
return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body, url: t.url };
|
|
12
12
|
}
|
|
13
13
|
export function toAgentComment(c) {
|
|
14
|
-
return { id: c.id, author: c.author, body: c.body };
|
|
14
|
+
return { id: c.id, author: c.author, body: c.body, url: c.url };
|
|
15
15
|
}
|
|
16
16
|
export function toAgentCheck(c) {
|
|
17
17
|
return {
|
|
18
18
|
name: c.name,
|
|
19
19
|
runId: c.runId,
|
|
20
20
|
detailsUrl: c.detailsUrl,
|
|
21
|
-
failureKind: c.failureKind,
|
|
22
21
|
...(c.workflowName !== undefined && { workflowName: c.workflowName }),
|
|
22
|
+
...(c.jobName !== undefined && { jobName: c.jobName }),
|
|
23
23
|
...(c.failedStep !== undefined && { failedStep: c.failedStep }),
|
|
24
24
|
...(c.summary !== undefined && { summary: c.summary }),
|
|
25
|
+
...(c.logTail !== undefined && { logTail: c.logTail }),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
|
-
* Project
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* Project failing checks for the agent. Deduplicates only null-runId external
|
|
30
|
+
* checks by name — when runId is present each check may have a distinct job and
|
|
31
|
+
* log tail, so they are all kept.
|
|
31
32
|
*/
|
|
32
33
|
export function toAgentChecks(checks) {
|
|
33
|
-
const seenRunIds = new Set();
|
|
34
34
|
const seenNames = new Set();
|
|
35
35
|
const result = [];
|
|
36
36
|
for (const c of checks) {
|
|
37
|
-
if (c.runId
|
|
38
|
-
if (seenRunIds.has(c.runId))
|
|
39
|
-
continue;
|
|
40
|
-
seenRunIds.add(c.runId);
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
37
|
+
if (c.runId === null) {
|
|
43
38
|
if (seenNames.has(c.name))
|
|
44
39
|
continue;
|
|
45
40
|
seenNames.add(c.name);
|
|
@@ -26,20 +26,15 @@ export function buildCheckInstructions(report) {
|
|
|
26
26
|
}
|
|
27
27
|
// 3. CI budget policy — one instruction per failing check
|
|
28
28
|
for (const c of checks.failing) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const rerunCmd = c.runId
|
|
39
|
-
? `gh run rerun ${c.runId} --failed`
|
|
40
|
-
: `gh run rerun <runId> --failed`;
|
|
41
|
-
instructions.push(`Re-run transient failure: \`${c.name}\` [${c.failureKind}] — run \`${rerunCmd}\`.`);
|
|
42
|
-
}
|
|
29
|
+
const stepHint = c.failedStep ? ` (failed step: \`${c.failedStep}\`)` : "";
|
|
30
|
+
const diagnosisHint = c.runId
|
|
31
|
+
? c.logTail !== undefined
|
|
32
|
+
? `examine the log tail${stepHint} — if transient, run \`gh run rerun ${c.runId} --failed\`; otherwise apply a fix`
|
|
33
|
+
: `run \`gh run view ${c.runId} --log-failed\`${stepHint} to diagnose — if transient, rerun with \`gh run rerun ${c.runId} --failed\`; otherwise apply a fix`
|
|
34
|
+
: c.detailsUrl
|
|
35
|
+
? `open the check details (${c.detailsUrl}) to diagnose the failure`
|
|
36
|
+
: `no run or details URL available — escalate to a human`;
|
|
37
|
+
instructions.push(`Failing check: \`${c.name}\` — ${diagnosisHint}.`);
|
|
43
38
|
}
|
|
44
39
|
// 4. Ready-to-merge gate
|
|
45
40
|
const isClean = mergeStatus.mergeStateStatus === "CLEAN";
|
package/bin/reporters/text.mjs
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { buildCheckInstructions } from "./check-instructions.mjs";
|
|
2
2
|
export function formatText(report) {
|
|
3
3
|
const parts = [];
|
|
4
|
-
// Header — scan-friendly status lines for quick at-a-glance review
|
|
5
4
|
parts.push(`\nPR #${report.pr} — ${report.repo}`);
|
|
6
5
|
parts.push(`Status: ${report.status}`);
|
|
7
6
|
parts.push(`Base: ${report.baseBranch}`);
|
|
8
7
|
parts.push("");
|
|
9
|
-
// Merge status
|
|
10
8
|
const ms = report.mergeStatus;
|
|
11
9
|
parts.push("## Merge Status");
|
|
12
10
|
parts.push("");
|
|
@@ -17,7 +15,6 @@ export function formatText(report) {
|
|
|
17
15
|
parts.push(` isDraft: ${ms.isDraft}`);
|
|
18
16
|
parts.push(` copilotReviewInProgress: ${ms.copilotReviewInProgress}`);
|
|
19
17
|
parts.push("");
|
|
20
|
-
// CI checks
|
|
21
18
|
const { passing, failing, inProgress, skipped } = report.checks;
|
|
22
19
|
const total = passing.length + failing.length + inProgress.length + skipped.length;
|
|
23
20
|
parts.push("## CI Checks");
|
|
@@ -63,8 +60,7 @@ export function formatText(report) {
|
|
|
63
60
|
}
|
|
64
61
|
parts.push("");
|
|
65
62
|
}
|
|
66
|
-
|
|
67
|
-
const { actionable: actionableThreads, autoResolved, autoResolveErrors } = report.threads;
|
|
63
|
+
const { actionable: actionableThreads, autoResolved, autoResolveErrors, firstLook: firstLookThreads, } = report.threads;
|
|
68
64
|
const hasThreadSection = autoResolved.length > 0 || autoResolveErrors.length > 0 || actionableThreads.length > 0;
|
|
69
65
|
if (hasThreadSection) {
|
|
70
66
|
parts.push("## Review Threads");
|
|
@@ -94,8 +90,7 @@ export function formatText(report) {
|
|
|
94
90
|
parts.push("");
|
|
95
91
|
}
|
|
96
92
|
}
|
|
97
|
-
|
|
98
|
-
const { actionable: actionableComments } = report.comments;
|
|
93
|
+
const { actionable: actionableComments, firstLook: firstLookComments } = report.comments;
|
|
99
94
|
if (actionableComments.length > 0) {
|
|
100
95
|
parts.push("## PR Comments");
|
|
101
96
|
parts.push("");
|
|
@@ -106,7 +101,6 @@ export function formatText(report) {
|
|
|
106
101
|
}
|
|
107
102
|
parts.push("");
|
|
108
103
|
}
|
|
109
|
-
// CHANGES_REQUESTED reviews
|
|
110
104
|
if (report.changesRequestedReviews.length > 0) {
|
|
111
105
|
parts.push("## CHANGES_REQUESTED Reviews");
|
|
112
106
|
parts.push("");
|
|
@@ -115,7 +109,6 @@ export function formatText(report) {
|
|
|
115
109
|
}
|
|
116
110
|
parts.push("");
|
|
117
111
|
}
|
|
118
|
-
// Review summaries
|
|
119
112
|
if (report.reviewSummaries.length > 0) {
|
|
120
113
|
parts.push("## Review Summaries");
|
|
121
114
|
parts.push("");
|
|
@@ -124,7 +117,6 @@ export function formatText(report) {
|
|
|
124
117
|
}
|
|
125
118
|
parts.push("");
|
|
126
119
|
}
|
|
127
|
-
// Approved reviews
|
|
128
120
|
if (report.approvedReviews.length > 0) {
|
|
129
121
|
parts.push("## Approved Reviews");
|
|
130
122
|
parts.push("");
|
|
@@ -133,15 +125,35 @@ export function formatText(report) {
|
|
|
133
125
|
}
|
|
134
126
|
parts.push("");
|
|
135
127
|
}
|
|
136
|
-
|
|
128
|
+
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
129
|
+
if (firstLookTotal > 0) {
|
|
130
|
+
parts.push("## First-look items");
|
|
131
|
+
parts.push("");
|
|
132
|
+
for (const t of firstLookThreads) {
|
|
133
|
+
const statusTag = t.autoResolved
|
|
134
|
+
? `[status: outdated, auto-resolved]`
|
|
135
|
+
: `[status: ${t.firstLookStatus}]`;
|
|
136
|
+
const loc = t.path ? `${t.path}:${t.line ?? "?"}` : "(no location)";
|
|
137
|
+
parts.push(`- threadId=${t.id} ${loc} (@${t.author}) ${statusTag}`);
|
|
138
|
+
parts.push(` ${firstLine(t.body)}`);
|
|
139
|
+
}
|
|
140
|
+
for (const c of firstLookComments) {
|
|
141
|
+
parts.push(`- commentId=${c.id} (@${c.author}) [status: minimized]`);
|
|
142
|
+
parts.push(` ${firstLine(c.body)}`);
|
|
143
|
+
}
|
|
144
|
+
parts.push("");
|
|
145
|
+
}
|
|
137
146
|
const totalActionable = actionableThreads.length + actionableComments.length + report.changesRequestedReviews.length;
|
|
138
147
|
parts.push("## Summary");
|
|
139
148
|
parts.push("");
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
const counts = [];
|
|
150
|
+
if (totalActionable > 0)
|
|
151
|
+
counts.push(`${totalActionable} actionable`);
|
|
152
|
+
if (firstLookTotal > 0)
|
|
153
|
+
counts.push(`${firstLookTotal} first-look`);
|
|
154
|
+
const summaryLine = counts.join(", ") || "0 actionable — all threads resolved/minimized";
|
|
155
|
+
parts.push(summaryLine);
|
|
143
156
|
parts.push("");
|
|
144
|
-
// Instructions
|
|
145
157
|
parts.push("## Instructions");
|
|
146
158
|
parts.push("");
|
|
147
159
|
const instructions = buildCheckInstructions(report);
|
|
@@ -150,9 +162,6 @@ export function formatText(report) {
|
|
|
150
162
|
});
|
|
151
163
|
return parts.join("\n");
|
|
152
164
|
}
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
// Helpers
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
165
|
function firstLine(text) {
|
|
157
166
|
return (text.split("\n")[0] ?? "").trim().slice(0, 120);
|
|
158
167
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, access, readdir } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Public API
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/**
|
|
9
|
+
* Read the seen/ directory once and return a Set of already-seen IDs.
|
|
10
|
+
* Prefer this over repeated hasSeen() calls to avoid EMFILE on large PRs.
|
|
11
|
+
* Returns an empty Set if the directory does not yet exist.
|
|
12
|
+
*/
|
|
13
|
+
export async function loadSeenSet(key) {
|
|
14
|
+
try {
|
|
15
|
+
const dir = resolveDir(key);
|
|
16
|
+
const entries = await readdir(dir);
|
|
17
|
+
return new Set(entries.filter((e) => e.endsWith(".json")).map((e) => e.slice(0, -5)));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return new Set();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Return true if a "seen" marker exists for this id. */
|
|
24
|
+
export async function hasSeen(key, id) {
|
|
25
|
+
try {
|
|
26
|
+
await access(resolvePath(key, id));
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Write a "seen" marker for this id. Idempotent — preserves original seenAt on double-write. */
|
|
34
|
+
export async function markSeen(key, id) {
|
|
35
|
+
try {
|
|
36
|
+
const path = resolvePath(key, id);
|
|
37
|
+
await mkdir(dirname(path), { recursive: true });
|
|
38
|
+
// O_EXCL: create-only — EEXIST means already marked, which is the idempotent success case.
|
|
39
|
+
// seenAt is unix milliseconds (Date.now()), matching JS convention for this module.
|
|
40
|
+
await writeFile(path, JSON.stringify({ seenAt: Date.now() }), { flag: "wx", encoding: "utf8" });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// EEXIST = already seen. All other errors are best-effort.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Read the full marker for inspection (returns null on miss or error). */
|
|
47
|
+
export async function readSeenMarker(key, id) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readFile(resolvePath(key, id), "utf8");
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function resolveDir(key) {
|
|
60
|
+
for (const [field, value] of [
|
|
61
|
+
["owner", key.owner],
|
|
62
|
+
["repo", key.repo],
|
|
63
|
+
]) {
|
|
64
|
+
if (!SAFE_SEGMENT.test(value)) {
|
|
65
|
+
throw new Error(`Invalid state key segment "${field}": ${value}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const base = process.env["PR_SHEPHERD_STATE_DIR"] ?? join(tmpdir(), "pr-shepherd-state");
|
|
69
|
+
return join(base, `${key.owner}-${key.repo}`, String(key.pr), "seen");
|
|
70
|
+
}
|
|
71
|
+
function resolvePath(key, id) {
|
|
72
|
+
if (!SAFE_SEGMENT.test(id)) {
|
|
73
|
+
throw new Error(`Invalid state key segment "id": ${id}`);
|
|
74
|
+
}
|
|
75
|
+
return join(resolveDir(key), `${id}.json`);
|
|
76
|
+
}
|
package/bin/types/report.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { applyStallGuard } from "./stall.mjs";
|
|
2
|
-
export async function buildRerunCiResult(transientChecks, base, prNumber, stallKey, stallTimeoutSeconds, headSha, report, reviewSummaryIds) {
|
|
3
|
-
const runMap = new Map();
|
|
4
|
-
for (const c of transientChecks) {
|
|
5
|
-
if (c.runId === null)
|
|
6
|
-
continue;
|
|
7
|
-
const existing = runMap.get(c.runId);
|
|
8
|
-
if (existing) {
|
|
9
|
-
existing.checkNames.push(c.name);
|
|
10
|
-
}
|
|
11
|
-
else {
|
|
12
|
-
runMap.set(c.runId, {
|
|
13
|
-
runId: c.runId,
|
|
14
|
-
checkNames: [c.name],
|
|
15
|
-
failureKind: c.failureKind,
|
|
16
|
-
workflowName: c.workflowName,
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
const reran = [...runMap.values()];
|
|
21
|
-
const runSummaries = reran.map(({ runId, checkNames, failureKind, workflowName }) => {
|
|
22
|
-
const prefix = workflowName ? `${workflowName} › ` : "";
|
|
23
|
-
return `${runId} (${prefix}${checkNames.join(", ")} — ${failureKind})`;
|
|
24
|
-
});
|
|
25
|
-
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
|
|
26
|
-
...base,
|
|
27
|
-
action: "rerun_ci",
|
|
28
|
-
reran,
|
|
29
|
-
log: `RERUN NEEDED — ${reran.length} CI run${reran.length === 1 ? "" : "s"}: ${runSummaries.join(", ")}`,
|
|
30
|
-
}, report, reviewSummaryIds);
|
|
31
|
-
}
|