patchrelay 0.68.3 → 0.68.5

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.
@@ -0,0 +1,40 @@
1
+ function normalizedReviewDecision(value) {
2
+ return value?.trim().toUpperCase();
3
+ }
4
+ function reviewDecisionToPrReviewState(value) {
5
+ const normalized = normalizedReviewDecision(value);
6
+ if (normalized === "APPROVED")
7
+ return "approved";
8
+ if (normalized === "CHANGES_REQUESTED")
9
+ return "changes_requested";
10
+ if (normalized === "REVIEW_REQUIRED")
11
+ return "commented";
12
+ return undefined;
13
+ }
14
+ /**
15
+ * Pure projection from a fresh `gh pr view` snapshot into the issue-row
16
+ * fields that should be written back. Keeps the snapshot row in sync with
17
+ * GitHub truth and records a CI snapshot row (for the queue-health monitor
18
+ * and the operator feed) when both the head SHA and a gate check status
19
+ * are observable.
20
+ *
21
+ * Settled-at is null when the gate check is still pending so callers can
22
+ * tell a freshly-observed "in progress" from a recently-settled result.
23
+ */
24
+ export function buildPrStateUpdates(pr, gateCheckStatus, primaryGateCheckName, now = () => new Date()) {
25
+ const prReviewState = reviewDecisionToPrReviewState(pr.reviewDecision);
26
+ return {
27
+ ...(pr.headRefOid ? { prHeadSha: pr.headRefOid } : {}),
28
+ ...(pr.state === "OPEN" ? { prState: "open" } : {}),
29
+ ...(prReviewState ? { prReviewState } : {}),
30
+ ...(gateCheckStatus ? { prCheckStatus: gateCheckStatus } : {}),
31
+ ...(pr.headRefOid && gateCheckStatus
32
+ ? {
33
+ lastGitHubCiSnapshotHeadSha: pr.headRefOid,
34
+ lastGitHubCiSnapshotGateCheckName: primaryGateCheckName,
35
+ lastGitHubCiSnapshotGateCheckStatus: gateCheckStatus,
36
+ lastGitHubCiSnapshotSettledAt: gateCheckStatus === "pending" ? null : now().toISOString(),
37
+ }
38
+ : {}),
39
+ };
40
+ }
@@ -134,7 +134,7 @@ export class RunOrchestrator {
134
134
  this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
135
135
  this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
136
136
  this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
137
- this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, feed);
137
+ this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, (projectId) => this.config.projects.find((project) => project.id === projectId)?.github?.repoFullName, feed);
138
138
  this.runWakePlanner = new RunWakePlanner(db);
139
139
  this.idleReconciler = new IdleIssueReconciler(db, config, this.wakeDispatcher, logger, feed);
140
140
  this.mainBranchHealthMonitor = new MainBranchHealthMonitor(db, config, linearProvider, this.wakeDispatcher, logger, feed);
@@ -6,6 +6,7 @@ import { getThreadTurns } from "./codex-thread-utils.js";
6
6
  import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
7
7
  import { resolveEffectiveActiveRun } from "./effective-active-run.js";
8
8
  import { isThreadMaterializingError } from "./codex-thread-errors.js";
9
+ import { fetchPullRequestSnapshot } from "./reconcile-pr-fetch.js";
9
10
  const THREAD_MATERIALIZATION_GRACE_MS = 10 * 60_000;
