patchrelay 0.38.0 → 0.38.2
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/dist/build-info.json +3 -3
- package/dist/cli/args.js +4 -0
- package/dist/cli/commands/issues.js +20 -1
- package/dist/cli/data.js +54 -7
- package/dist/cli/formatters/text.js +10 -0
- package/dist/cli/help.js +4 -0
- package/dist/cli/index.js +3 -0
- package/dist/config.js +26 -0
- package/dist/db/issue-store.js +10 -2
- package/dist/db/migrations.js +5 -0
- package/dist/factory-state.js +1 -0
- package/dist/github-linear-session-sync.js +57 -0
- package/dist/github-pr-comment-handler.js +74 -0
- package/dist/github-webhook-failure-context.js +70 -0
- package/dist/github-webhook-handler.js +52 -975
- package/dist/github-webhook-issue-resolution.js +46 -0
- package/dist/github-webhook-late-publication-guard.js +94 -0
- package/dist/github-webhook-policy.js +105 -0
- package/dist/github-webhook-reactive-run.js +302 -0
- package/dist/github-webhook-state-projector.js +245 -0
- package/dist/github-webhook-terminal-handler.js +111 -0
- package/dist/github-webhooks.js +39 -4
- package/dist/http.js +17 -0
- package/dist/idle-reconciliation.js +4 -2
- package/dist/issue-overview-query.js +8 -57
- package/dist/issue-session-events.js +1 -0
- package/dist/legacy-issue-overview.js +58 -0
- package/dist/linear-activity-key.js +11 -0
- package/dist/linear-agent-session-client.js +14 -1
- package/dist/linear-progress-reporter.js +7 -181
- package/dist/linear-status-comment-sync.js +3 -19
- package/dist/manual-issue-actions.js +37 -0
- package/dist/presentation-text.js +11 -1
- package/dist/prompting/patchrelay.js +8 -6
- package/dist/reactive-pr-state.js +65 -0
- package/dist/reactive-run-policy.js +35 -118
- package/dist/remote-pr-state.js +11 -0
- package/dist/run-budgets.js +12 -0
- package/dist/run-notification-handler.js +4 -0
- package/dist/run-orchestrator.js +28 -8
- package/dist/run-wake-planner.js +11 -10
- package/dist/service-issue-actions.js +80 -27
- package/dist/service.js +3 -0
- package/dist/webhooks/desired-stage-recorder.js +34 -10
- package/package.json +1 -1
|
@@ -1,185 +1,11 @@
|
|
|
1
|
-
import { sanitizeOperatorFacingCommand, sanitizeOperatorFacingText } from "./presentation-text.js";
|
|
2
|
-
const PROGRESS_THROTTLE_MS = 5_000;
|
|
3
|
-
const MAX_PROGRESS_TEXT_LENGTH = 220;
|
|
4
1
|
export class LinearProgressReporter {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
agentMessageProgressPublished = new Set();
|
|
11
|
-
constructor(db, emitActivity) {
|
|
12
|
-
this.db = db;
|
|
13
|
-
this.emitActivity = emitActivity;
|
|
2
|
+
constructor(_db, _emitActivity) { }
|
|
3
|
+
maybeEmitProgress(_notification, _run) {
|
|
4
|
+
// Keep routine Codex progress in local/operator surfaces rather than
|
|
5
|
+
// turning every planning or reasoning update into Linear thread chatter.
|
|
6
|
+
return;
|
|
14
7
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (!issue)
|
|
18
|
-
return;
|
|
19
|
-
const agentSentence = this.consumeAgentMessageSentence(notification, run);
|
|
20
|
-
const workingOn = resolveWorkingOnActivity(notification, agentSentence?.sentence);
|
|
21
|
-
if (workingOn && !this.workingOnPublishedRuns.has(run.id)) {
|
|
22
|
-
this.workingOnPublishedRuns.add(run.id);
|
|
23
|
-
void this.emitActivity(issue, workingOn);
|
|
24
|
-
}
|
|
25
|
-
const progress = resolveEphemeralProgressActivity(notification, agentSentence?.sentence);
|
|
26
|
-
if (!progress)
|
|
27
|
-
return;
|
|
28
|
-
if (!progress.bypassThrottle) {
|
|
29
|
-
const now = Date.now();
|
|
30
|
-
const lastEmit = this.progressThrottle.get(run.id) ?? 0;
|
|
31
|
-
if (now - lastEmit < PROGRESS_THROTTLE_MS)
|
|
32
|
-
return;
|
|
33
|
-
this.progressThrottle.set(run.id, now);
|
|
34
|
-
}
|
|
35
|
-
void this.emitActivity(issue, progress.activity, { ephemeral: true });
|
|
8
|
+
clearProgress(_runId) {
|
|
9
|
+
return;
|
|
36
10
|
}
|
|
37
|
-
clearProgress(runId) {
|
|
38
|
-
this.progressThrottle.delete(runId);
|
|
39
|
-
this.workingOnPublishedRuns.delete(runId);
|
|
40
|
-
for (const key of this.agentMessageBuffers.keys()) {
|
|
41
|
-
if (key.startsWith(`${runId}:`)) {
|
|
42
|
-
this.agentMessageBuffers.delete(key);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
for (const key of this.agentMessageProgressPublished) {
|
|
46
|
-
if (key.startsWith(`${runId}:`)) {
|
|
47
|
-
this.agentMessageProgressPublished.delete(key);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
consumeAgentMessageSentence(notification, run) {
|
|
52
|
-
const messageKey = resolveAgentMessageKey(notification, run);
|
|
53
|
-
if (!messageKey)
|
|
54
|
-
return undefined;
|
|
55
|
-
if (this.agentMessageProgressPublished.has(messageKey))
|
|
56
|
-
return undefined;
|
|
57
|
-
const delta = resolveAgentMessageDelta(notification);
|
|
58
|
-
if (delta) {
|
|
59
|
-
const previous = this.agentMessageBuffers.get(messageKey) ?? "";
|
|
60
|
-
const next = `${previous}${delta}`;
|
|
61
|
-
this.agentMessageBuffers.set(messageKey, next);
|
|
62
|
-
const sentence = extractFirstCompletedSentence(next);
|
|
63
|
-
if (!sentence)
|
|
64
|
-
return undefined;
|
|
65
|
-
this.agentMessageProgressPublished.add(messageKey);
|
|
66
|
-
return { sentence };
|
|
67
|
-
}
|
|
68
|
-
const completedText = resolveCompletedAgentMessageText(notification);
|
|
69
|
-
if (!completedText)
|
|
70
|
-
return undefined;
|
|
71
|
-
const sentence = extractFirstSentence(completedText);
|
|
72
|
-
if (!sentence)
|
|
73
|
-
return undefined;
|
|
74
|
-
this.agentMessageProgressPublished.add(messageKey);
|
|
75
|
-
return { sentence };
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
function resolveWorkingOnActivity(notification, agentSentence) {
|
|
79
|
-
const summary = resolveWorkingOnSummary(notification) ?? agentSentence;
|
|
80
|
-
if (!summary)
|
|
81
|
-
return undefined;
|
|
82
|
-
return { type: "response", body: `Working on: ${summary}` };
|
|
83
|
-
}
|
|
84
|
-
function resolveEphemeralProgressActivity(notification, agentSentence) {
|
|
85
|
-
if (notification.method === "item/started") {
|
|
86
|
-
const item = notification.params.item;
|
|
87
|
-
if (!item)
|
|
88
|
-
return undefined;
|
|
89
|
-
const type = typeof item.type === "string" ? item.type : undefined;
|
|
90
|
-
if (type === "commandExecution") {
|
|
91
|
-
const cmd = item.command;
|
|
92
|
-
const cmdStr = Array.isArray(cmd)
|
|
93
|
-
? sanitizeOperatorFacingCommand(cmd.map((part) => String(part)).join(" "))
|
|
94
|
-
: sanitizeOperatorFacingCommand(typeof cmd === "string" ? cmd : undefined);
|
|
95
|
-
return { activity: { type: "action", action: "Running", parameter: truncateProgressText(cmdStr ?? "command", 120) } };
|
|
96
|
-
}
|
|
97
|
-
if (type === "mcpToolCall") {
|
|
98
|
-
const server = typeof item.server === "string" ? item.server : "";
|
|
99
|
-
const tool = typeof item.tool === "string" ? item.tool : "";
|
|
100
|
-
return { activity: { type: "action", action: "Using", parameter: `${server}/${tool}` } };
|
|
101
|
-
}
|
|
102
|
-
if (type === "dynamicToolCall") {
|
|
103
|
-
const tool = typeof item.tool === "string" ? item.tool : "tool";
|
|
104
|
-
return { activity: { type: "action", action: "Using", parameter: tool } };
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (agentSentence) {
|
|
108
|
-
return {
|
|
109
|
-
activity: { type: "thought", body: agentSentence },
|
|
110
|
-
bypassThrottle: true,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
return undefined;
|
|
114
|
-
}
|
|
115
|
-
function resolveWorkingOnSummary(notification) {
|
|
116
|
-
if (notification.method !== "turn/plan/updated") {
|
|
117
|
-
return undefined;
|
|
118
|
-
}
|
|
119
|
-
const plan = notification.params.plan;
|
|
120
|
-
if (!Array.isArray(plan))
|
|
121
|
-
return undefined;
|
|
122
|
-
const ranked = plan
|
|
123
|
-
.map((entry) => entry)
|
|
124
|
-
.filter((entry) => typeof entry.step === "string" && entry.step.trim().length > 0)
|
|
125
|
-
.sort((a, b) => rankPlanStatus(a.status) - rankPlanStatus(b.status));
|
|
126
|
-
const first = ranked[0];
|
|
127
|
-
return summarizeProgressSentence(typeof first?.step === "string" ? first.step : undefined);
|
|
128
|
-
}
|
|
129
|
-
function rankPlanStatus(status) {
|
|
130
|
-
return status === "inProgress" ? 0
|
|
131
|
-
: status === "pending" ? 1
|
|
132
|
-
: status === "completed" ? 2
|
|
133
|
-
: 3;
|
|
134
|
-
}
|
|
135
|
-
function resolveAgentMessageKey(notification, run) {
|
|
136
|
-
if (notification.method === "item/agentMessage/delta") {
|
|
137
|
-
const itemId = typeof notification.params.itemId === "string" ? notification.params.itemId : undefined;
|
|
138
|
-
return itemId ? `${run.id}:${itemId}` : undefined;
|
|
139
|
-
}
|
|
140
|
-
if (notification.method === "item/completed") {
|
|
141
|
-
const item = notification.params.item;
|
|
142
|
-
const itemId = typeof item?.id === "string" ? item.id : undefined;
|
|
143
|
-
const itemType = typeof item?.type === "string" ? item.type : undefined;
|
|
144
|
-
return itemId && itemType === "agentMessage" ? `${run.id}:${itemId}` : undefined;
|
|
145
|
-
}
|
|
146
|
-
return undefined;
|
|
147
|
-
}
|
|
148
|
-
function resolveAgentMessageDelta(notification) {
|
|
149
|
-
if (notification.method !== "item/agentMessage/delta") {
|
|
150
|
-
return undefined;
|
|
151
|
-
}
|
|
152
|
-
return typeof notification.params.delta === "string" ? notification.params.delta : undefined;
|
|
153
|
-
}
|
|
154
|
-
function resolveCompletedAgentMessageText(notification) {
|
|
155
|
-
if (notification.method !== "item/completed") {
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
const item = notification.params.item;
|
|
159
|
-
if (!item || item.type !== "agentMessage")
|
|
160
|
-
return undefined;
|
|
161
|
-
return typeof item.text === "string" ? item.text : undefined;
|
|
162
|
-
}
|
|
163
|
-
function extractFirstSentence(text) {
|
|
164
|
-
const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
|
|
165
|
-
if (!sanitized)
|
|
166
|
-
return undefined;
|
|
167
|
-
const match = sanitized.match(/^(.+?[.!?])(?:\s|$)/);
|
|
168
|
-
return truncateProgressText((match?.[1] ?? sanitized).trim(), MAX_PROGRESS_TEXT_LENGTH);
|
|
169
|
-
}
|
|
170
|
-
function extractFirstCompletedSentence(text) {
|
|
171
|
-
const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
|
|
172
|
-
if (!sanitized)
|
|
173
|
-
return undefined;
|
|
174
|
-
const match = sanitized.match(/^(.+?[.!?])(?:\s|$)/);
|
|
175
|
-
return match?.[1] ? truncateProgressText(match[1].trim(), MAX_PROGRESS_TEXT_LENGTH) : undefined;
|
|
176
|
-
}
|
|
177
|
-
function summarizeProgressSentence(text) {
|
|
178
|
-
const summary = extractFirstSentence(text);
|
|
179
|
-
if (!summary)
|
|
180
|
-
return undefined;
|
|
181
|
-
return summary.endsWith(".") || summary.endsWith("!") || summary.endsWith("?") ? summary : `${summary}.`;
|
|
182
|
-
}
|
|
183
|
-
function truncateProgressText(text, maxLength) {
|
|
184
|
-
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3).trimEnd()}...`;
|
|
185
11
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { extractCompletionCheck } from "./completion-check.js";
|
|
2
|
+
import { isClosedPrState } from "./pr-state.js";
|
|
2
3
|
import { deriveIssueStatusNote } from "./status-note.js";
|
|
3
4
|
import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
|
|
4
|
-
import { isClosedPrState } from "./pr-state.js";
|
|
5
|
-
import { isUndelegatedPausedIssue } from "./paused-issue-state.js";
|
|
6
5
|
export async function syncVisibleStatusComment(params) {
|
|
7
6
|
const { db, issue, linear, logger, trackedIssue, options } = params;
|
|
8
7
|
try {
|
|
@@ -25,23 +24,8 @@ export async function syncVisibleStatusComment(params) {
|
|
|
25
24
|
logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear status comment");
|
|
26
25
|
}
|
|
27
26
|
}
|
|
28
|
-
export function shouldSyncVisibleIssueComment(
|
|
29
|
-
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
if (issue.sessionState === "waiting_input" || issue.sessionState === "failed"
|
|
33
|
-
|| issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
if (isUndelegatedPausedIssue(issue)) {
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
|
-
if ((issue.sessionState === "done" || issue.factoryState === "done")
|
|
40
|
-
&& ((issue.prNumber === undefined && !issue.prUrl)
|
|
41
|
-
|| isClosedPrState(issue.prState))) {
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
return false;
|
|
27
|
+
export function shouldSyncVisibleIssueComment(_issue, _hasAgentSession) {
|
|
28
|
+
return true;
|
|
45
29
|
}
|
|
46
30
|
function renderStatusComment(db, issue, trackedIssue, options) {
|
|
47
31
|
const activeRun = issue.activeRunId ? db.runs.getRunById(issue.activeRunId) : undefined;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { hasOpenPr } from "./pr-state.js";
|
|
2
|
+
export function resolveRetryTarget(params) {
|
|
3
|
+
if (params.prState === "merged") {
|
|
4
|
+
return { runType: "none", factoryState: "done" };
|
|
5
|
+
}
|
|
6
|
+
if (hasOpenPr(params.prNumber, params.prState) && params.lastGitHubFailureSource === "queue_eviction") {
|
|
7
|
+
return { runType: "queue_repair", factoryState: "repairing_queue" };
|
|
8
|
+
}
|
|
9
|
+
if (hasOpenPr(params.prNumber, params.prState)
|
|
10
|
+
&& (params.prCheckStatus === "failed" || params.prCheckStatus === "failure" || params.lastGitHubFailureSource === "branch_ci")) {
|
|
11
|
+
return { runType: "ci_repair", factoryState: "repairing_ci" };
|
|
12
|
+
}
|
|
13
|
+
if (hasOpenPr(params.prNumber, params.prState) && params.prReviewState === "changes_requested") {
|
|
14
|
+
return {
|
|
15
|
+
runType: params.pendingRunType === "branch_upkeep" || params.lastRunType === "branch_upkeep"
|
|
16
|
+
? "branch_upkeep"
|
|
17
|
+
: "review_fix",
|
|
18
|
+
factoryState: "changes_requested",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (hasOpenPr(params.prNumber, params.prState)) {
|
|
22
|
+
return { runType: "implementation", factoryState: "implementing" };
|
|
23
|
+
}
|
|
24
|
+
return { runType: "implementation", factoryState: "delegated" };
|
|
25
|
+
}
|
|
26
|
+
export function buildManualRetryAttemptReset(runType) {
|
|
27
|
+
if (runType === "ci_repair") {
|
|
28
|
+
return { ciRepairAttempts: 0 };
|
|
29
|
+
}
|
|
30
|
+
if (runType === "queue_repair") {
|
|
31
|
+
return { queueRepairAttempts: 0 };
|
|
32
|
+
}
|
|
33
|
+
if (runType === "review_fix" || runType === "branch_upkeep") {
|
|
34
|
+
return { reviewFixAttempts: 0 };
|
|
35
|
+
}
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
@@ -5,6 +5,16 @@ function unwrapShellWrappedCommand(text) {
|
|
|
5
5
|
.replace(/^(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+'([^`\n]+)'$/g, "$1")
|
|
6
6
|
.replace(/^(?:\/bin\/bash|bash|\/bin\/sh|sh)\s+-lc\s+"([^`\n]+)"$/g, "$1");
|
|
7
7
|
}
|
|
8
|
+
function stripLocalMarkdownLinks(text) {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/\[([^\]]+)\]\(<\/[^)>]+>\)/g, "`$1`")
|
|
11
|
+
.replace(/\[([^\]]+)\]\((\/[^)\s]+)\)/g, "`$1`");
|
|
12
|
+
}
|
|
13
|
+
function stripLocalAbsolutePaths(text) {
|
|
14
|
+
return text
|
|
15
|
+
.replace(/`\/(?:home|Users|private|tmp|var\/folders)\/[^`\n]+`/g, "`local path omitted`")
|
|
16
|
+
.replace(/(^|[\s(])\/(?:home|Users|private|tmp|var\/folders)\/[^\s)]+/g, "$1`local path omitted`");
|
|
17
|
+
}
|
|
8
18
|
export function sanitizeOperatorFacingCommand(command) {
|
|
9
19
|
const trimmed = command?.trim();
|
|
10
20
|
if (!trimmed) {
|
|
@@ -17,5 +27,5 @@ export function sanitizeOperatorFacingText(text) {
|
|
|
17
27
|
if (!trimmed) {
|
|
18
28
|
return undefined;
|
|
19
29
|
}
|
|
20
|
-
return unwrapShellWrappedCommand(trimmed);
|
|
30
|
+
return stripLocalAbsolutePaths(stripLocalMarkdownLinks(unwrapShellWrappedCommand(trimmed)));
|
|
21
31
|
}
|
|
@@ -184,12 +184,12 @@ function buildRequestedChangesContext(runType, context) {
|
|
|
184
184
|
const mode = resolveRequestedChangesMode(runType, context);
|
|
185
185
|
const lines = [];
|
|
186
186
|
if (mode === "branch_upkeep") {
|
|
187
|
-
lines.push("## Branch Upkeep After Requested Changes", "", "The requested review changes may already be addressed, but GitHub still shows the PR branch as behind or dirty against the base branch.", "Update the existing PR branch onto the latest base branch, resolve conflicts carefully, rerun the narrowest relevant verification, and push a newer head.", "Do not open a new PR.", "");
|
|
187
|
+
lines.push("## Branch Upkeep After Requested Changes", "", "Goal: restore merge readiness on the existing PR branch without regressing review or CI readiness.", "The requested review changes may already be addressed, but GitHub still shows the PR branch as behind or dirty against the base branch.", "Update the existing PR branch onto the latest base branch, resolve conflicts carefully, rerun the narrowest relevant verification, and push a newer head.", "Do not open a new PR.", "", "1. Refresh the latest remote branch and base branch state first.", "2. Rebase or merge onto the latest base branch and resolve conflicts in a way that preserves the branch's current intent and prior fixes.", "3. Audit the conflicted areas for semantic regressions, not just textual conflicts.", "4. Run focused verification for the touched areas and enough surrounding checks to regain confidence that the branch is still review-ready.", "5. Commit and push a newer head on the existing PR branch.", "6. Do not stop at 'conflicts resolved' if the resulting branch is no longer likely to pass review or CI.", "");
|
|
188
188
|
}
|
|
189
189
|
else {
|
|
190
190
|
const reviewer = typeof context?.reviewerName === "string" ? context.reviewerName : undefined;
|
|
191
191
|
const reviewBody = typeof context?.reviewBody === "string" ? context.reviewBody.trim() : "";
|
|
192
|
-
lines.push("## Review Changes Requested", "", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "", "", "1. Start with the structured review context below
|
|
192
|
+
lines.push("## Review Changes Requested", "", "Goal: restore review readiness on the existing PR branch, not merely patch the latest cited line.", "Treat the reviewer comments as evidence of what still makes the branch unready. Your job is to return the branch to a state that is likely to pass the next full review.", "", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "", "", "1. Start with the structured review context below, then inspect the current diff (`git diff origin/main`) and current code before deciding what still needs work.", "2. Infer the underlying concern or invariant behind the review feedback. Do not assume each comment is an isolated chore.", "3. For each review point: if already resolved on the current head, note why. If not, fix it. Then inspect adjacent code paths and flows that could fail for the same reason.", "4. If the structured review context looks incomplete, inspect the latest GitHub review threads directly before deciding you are done.", "5. Verify the branch as a whole for the relevant concern class: current review issue, nearby regressions, relevant tests, and compatibility with the latest base branch.", "6. Only finish when you believe the branch is review-ready again. If you cannot get it there, stop and surface the blocker clearly.", "7. Commit and push a newer head on the existing PR branch. Do not try to hand the same head back to review.", "8. GitHub review happens after the new head is pushed and CI is green. Do not use `gh pr edit --add-reviewer` as part of this workflow.", "");
|
|
193
193
|
appendStructuredReviewContext(lines, context);
|
|
194
194
|
}
|
|
195
195
|
return lines.join("\n").trim();
|
|
@@ -201,7 +201,8 @@ function buildCiRepairContext(context) {
|
|
|
201
201
|
return [
|
|
202
202
|
"## CI Repair",
|
|
203
203
|
"",
|
|
204
|
-
"
|
|
204
|
+
"Goal: restore CI readiness on the existing PR branch so the next full CI run is likely to pass.",
|
|
205
|
+
"A full CI iteration has settled failed on your PR. Start from the specific failing check/job/step below on the latest remote PR branch tip, but do not stop at a narrow patch if the same root cause is likely to fail other checks in the suite.",
|
|
205
206
|
snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "",
|
|
206
207
|
snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "",
|
|
207
208
|
snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "",
|
|
@@ -219,10 +220,11 @@ function buildCiRepairContext(context) {
|
|
|
219
220
|
: "",
|
|
220
221
|
"",
|
|
221
222
|
"Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.",
|
|
222
|
-
"Read the latest logs for the named failing check,
|
|
223
|
+
"Read the latest logs for the named failing check, identify the root cause, and check whether that same cause is likely to affect other jobs or checks.",
|
|
224
|
+
"Fix the root cause, not just the first visible symptom.",
|
|
223
225
|
"Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.",
|
|
224
|
-
"Run
|
|
225
|
-
"Do not open a new PR. Keep working on the existing branch until CI
|
|
226
|
+
"Run the narrowest local verification that gives real confidence for the suite, then commit and push.",
|
|
227
|
+
"Do not open a new PR. Keep working on the existing branch until the branch is likely to pass CI again or the situation is clearly stuck.",
|
|
226
228
|
"Do not change test expectations unless the test is genuinely wrong.",
|
|
227
229
|
].filter(Boolean).join("\n");
|
|
228
230
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readRemotePrState } from "./remote-pr-state.js";
|
|
2
|
+
export function isRequestedChangesRunType(runType) {
|
|
3
|
+
return runType === "review_fix" || runType === "branch_upkeep";
|
|
4
|
+
}
|
|
5
|
+
export function normalizeRemotePrState(value) {
|
|
6
|
+
const normalized = value?.trim().toUpperCase();
|
|
7
|
+
if (normalized === "OPEN")
|
|
8
|
+
return "open";
|
|
9
|
+
if (normalized === "CLOSED")
|
|
10
|
+
return "closed";
|
|
11
|
+
if (normalized === "MERGED")
|
|
12
|
+
return "merged";
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function normalizeRemoteReviewDecision(value) {
|
|
16
|
+
const normalized = value?.trim().toUpperCase();
|
|
17
|
+
if (normalized === "APPROVED")
|
|
18
|
+
return "approved";
|
|
19
|
+
if (normalized === "CHANGES_REQUESTED")
|
|
20
|
+
return "changes_requested";
|
|
21
|
+
if (normalized === "REVIEW_REQUIRED")
|
|
22
|
+
return "commented";
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
export function isDirtyMergeStateStatus(value) {
|
|
26
|
+
return value?.trim().toUpperCase() === "DIRTY";
|
|
27
|
+
}
|
|
28
|
+
export function buildReviewFixBranchUpkeepContext(prNumber, baseBranch, pr, context) {
|
|
29
|
+
const promptContext = [
|
|
30
|
+
`The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${String(pr.mergeStateStatus)} against latest ${baseBranch}.`,
|
|
31
|
+
`This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
|
|
32
|
+
"Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
|
|
33
|
+
].join(" ");
|
|
34
|
+
return {
|
|
35
|
+
...(context ?? {}),
|
|
36
|
+
branchUpkeepRequired: true,
|
|
37
|
+
reviewFixMode: "branch_upkeep",
|
|
38
|
+
wakeReason: "branch_upkeep",
|
|
39
|
+
promptContext,
|
|
40
|
+
...(pr.mergeStateStatus ? { mergeStateStatus: pr.mergeStateStatus } : {}),
|
|
41
|
+
...(pr.headRefOid ? { failingHeadSha: pr.headRefOid } : {}),
|
|
42
|
+
baseBranch,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function readReactivePrSnapshot(config, projectId, prNumber) {
|
|
46
|
+
const project = config.projects.find((entry) => entry.id === projectId);
|
|
47
|
+
const repoFullName = project?.github?.repoFullName;
|
|
48
|
+
if (!repoFullName) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const pr = await readRemotePrState(repoFullName, prNumber);
|
|
52
|
+
if (!pr) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
projectId,
|
|
57
|
+
repoFullName,
|
|
58
|
+
baseBranch: project?.github?.baseBranch ?? "main",
|
|
59
|
+
pr,
|
|
60
|
+
prState: normalizeRemotePrState(pr.state),
|
|
61
|
+
reviewState: normalizeRemoteReviewDecision(pr.reviewDecision),
|
|
62
|
+
headSha: pr.headRefOid,
|
|
63
|
+
gateCheckName: project?.gateChecks?.find((entry) => entry.trim())?.trim() ?? "verify",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -1,47 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
function isRequestedChangesRunType(runType) {
|
|
3
|
-
return runType === "review_fix" || runType === "branch_upkeep";
|
|
4
|
-
}
|
|
5
|
-
function normalizeRemotePrState(value) {
|
|
6
|
-
const normalized = value?.trim().toUpperCase();
|
|
7
|
-
if (normalized === "OPEN")
|
|
8
|
-
return "open";
|
|
9
|
-
if (normalized === "CLOSED")
|
|
10
|
-
return "closed";
|
|
11
|
-
if (normalized === "MERGED")
|
|
12
|
-
return "merged";
|
|
13
|
-
return undefined;
|
|
14
|
-
}
|
|
15
|
-
function normalizeRemoteReviewDecision(value) {
|
|
16
|
-
const normalized = value?.trim().toUpperCase();
|
|
17
|
-
if (normalized === "APPROVED")
|
|
18
|
-
return "approved";
|
|
19
|
-
if (normalized === "CHANGES_REQUESTED")
|
|
20
|
-
return "changes_requested";
|
|
21
|
-
if (normalized === "REVIEW_REQUIRED")
|
|
22
|
-
return "commented";
|
|
23
|
-
return undefined;
|
|
24
|
-
}
|
|
25
|
-
function isDirtyMergeStateStatus(value) {
|
|
26
|
-
return value?.trim().toUpperCase() === "DIRTY";
|
|
27
|
-
}
|
|
28
|
-
function buildReviewFixBranchUpkeepContext(prNumber, baseBranch, pr, context) {
|
|
29
|
-
const promptContext = [
|
|
30
|
-
`The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${String(pr.mergeStateStatus)} against latest ${baseBranch}.`,
|
|
31
|
-
`This turn is branch upkeep on the existing PR branch: update onto latest ${baseBranch}, resolve any conflicts, rerun the narrowest relevant verification, and push a newer head.`,
|
|
32
|
-
"Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
|
|
33
|
-
].join(" ");
|
|
34
|
-
return {
|
|
35
|
-
...(context ?? {}),
|
|
36
|
-
branchUpkeepRequired: true,
|
|
37
|
-
reviewFixMode: "branch_upkeep",
|
|
38
|
-
wakeReason: "branch_upkeep",
|
|
39
|
-
promptContext,
|
|
40
|
-
...(pr.mergeStateStatus ? { mergeStateStatus: pr.mergeStateStatus } : {}),
|
|
41
|
-
...(pr.headRefOid ? { failingHeadSha: pr.headRefOid } : {}),
|
|
42
|
-
baseBranch,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
1
|
+
import { buildReviewFixBranchUpkeepContext, isDirtyMergeStateStatus, isRequestedChangesRunType, readReactivePrSnapshot, } from "./reactive-pr-state.js";
|
|
45
2
|
export class ReactiveRunPolicy {
|
|
46
3
|
config;
|
|
47
4
|
db;
|
|
@@ -60,15 +17,11 @@ export class ReactiveRunPolicy {
|
|
|
60
17
|
if (!issue.prNumber || issue.prState !== "open" || !issue.lastGitHubFailureHeadSha) {
|
|
61
18
|
return undefined;
|
|
62
19
|
}
|
|
63
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
64
|
-
if (!project?.github?.repoFullName) {
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
20
|
try {
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
21
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
22
|
+
if (!snapshot || snapshot.prState !== "open")
|
|
70
23
|
return undefined;
|
|
71
|
-
if (!
|
|
24
|
+
if (!snapshot.headSha || snapshot.headSha !== issue.lastGitHubFailureHeadSha)
|
|
72
25
|
return undefined;
|
|
73
26
|
return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
|
|
74
27
|
}
|
|
@@ -91,18 +44,14 @@ export class ReactiveRunPolicy {
|
|
|
91
44
|
if (!run.sourceHeadSha) {
|
|
92
45
|
return `Requested-changes run finished for PR #${issue.prNumber} without a recorded starting head SHA. PatchRelay cannot verify that a new head was published.`;
|
|
93
46
|
}
|
|
94
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
95
|
-
if (!project?.github?.repoFullName) {
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
47
|
try {
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
48
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
49
|
+
if (!snapshot || snapshot.prState !== "open")
|
|
101
50
|
return undefined;
|
|
102
|
-
if (!
|
|
51
|
+
if (!snapshot.headSha) {
|
|
103
52
|
return `Requested-changes run finished for PR #${issue.prNumber} but GitHub did not report a current head SHA.`;
|
|
104
53
|
}
|
|
105
|
-
if (
|
|
54
|
+
if (snapshot.headSha === run.sourceHeadSha) {
|
|
106
55
|
return `Requested-changes run finished for PR #${issue.prNumber} without pushing a new head; PatchRelay must not hand the same SHA back to review.`;
|
|
107
56
|
}
|
|
108
57
|
return undefined;
|
|
@@ -123,28 +72,20 @@ export class ReactiveRunPolicy {
|
|
|
123
72
|
if (!issue.prNumber) {
|
|
124
73
|
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
125
74
|
}
|
|
126
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
127
|
-
const repoFullName = project?.github?.repoFullName;
|
|
128
|
-
if (!repoFullName) {
|
|
129
|
-
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
130
|
-
}
|
|
131
75
|
try {
|
|
132
|
-
const
|
|
133
|
-
if (!
|
|
76
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
77
|
+
if (!snapshot) {
|
|
134
78
|
return this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
|
|
135
79
|
}
|
|
136
|
-
const
|
|
137
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
138
|
-
const gateCheckName = project?.gateChecks?.find((entry) => entry.trim())?.trim() ?? "verify";
|
|
139
|
-
const headAdvanced = Boolean(pr.headRefOid && pr.headRefOid !== issue.lastGitHubFailureHeadSha);
|
|
80
|
+
const headAdvanced = Boolean(snapshot.headSha && snapshot.headSha !== issue.lastGitHubFailureHeadSha);
|
|
140
81
|
const reviewFixHeadAdvanced = isRequestedChangesRunType(run.runType)
|
|
141
|
-
&& Boolean(
|
|
82
|
+
&& Boolean(snapshot.headSha && run.sourceHeadSha && snapshot.headSha !== run.sourceHeadSha);
|
|
142
83
|
this.upsertIssueIfLeaseHeld(run.projectId, run.linearIssueId, {
|
|
143
84
|
projectId: run.projectId,
|
|
144
85
|
linearIssueId: run.linearIssueId,
|
|
145
|
-
...(
|
|
146
|
-
...(
|
|
147
|
-
...(
|
|
86
|
+
...(snapshot.prState ? { prState: snapshot.prState } : {}),
|
|
87
|
+
...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
|
|
88
|
+
...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
|
|
148
89
|
...((headAdvanced || reviewFixHeadAdvanced)
|
|
149
90
|
? {
|
|
150
91
|
prCheckStatus: "pending",
|
|
@@ -158,8 +99,8 @@ export class ReactiveRunPolicy {
|
|
|
158
99
|
lastQueueIncidentJson: null,
|
|
159
100
|
lastAttemptedFailureHeadSha: null,
|
|
160
101
|
lastAttemptedFailureSignature: null,
|
|
161
|
-
lastGitHubCiSnapshotHeadSha:
|
|
162
|
-
lastGitHubCiSnapshotGateCheckName: gateCheckName,
|
|
102
|
+
lastGitHubCiSnapshotHeadSha: snapshot.headSha ?? null,
|
|
103
|
+
lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName,
|
|
163
104
|
lastGitHubCiSnapshotGateCheckStatus: "pending",
|
|
164
105
|
lastGitHubCiSnapshotJson: null,
|
|
165
106
|
lastGitHubCiSnapshotSettledAt: null,
|
|
@@ -183,31 +124,24 @@ export class ReactiveRunPolicy {
|
|
|
183
124
|
if (!issue.prNumber || issue.prState !== "open" || issue.prReviewState !== "changes_requested") {
|
|
184
125
|
return context;
|
|
185
126
|
}
|
|
186
|
-
const project = this.config.projects.find((entry) => entry.id === issue.projectId);
|
|
187
|
-
const repoFullName = project?.github?.repoFullName;
|
|
188
|
-
if (!repoFullName) {
|
|
189
|
-
return context;
|
|
190
|
-
}
|
|
191
127
|
try {
|
|
192
|
-
const
|
|
193
|
-
if (!
|
|
128
|
+
const snapshot = await readReactivePrSnapshot(this.config, issue.projectId, issue.prNumber);
|
|
129
|
+
if (!snapshot)
|
|
194
130
|
return context;
|
|
195
|
-
const nextPrState = normalizeRemotePrState(pr.state);
|
|
196
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
197
131
|
this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
|
|
198
132
|
projectId: issue.projectId,
|
|
199
133
|
linearIssueId: issue.linearIssueId,
|
|
200
|
-
...(
|
|
201
|
-
...(
|
|
202
|
-
...(
|
|
134
|
+
...(snapshot.prState ? { prState: snapshot.prState } : {}),
|
|
135
|
+
...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
|
|
136
|
+
...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
|
|
203
137
|
}, "review-fix wake refresh");
|
|
204
|
-
if (
|
|
138
|
+
if (snapshot.prState !== "open")
|
|
205
139
|
return context;
|
|
206
|
-
if (
|
|
140
|
+
if (snapshot.reviewState && snapshot.reviewState !== "changes_requested")
|
|
207
141
|
return context;
|
|
208
|
-
if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
|
|
142
|
+
if (!isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus))
|
|
209
143
|
return context;
|
|
210
|
-
return buildReviewFixBranchUpkeepContext(issue.prNumber,
|
|
144
|
+
return buildReviewFixBranchUpkeepContext(issue.prNumber, snapshot.baseBranch, snapshot.pr, context);
|
|
211
145
|
}
|
|
212
146
|
catch (error) {
|
|
213
147
|
this.logger.debug({
|
|
@@ -228,34 +162,27 @@ export class ReactiveRunPolicy {
|
|
|
228
162
|
if (issue.prReviewState !== "changes_requested") {
|
|
229
163
|
return undefined;
|
|
230
164
|
}
|
|
231
|
-
const project = this.config.projects.find((entry) => entry.id === run.projectId);
|
|
232
|
-
const repoFullName = project?.github?.repoFullName;
|
|
233
|
-
if (!repoFullName) {
|
|
234
|
-
return undefined;
|
|
235
|
-
}
|
|
236
165
|
try {
|
|
237
|
-
const
|
|
238
|
-
if (!
|
|
166
|
+
const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
|
|
167
|
+
if (!snapshot)
|
|
239
168
|
return undefined;
|
|
240
|
-
const nextPrState = normalizeRemotePrState(pr.state);
|
|
241
|
-
const nextReviewState = normalizeRemoteReviewDecision(pr.reviewDecision);
|
|
242
169
|
this.upsertIssueIfLeaseHeld(issue.projectId, issue.linearIssueId, {
|
|
243
170
|
projectId: issue.projectId,
|
|
244
171
|
linearIssueId: issue.linearIssueId,
|
|
245
|
-
...(
|
|
246
|
-
...(
|
|
247
|
-
...(
|
|
172
|
+
...(snapshot.prState ? { prState: snapshot.prState } : {}),
|
|
173
|
+
...(snapshot.headSha ? { prHeadSha: snapshot.headSha } : {}),
|
|
174
|
+
...(snapshot.reviewState ? { prReviewState: snapshot.reviewState } : {}),
|
|
248
175
|
}, "post-run follow-up refresh");
|
|
249
|
-
if (
|
|
176
|
+
if (snapshot.prState !== "open")
|
|
250
177
|
return undefined;
|
|
251
|
-
if (
|
|
178
|
+
if (snapshot.reviewState && snapshot.reviewState !== "changes_requested")
|
|
252
179
|
return undefined;
|
|
253
|
-
if (!isDirtyMergeStateStatus(pr.mergeStateStatus))
|
|
180
|
+
if (!isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus))
|
|
254
181
|
return undefined;
|
|
255
182
|
return {
|
|
256
183
|
pendingRunType: "branch_upkeep",
|
|
257
184
|
factoryState: "changes_requested",
|
|
258
|
-
context: buildReviewFixBranchUpkeepContext(issue.prNumber,
|
|
185
|
+
context: buildReviewFixBranchUpkeepContext(issue.prNumber, snapshot.baseBranch, snapshot.pr),
|
|
259
186
|
summary: `PR #${issue.prNumber} is still dirty after review fix; queued branch upkeep`,
|
|
260
187
|
};
|
|
261
188
|
}
|
|
@@ -268,16 +195,6 @@ export class ReactiveRunPolicy {
|
|
|
268
195
|
return undefined;
|
|
269
196
|
}
|
|
270
197
|
}
|
|
271
|
-
async loadRemotePrState(repoFullName, prNumber) {
|
|
272
|
-
const { stdout, exitCode } = await execCommand("gh", [
|
|
273
|
-
"pr", "view", String(prNumber),
|
|
274
|
-
"--repo", repoFullName,
|
|
275
|
-
"--json", "headRefOid,state,reviewDecision,mergeStateStatus",
|
|
276
|
-
], { timeoutMs: 10_000 });
|
|
277
|
-
if (exitCode !== 0)
|
|
278
|
-
return undefined;
|
|
279
|
-
return JSON.parse(stdout);
|
|
280
|
-
}
|
|
281
198
|
upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
|
|
282
199
|
const updated = this.withHeldLease(projectId, linearIssueId, (lease) => this.db.issueSessions.upsertIssueWithLease(lease, params));
|
|
283
200
|
if (updated === undefined) {
|