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.
Files changed (45) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/args.js +4 -0
  3. package/dist/cli/commands/issues.js +20 -1
  4. package/dist/cli/data.js +54 -7
  5. package/dist/cli/formatters/text.js +10 -0
  6. package/dist/cli/help.js +4 -0
  7. package/dist/cli/index.js +3 -0
  8. package/dist/config.js +26 -0
  9. package/dist/db/issue-store.js +10 -2
  10. package/dist/db/migrations.js +5 -0
  11. package/dist/factory-state.js +1 -0
  12. package/dist/github-linear-session-sync.js +57 -0
  13. package/dist/github-pr-comment-handler.js +74 -0
  14. package/dist/github-webhook-failure-context.js +70 -0
  15. package/dist/github-webhook-handler.js +52 -975
  16. package/dist/github-webhook-issue-resolution.js +46 -0
  17. package/dist/github-webhook-late-publication-guard.js +94 -0
  18. package/dist/github-webhook-policy.js +105 -0
  19. package/dist/github-webhook-reactive-run.js +302 -0
  20. package/dist/github-webhook-state-projector.js +245 -0
  21. package/dist/github-webhook-terminal-handler.js +111 -0
  22. package/dist/github-webhooks.js +39 -4
  23. package/dist/http.js +17 -0
  24. package/dist/idle-reconciliation.js +4 -2
  25. package/dist/issue-overview-query.js +8 -57
  26. package/dist/issue-session-events.js +1 -0
  27. package/dist/legacy-issue-overview.js +58 -0
  28. package/dist/linear-activity-key.js +11 -0
  29. package/dist/linear-agent-session-client.js +14 -1
  30. package/dist/linear-progress-reporter.js +7 -181
  31. package/dist/linear-status-comment-sync.js +3 -19
  32. package/dist/manual-issue-actions.js +37 -0
  33. package/dist/presentation-text.js +11 -1
  34. package/dist/prompting/patchrelay.js +8 -6
  35. package/dist/reactive-pr-state.js +65 -0
  36. package/dist/reactive-run-policy.js +35 -118
  37. package/dist/remote-pr-state.js +11 -0
  38. package/dist/run-budgets.js +12 -0
  39. package/dist/run-notification-handler.js +4 -0
  40. package/dist/run-orchestrator.js +28 -8
  41. package/dist/run-wake-planner.js +11 -10
  42. package/dist/service-issue-actions.js +80 -27
  43. package/dist/service.js +3 -0
  44. package/dist/webhooks/desired-stage-recorder.js +34 -10
  45. 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
- db;
6
- emitActivity;
7
- progressThrottle = new Map();
8
- workingOnPublishedRuns = new Set();
9
- agentMessageBuffers = new Map();
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
- maybeEmitProgress(notification, run) {
16
- const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
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(issue, hasAgentSession) {
29
- if (!hasAgentSession) {
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. Treat the inline review comments as the primary repair checklist for this turn.", "2. Check the current diff (`git diff origin/main`) a prior rebase may have already resolved some concerns.", "3. For each review point: if already resolved on the current head, note why. If not, fix it.", "4. If the structured review context looks incomplete, inspect the latest GitHub review threads directly before deciding you are done.", "5. Run verification, commit, and push a newer head on the existing PR branch.", "6. Do not try to hand the same head back to review. If you cannot produce a new pushed head, stop and surface the blocker clearly.", "7. 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.", "");
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
- "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, fix that concrete failure first, then push to the same PR branch.",
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, fix that root cause, and only broaden scope when the logs show direct fallout from the same issue.",
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 focused verification for the named failure, then commit and push.",
225
- "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.",
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 { execCommand } from "./utils.js";
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 pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
69
- if (!pr || pr.state?.toUpperCase() !== "OPEN")
21
+ const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
22
+ if (!snapshot || snapshot.prState !== "open")
70
23
  return undefined;
71
- if (!pr.headRefOid || pr.headRefOid !== issue.lastGitHubFailureHeadSha)
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 pr = await this.loadRemotePrState(project.github.repoFullName, issue.prNumber);
100
- if (!pr || pr.state?.toUpperCase() !== "OPEN")
48
+ const snapshot = await readReactivePrSnapshot(this.config, run.projectId, issue.prNumber);
49
+ if (!snapshot || snapshot.prState !== "open")
101
50
  return undefined;
102
- if (!pr.headRefOid) {
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 (pr.headRefOid === run.sourceHeadSha) {
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 pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
133
- if (!pr) {
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 nextPrState = normalizeRemotePrState(pr.state);
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(pr.headRefOid && run.sourceHeadSha && pr.headRefOid !== run.sourceHeadSha);
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
- ...(nextPrState ? { prState: nextPrState } : {}),
146
- ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
147
- ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
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: pr.headRefOid ?? null,
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 pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
193
- if (!pr)
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
- ...(nextPrState ? { prState: nextPrState } : {}),
201
- ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
202
- ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
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 (nextPrState !== "open")
138
+ if (snapshot.prState !== "open")
205
139
  return context;
206
- if (nextReviewState && nextReviewState !== "changes_requested")
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, project?.github?.baseBranch ?? "main", pr, context);
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 pr = await this.loadRemotePrState(repoFullName, issue.prNumber);
238
- if (!pr)
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
- ...(nextPrState ? { prState: nextPrState } : {}),
246
- ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
247
- ...(nextReviewState ? { prReviewState: nextReviewState } : {}),
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 (nextPrState !== "open")
176
+ if (snapshot.prState !== "open")
250
177
  return undefined;
251
- if (nextReviewState && nextReviewState !== "changes_requested")
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, project?.github?.baseBranch ?? "main", pr),
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) {