patchrelay 0.68.3 → 0.68.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.68.3",
4
- "commit": "e2b544833efd",
5
- "builtAt": "2026-05-15T11:45:23.425Z"
3
+ "version": "0.68.4",
4
+ "commit": "7680b548a651",
5
+ "builtAt": "2026-05-15T11:58:17.825Z"
6
6
  }
@@ -0,0 +1,64 @@
1
+ export async function collectActiveOverlapFindings(snapshots, runCommand) {
2
+ const findings = [];
3
+ const diffsByProject = new Map();
4
+ for (const snapshot of snapshots) {
5
+ const { issue } = snapshot;
6
+ if (issue.activeRunId === undefined || !issue.worktreePath) {
7
+ continue;
8
+ }
9
+ const files = await listModifiedTrackedFiles(runCommand, issue.worktreePath);
10
+ if (files.size === 0) {
11
+ continue;
12
+ }
13
+ const projectDiffs = diffsByProject.get(issue.projectId) ?? [];
14
+ projectDiffs.push({ issue, files });
15
+ diffsByProject.set(issue.projectId, projectDiffs);
16
+ }
17
+ for (const [projectId, diffs] of diffsByProject) {
18
+ for (let leftIndex = 0; leftIndex < diffs.length; leftIndex += 1) {
19
+ const left = diffs[leftIndex];
20
+ for (let rightIndex = leftIndex + 1; rightIndex < diffs.length; rightIndex += 1) {
21
+ const right = diffs[rightIndex];
22
+ const overlap = [...left.files].filter((file) => right.files.has(file)).sort();
23
+ if (overlap.length === 0) {
24
+ continue;
25
+ }
26
+ findings.push({
27
+ status: "warn",
28
+ scope: "issue:overlap",
29
+ message: `Active work overlaps with ${right.issue.issueKey ?? right.issue.linearIssueId}: ${overlap.slice(0, 3).join(", ")}${overlap.length > 3 ? " ..." : ""}`,
30
+ ...(left.issue.issueKey ? { issueKey: left.issue.issueKey } : {}),
31
+ projectId,
32
+ });
33
+ }
34
+ }
35
+ }
36
+ return findings;
37
+ }
38
+ export async function listModifiedTrackedFiles(runCommand, worktreePath) {
39
+ let result;
40
+ try {
41
+ result = await runCommand("git", ["-C", worktreePath, "status", "--porcelain", "--untracked-files=no"]);
42
+ }
43
+ catch {
44
+ return new Set();
45
+ }
46
+ if (result.exitCode !== 0) {
47
+ return new Set();
48
+ }
49
+ const files = new Set();
50
+ for (const line of result.stdout.split("\n")) {
51
+ if (line.trim().length === 0)
52
+ continue;
53
+ const rawPath = line.slice(3).trim();
54
+ if (!rawPath)
55
+ continue;
56
+ const normalized = rawPath.includes(" -> ")
57
+ ? rawPath.split(" -> ").at(-1)?.trim()
58
+ : rawPath;
59
+ if (normalized) {
60
+ files.add(normalized);
61
+ }
62
+ }
63
+ return files;
64
+ }
@@ -0,0 +1,186 @@
1
+ import { deriveGateCheckStatusFromRollup } from "../../github-rollup.js";
2
+ export function getGateCheckNames(project) {
3
+ const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
4
+ return configured.length > 0 ? configured : ["verify"];
5
+ }
6
+ export function deriveCiGateStatus(statusCheckRollup, gateCheckNames) {
7
+ const gateStatus = deriveGateCheckStatusFromRollup(statusCheckRollup, gateCheckNames);
8
+ if (gateStatus) {
9
+ return gateStatus;
10
+ }
11
+ const entries = Array.isArray(statusCheckRollup) ? statusCheckRollup : [];
12
+ if (entries.length === 0) {
13
+ return "unknown";
14
+ }
15
+ const hasPending = entries.some((entry) => {
16
+ const status = entry.status?.trim().toLowerCase();
17
+ return status === "queued" || status === "in_progress" || status === "requested" || status === "waiting" || status === "pending";
18
+ });
19
+ if (hasPending) {
20
+ return "pending";
21
+ }
22
+ return "unknown";
23
+ }
24
+ export function deriveCiOwner(params) {
25
+ if (params.activeRunId !== undefined) {
26
+ return "patchrelay";
27
+ }
28
+ const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
29
+ && params.latestBlockingReviewHeadSha
30
+ && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
31
+ if (params.gateCheckStatus === "failure") {
32
+ if (!params.delegatedToPatchRelay)
33
+ return "paused";
34
+ return params.factoryState === "repairing_ci" ? "patchrelay" : "unknown";
35
+ }
36
+ if (params.gateCheckStatus === "pending") {
37
+ return "external";
38
+ }
39
+ if (params.factoryState === "awaiting_queue" || params.reviewDecision === "APPROVED") {
40
+ if (params.mergeConflictDetected && !params.delegatedToPatchRelay) {
41
+ return "paused";
42
+ }
43
+ return params.mergeConflictDetected && params.factoryState !== "repairing_queue"
44
+ ? "unknown"
45
+ : "downstream";
46
+ }
47
+ if (params.reviewDecision === "CHANGES_REQUESTED") {
48
+ if (params.mergeConflictDetected) {
49
+ if (!params.delegatedToPatchRelay)
50
+ return "paused";
51
+ return params.factoryState === "changes_requested" ? "patchrelay" : "unknown";
52
+ }
53
+ if (!params.delegatedToPatchRelay)
54
+ return "paused";
55
+ if (params.factoryState === "changes_requested")
56
+ return "patchrelay";
57
+ if (params.reviewQuillAttempt?.backlog
58
+ && params.currentHeadSha
59
+ && params.reviewQuillAttempt.headSha
60
+ && params.currentHeadSha !== params.reviewQuillAttempt.headSha) {
61
+ return "review-quill";
62
+ }
63
+ if (params.reviewQuillAttempt && !params.reviewQuillAttempt.backlog)
64
+ return "review-quill";
65
+ if (headAdvancedPastBlockingReview)
66
+ return "reviewer";
67
+ return "unknown";
68
+ }
69
+ if (params.reviewDecision === "REVIEW_REQUIRED") {
70
+ if (params.reviewQuillAttempt)
71
+ return "review-quill";
72
+ if (params.gateCheckStatus === "success")
73
+ return "reviewer";
74
+ return params.reviewRequested ? "reviewer" : "unknown";
75
+ }
76
+ if (params.gateCheckStatus === "success" && params.factoryState === "pr_open") {
77
+ return "reviewer";
78
+ }
79
+ return "external";
80
+ }
81
+ export function describeCiOwnership(params) {
82
+ const blockingReviewTargetsCurrentHead = Boolean(params.currentHeadSha
83
+ && params.latestBlockingReviewHeadSha
84
+ && params.currentHeadSha === params.latestBlockingReviewHeadSha);
85
+ const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
86
+ && params.latestBlockingReviewHeadSha
87
+ && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
88
+ if (params.owner === "patchrelay") {
89
+ if (params.mergeConflictDetected) {
90
+ return "PatchRelay owns the next branch-upkeep move";
91
+ }
92
+ return params.gateCheckStatus === "failure"
93
+ ? "PatchRelay owns the next CI repair move"
94
+ : "PatchRelay owns the next requested-changes move";
95
+ }
96
+ if (params.owner === "review-quill") {
97
+ if (params.reviewQuillAttempt?.backlog) {
98
+ return "review-quill is actively reconciling this repo; this PR is waiting in the current review backlog";
99
+ }
100
+ return params.reviewQuillAttempt?.id && params.reviewQuillAttempt.status
101
+ ? `review-quill attempt #${params.reviewQuillAttempt.id} is ${params.reviewQuillAttempt.status} on the current head`
102
+ : "review-quill owns the current review attempt";
103
+ }
104
+ if (params.owner === "reviewer") {
105
+ if (headAdvancedPastBlockingReview) {
106
+ return "Waiting on review of a newer pushed head";
107
+ }
108
+ return params.reviewRequested
109
+ ? "Waiting on an active reviewer request"
110
+ : "Waiting on review of the current head";
111
+ }
112
+ if (params.owner === "downstream") {
113
+ return params.mergeConflictDetected
114
+ ? "Downstream merge automation is expected to repair or requeue this PR"
115
+ : "Downstream merge automation owns the next move";
116
+ }
117
+ if (params.owner === "external") {
118
+ return params.gateCheckStatus === "pending"
119
+ ? "Waiting on external CI checks to settle"
120
+ : "Waiting on external GitHub automation";
121
+ }
122
+ if (params.owner === "paused") {
123
+ if (params.gateCheckStatus === "failure") {
124
+ return "PatchRelay is paused; delegate the issue again to repair failing CI";
125
+ }
126
+ if (params.reviewDecision === "CHANGES_REQUESTED") {
127
+ return params.mergeConflictDetected
128
+ ? "PatchRelay is paused; delegate the issue again to repair the blocked PR branch"
129
+ : "PatchRelay is paused; delegate the issue again to address requested changes";
130
+ }
131
+ if (params.mergeConflictDetected) {
132
+ return "PatchRelay is paused; delegate the issue again to repair this merge conflict";
133
+ }
134
+ return "PatchRelay is paused; no automatic repair will start until the issue is delegated again";
135
+ }
136
+ if (params.reviewDecision === "CHANGES_REQUESTED") {
137
+ if (params.mergeConflictDetected) {
138
+ return headAdvancedPastBlockingReview
139
+ ? "PR is still dirty after a newer pushed head and no branch-upkeep run is active"
140
+ : "PR is still dirty on the current blocked head and no branch-upkeep run is active";
141
+ }
142
+ return blockingReviewTargetsCurrentHead
143
+ ? "Requested changes still block the same head and no fix run is active"
144
+ : "Waiting on review after a newer pushed head";
145
+ }
146
+ if (params.reviewDecision === "REVIEW_REQUIRED") {
147
+ return "Waiting on review of the current head";
148
+ }
149
+ return "No visible next owner for this PR state";
150
+ }
151
+ export function buildCiEntry(params) {
152
+ const { issue, delegatedToPatchRelay, gateCheckStatus, reviewDecision, reviewRequested, currentHeadSha, latestBlockingReviewHeadSha, mergeConflictDetected, reviewQuillAttempt, } = params;
153
+ const owner = deriveCiOwner({
154
+ delegatedToPatchRelay,
155
+ gateCheckStatus,
156
+ activeRunId: issue.activeRunId,
157
+ factoryState: issue.factoryState,
158
+ reviewDecision,
159
+ reviewRequested,
160
+ currentHeadSha,
161
+ latestBlockingReviewHeadSha,
162
+ mergeConflictDetected,
163
+ reviewQuillAttempt,
164
+ });
165
+ return {
166
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
167
+ projectId: issue.projectId,
168
+ prNumber: issue.prNumber,
169
+ gateStatus: gateCheckStatus,
170
+ owner,
171
+ orphaned: owner === "unknown",
172
+ factoryState: issue.factoryState,
173
+ ...(reviewDecision ? { reviewDecision } : {}),
174
+ message: describeCiOwnership({
175
+ delegatedToPatchRelay,
176
+ gateCheckStatus,
177
+ owner,
178
+ reviewDecision,
179
+ reviewRequested,
180
+ currentHeadSha,
181
+ latestBlockingReviewHeadSha,
182
+ mergeConflictDetected,
183
+ reviewQuillAttempt,
184
+ }),
185
+ };
186
+ }
@@ -0,0 +1,212 @@
1
+ import { resolveClosedPrDisposition } from "../../pr-state.js";
2
+ import { buildCiEntry, deriveCiGateStatus, getGateCheckNames, } from "./ci-classification.js";
3
+ import { extractLatestBlockingReviewHeadSha, extractRequestedReviewerLogins, probeGitHubPullRequest, } from "./github-probe.js";
4
+ import { RECONCILIATION_GRACE_MS } from "./shared.js";
5
+ export async function evaluateGitHubIssueHealth(snapshot, config, runCommand, reviewQuillProbe, reviewQuillAttemptOwners, mergeStewardProbe) {
6
+ const { issue, ageMs } = snapshot;
7
+ const project = config.projects.find((entry) => entry.id === issue.projectId);
8
+ const repoFullName = project?.github?.repoFullName;
9
+ if (!repoFullName || issue.prNumber === undefined) {
10
+ return {
11
+ finding: issue.prNumber !== undefined
12
+ ? {
13
+ status: "fail",
14
+ scope: "github:config",
15
+ message: "PR-backed issue has no GitHub repo configured",
16
+ }
17
+ : undefined,
18
+ };
19
+ }
20
+ const probe = await probeGitHubPullRequest(runCommand, repoFullName, issue.prNumber);
21
+ if (!probe.ok) {
22
+ return {
23
+ ciEntry: {
24
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
25
+ projectId: issue.projectId,
26
+ prNumber: issue.prNumber,
27
+ gateStatus: "unknown",
28
+ owner: "unknown",
29
+ orphaned: true,
30
+ factoryState: issue.factoryState,
31
+ message: `GitHub probe failed: ${probe.error}`,
32
+ },
33
+ finding: {
34
+ status: "warn",
35
+ scope: "github:probe",
36
+ message: `Unable to query GitHub PR state: ${probe.error}`,
37
+ },
38
+ };
39
+ }
40
+ const pr = probe.pr;
41
+ const gateCheckNames = getGateCheckNames(project);
42
+ const gateCheckStatus = deriveCiGateStatus(pr.statusCheckRollup, gateCheckNames);
43
+ const reviewDecision = pr.reviewDecision?.trim().toUpperCase();
44
+ const requestedReviewers = extractRequestedReviewerLogins(pr.reviewRequests);
45
+ const reviewRequested = requestedReviewers.length > 0;
46
+ const latestBlockingReviewHeadSha = extractLatestBlockingReviewHeadSha(pr.latestReviews);
47
+ const mergeConflictDetected = pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY";
48
+ const reviewQuillAttempt = issue.issueKey ? reviewQuillAttemptOwners?.get(issue.issueKey) : undefined;
49
+ if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
50
+ return {
51
+ finding: {
52
+ status: "fail",
53
+ scope: "github:reconcile",
54
+ message: "PR is already merged but the issue has not advanced to done",
55
+ },
56
+ };
57
+ }
58
+ if (pr.state === "CLOSED") {
59
+ const closedPrDisposition = resolveClosedPrDisposition(issue);
60
+ if (closedPrDisposition === "redelegate" && issue.factoryState !== "delegated" && ageMs >= RECONCILIATION_GRACE_MS) {
61
+ return {
62
+ finding: {
63
+ status: "fail",
64
+ scope: "github:reconcile",
65
+ message: "PR is closed but unfinished work has not been re-delegated",
66
+ },
67
+ };
68
+ }
69
+ return {};
70
+ }
71
+ const ciEntry = buildCiEntry({
72
+ issue,
73
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
74
+ gateCheckStatus,
75
+ reviewDecision,
76
+ reviewRequested,
77
+ currentHeadSha: pr.headRefOid,
78
+ latestBlockingReviewHeadSha,
79
+ mergeConflictDetected,
80
+ reviewQuillAttempt,
81
+ });
82
+ if (pr.state === "MERGED" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
83
+ return {
84
+ ciEntry,
85
+ finding: {
86
+ status: "fail",
87
+ scope: "github:reconcile",
88
+ message: "PR is already merged but the issue has not advanced to done",
89
+ },
90
+ };
91
+ }
92
+ if (pr.state === "CLOSED" && issue.factoryState !== "delegated" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
93
+ return {
94
+ ciEntry,
95
+ finding: {
96
+ status: "fail",
97
+ scope: "github:reconcile",
98
+ message: "PR is closed but the issue is still waiting on PR state",
99
+ },
100
+ };
101
+ }
102
+ if (issue.delegatedToPatchRelay
103
+ && gateCheckStatus === "failure"
104
+ && issue.factoryState !== "repairing_ci"
105
+ // Plan §6.1 / §4.3: branch CI failures while In Deploy are
106
+ // metadata only — the lander's spec CI is the gate. Don't flag
107
+ // these as a missing-ci-repair condition.
108
+ && issue.factoryState !== "awaiting_queue"
109
+ && issue.activeRunId === undefined
110
+ && ageMs >= RECONCILIATION_GRACE_MS) {
111
+ // Plan §6.1: when the PR is also approved, this is the
112
+ // "In Review · stuck at admission" condition — the lander would
113
+ // accept the verdict but branch CI is red and (post-§4.3) we no
114
+ // longer auto-repair. Keep the same scope/status pair so existing
115
+ // dashboards continue to surface it; just sharpen the message.
116
+ if (reviewDecision === "APPROVED") {
117
+ return {
118
+ ciEntry,
119
+ finding: {
120
+ status: "fail",
121
+ scope: "github:ci",
122
+ message: "In Review · stuck at admission — PR is approved but gate CI is red and no CI repair is running",
123
+ },
124
+ };
125
+ }
126
+ return {
127
+ ciEntry,
128
+ finding: {
129
+ status: "fail",
130
+ scope: "github:ci",
131
+ message: "Gate CI is failing but no CI repair is running or queued",
132
+ },
133
+ };
134
+ }
135
+ if (reviewDecision === "APPROVED" && issue.factoryState !== "awaiting_queue" && issue.factoryState !== "done" && ageMs >= RECONCILIATION_GRACE_MS) {
136
+ return {
137
+ ciEntry,
138
+ finding: {
139
+ status: "fail",
140
+ scope: "github:reconcile",
141
+ message: "PR is approved but the issue has not handed off to downstream merge automation",
142
+ },
143
+ };
144
+ }
145
+ if (gateCheckStatus === "success"
146
+ && reviewDecision === "CHANGES_REQUESTED"
147
+ && mergeConflictDetected
148
+ && issue.delegatedToPatchRelay
149
+ && issue.factoryState !== "changes_requested"
150
+ && issue.activeRunId === undefined
151
+ && ageMs >= RECONCILIATION_GRACE_MS) {
152
+ return {
153
+ ciEntry,
154
+ finding: {
155
+ status: "fail",
156
+ scope: "github:branch-upkeep",
157
+ message: "PR is still dirty after requested changes, but no branch-upkeep run is active",
158
+ },
159
+ };
160
+ }
161
+ if (gateCheckStatus === "success"
162
+ && reviewDecision === "CHANGES_REQUESTED"
163
+ && latestBlockingReviewHeadSha === pr.headRefOid
164
+ && !reviewQuillAttempt
165
+ && issue.delegatedToPatchRelay
166
+ && issue.factoryState !== "changes_requested"
167
+ && ageMs >= RECONCILIATION_GRACE_MS) {
168
+ return {
169
+ ciEntry,
170
+ finding: {
171
+ status: "fail",
172
+ scope: "github:review-handoff",
173
+ message: "Requested changes still block the current head, but no review fix is running",
174
+ },
175
+ };
176
+ }
177
+ if (requestedReviewers.includes("review-quill") && reviewQuillProbe && reviewQuillProbe.status !== "pass") {
178
+ return {
179
+ ciEntry,
180
+ finding: {
181
+ status: "fail",
182
+ scope: "github:review-automation",
183
+ message: `PR is waiting on review-quill but the service is not healthy: ${reviewQuillProbe.message}`,
184
+ },
185
+ };
186
+ }
187
+ if (issue.delegatedToPatchRelay
188
+ && issue.factoryState === "awaiting_queue"
189
+ && mergeConflictDetected
190
+ && issue.activeRunId === undefined
191
+ && ageMs >= RECONCILIATION_GRACE_MS) {
192
+ return {
193
+ ciEntry,
194
+ finding: {
195
+ status: "fail",
196
+ scope: "github:queue",
197
+ message: "PR has merge conflicts but no queue repair is running or queued",
198
+ },
199
+ };
200
+ }
201
+ if (issue.factoryState === "awaiting_queue" && mergeStewardProbe && mergeStewardProbe.status !== "pass" && ageMs >= RECONCILIATION_GRACE_MS) {
202
+ return {
203
+ ciEntry,
204
+ finding: {
205
+ status: "fail",
206
+ scope: "github:queue",
207
+ message: `Issue is waiting on downstream merge automation but merge-steward is not healthy: ${mergeStewardProbe.message}`,
208
+ },
209
+ };
210
+ }
211
+ return { ciEntry };
212
+ }
@@ -0,0 +1,67 @@
1
+ import { safeJsonParse } from "./shared.js";
2
+ export async function probeGitHubPullRequest(runCommand, repoFullName, prNumber) {
3
+ let result;
4
+ try {
5
+ result = await runCommand("gh", [
6
+ "pr",
7
+ "view",
8
+ String(prNumber),
9
+ "--repo",
10
+ repoFullName,
11
+ "--json",
12
+ "state,reviewDecision,reviewRequests,latestReviews,statusCheckRollup,mergeable,mergeStateStatus,headRefOid",
13
+ ]);
14
+ }
15
+ catch (error) {
16
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
17
+ }
18
+ if (result.exitCode !== 0) {
19
+ return {
20
+ ok: false,
21
+ error: [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join(" ") || `gh exited ${result.exitCode}`,
22
+ };
23
+ }
24
+ const parsed = safeJsonParse(result.stdout);
25
+ if (!parsed) {
26
+ return { ok: false, error: "invalid JSON from gh pr view" };
27
+ }
28
+ return { ok: true, pr: parsed };
29
+ }
30
+ export function extractLatestBlockingReviewHeadSha(latestReviews) {
31
+ if (!Array.isArray(latestReviews)) {
32
+ return undefined;
33
+ }
34
+ for (const review of latestReviews) {
35
+ if (!review || typeof review !== "object")
36
+ continue;
37
+ const state = typeof review.state === "string"
38
+ ? String(review.state).trim().toUpperCase()
39
+ : undefined;
40
+ if (state !== "CHANGES_REQUESTED")
41
+ continue;
42
+ const commitOid = typeof review.commit?.oid === "string"
43
+ ? String(review.commit.oid).trim()
44
+ : undefined;
45
+ if (commitOid)
46
+ return commitOid;
47
+ }
48
+ return undefined;
49
+ }
50
+ export function extractRequestedReviewerLogins(requests) {
51
+ if (!Array.isArray(requests)) {
52
+ return [];
53
+ }
54
+ const logins = requests.flatMap((request) => {
55
+ if (!request || typeof request !== "object") {
56
+ return [];
57
+ }
58
+ const direct = typeof request.login === "string"
59
+ ? String(request.login)
60
+ : undefined;
61
+ const nested = typeof request.requestedReviewer?.login === "string"
62
+ ? String(request.requestedReviewer.login)
63
+ : undefined;
64
+ return [direct, nested].filter((entry) => Boolean(entry)).map((entry) => entry.trim().toLowerCase());
65
+ });
66
+ return [...new Set(logins)];
67
+ }