10
11
  function isWithinThreadMaterializationGrace(run, nowMs = Date.now()) {
11
12
  const startedAtMs = Date.parse(run.startedAt);
@@ -24,8 +25,9 @@ export class RunReconciler {
24
25
  releaseLease;
25
26
  readThreadWithRetry;
26
27
  recoverOrEscalate;
28
+ resolveRepoFullName;
27
29
  feed;
28
- constructor(db, logger, linearProvider, linearSync, interruptedRunRecovery, runFinalizer, withHeldLease, releaseLease, readThreadWithRetry, recoverOrEscalate, feed) {
30
+ constructor(db, logger, linearProvider, linearSync, interruptedRunRecovery, runFinalizer, withHeldLease, releaseLease, readThreadWithRetry, recoverOrEscalate, resolveRepoFullName = () => undefined, feed) {
29
31
  this.db = db;
30
32
  this.logger = logger;
31
33
  this.linearProvider = linearProvider;
@@ -36,6 +38,7 @@ export class RunReconciler {
36
38
  this.releaseLease = releaseLease;
37
39
  this.readThreadWithRetry = readThreadWithRetry;
38
40
  this.recoverOrEscalate = recoverOrEscalate;
41
+ this.resolveRepoFullName = resolveRepoFullName;
39
42
  this.feed = feed;
40
43
  }
41
44
  async reconcile(params) {
@@ -76,6 +79,9 @@ export class RunReconciler {
76
79
  this.releaseLease(run.projectId, run.linearIssueId);
77
80
  return;
78
81
  }
82
+ if (await this.releaseRunIfPullRequestMerged(run, effectiveIssue)) {
83
+ return;
84
+ }
79
85
  if (!run.threadId) {
80
86
  if (recoveryLease === "owned") {
81
87
  this.logger.debug({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType }, "Skipping zombie reconciliation for locally-owned launch that has not created a thread yet");
@@ -171,6 +177,56 @@ export class RunReconciler {
171
177
  this.releaseLease(run.projectId, run.linearIssueId);
172
178
  }
173
179
  }
180
+ async releaseRunIfPullRequestMerged(run, issue) {
181
+ if (issue.prNumber === undefined)
182
+ return false;
183
+ if (issue.prState === "merged") {
184
+ this.releaseMergedRun(run, issue, "Cached PR state is merged");
185
+ return true;
186
+ }
187
+ const repoFullName = this.resolveRepoFullName(issue.projectId);
188
+ if (!repoFullName)
189
+ return false;
190
+ const snapshot = await fetchPullRequestSnapshot(repoFullName, issue.prNumber);
191
+ if (!snapshot.ok) {
192
+ this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, error: snapshot.error.message }, "Could not refresh active-run PR state during reconciliation");
193
+ return false;
194
+ }
195
+ if (snapshot.pr.state !== "MERGED")
196
+ return false;
197
+ this.releaseMergedRun(run, issue, "Pull request merged while the active Codex run was still marked running");
198
+ return true;
199
+ }
200
+ releaseMergedRun(run, issue, reason) {
201
+ this.withHeldLease(run.projectId, run.linearIssueId, () => {
202
+ this.db.issueSessions.clearPendingIssueSessionEvents(run.projectId, run.linearIssueId);
203
+ this.db.runs.finishRun(run.id, {
204
+ status: "released",
205
+ failureReason: reason,
206
+ });
207
+ this.db.issues.upsertIssue({
208
+ projectId: run.projectId,
209
+ linearIssueId: run.linearIssueId,
210
+ activeRunId: null,
211
+ factoryState: "done",
212
+ prState: "merged",
213
+ pendingRunType: null,
214
+ pendingRunContextJson: null,
215
+ });
216
+ });
217
+ this.feed?.publish({
218
+ level: "info",
219
+ kind: "stage",
220
+ issueKey: issue.issueKey,
221
+ projectId: run.projectId,
222
+ stage: "done",
223
+ status: "reconciled",
224
+ summary: `Released active ${run.runType} run after PR merge`,
225
+ });
226
+ const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
227
+ void this.linearSync.syncSession(doneIssue, { activeRunType: run.runType });
228
+ this.releaseLease(run.projectId, run.linearIssueId);
229
+ }
174
230
  async confirmDelegationAuthorityBeforeRelease(run, issue) {
175
231
  const installation = this.db.linearInstallations.getLinearInstallationForProject(run.projectId);
176
232
  const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.68.3",
3
+ "version": "0.68.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {