patchrelay 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.63.0",
4
- "commit": "e265b8a2a40d",
5
- "builtAt": "2026-05-04T22:32:17.463Z"
3
+ "version": "0.65.0",
4
+ "commit": "1b180d2544aa",
5
+ "builtAt": "2026-05-04T22:53:34.027Z"
6
6
  }
@@ -212,6 +212,18 @@ export class IssueStore {
212
212
  sets.push("last_attempted_failure_at = @lastAttemptedFailureAt");
213
213
  values.lastAttemptedFailureAt = params.lastAttemptedFailureAt;
214
214
  }
215
+ if (params.lastPublishedPatchId !== undefined) {
216
+ sets.push("last_published_patch_id = @lastPublishedPatchId");
217
+ values.lastPublishedPatchId = params.lastPublishedPatchId;
218
+ }
219
+ if (params.lastPublishedIntegrationTreeId !== undefined) {
220
+ sets.push("last_published_integration_tree_id = @lastPublishedIntegrationTreeId");
221
+ values.lastPublishedIntegrationTreeId = params.lastPublishedIntegrationTreeId;
222
+ }
223
+ if (params.lastPublishedHeadSha !== undefined) {
224
+ sets.push("last_published_head_sha = @lastPublishedHeadSha");
225
+ values.lastPublishedHeadSha = params.lastPublishedHeadSha;
226
+ }
215
227
  if (params.ciRepairAttempts !== undefined) {
216
228
  sets.push("ci_repair_attempts = @ciRepairAttempts");
217
229
  values.ciRepairAttempts = params.ciRepairAttempts;
@@ -251,6 +263,7 @@ export class IssueStore {
251
263
  last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
252
264
  last_queue_signal_at, last_queue_incident_json,
253
265
  last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
266
+ last_published_patch_id, last_published_integration_tree_id, last_published_head_sha,
254
267
  ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at, orchestration_settle_until,
255
268
  updated_at
256
269
  ) VALUES (
@@ -264,6 +277,7 @@ export class IssueStore {
264
277
  @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
265
278
  @lastQueueSignalAt, @lastQueueIncidentJson,
266
279
  @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
280
+ @lastPublishedPatchId, @lastPublishedIntegrationTreeId, @lastPublishedHeadSha,
267
281
  @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt, @orchestrationSettleUntil,
268
282
  @now
269
283
  )
@@ -319,6 +333,9 @@ export class IssueStore {
319
333
  lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
320
334
  lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
321
335
  lastAttemptedFailureAt: params.lastAttemptedFailureAt ?? null,
336
+ lastPublishedPatchId: params.lastPublishedPatchId ?? null,
337
+ lastPublishedIntegrationTreeId: params.lastPublishedIntegrationTreeId ?? null,
338
+ lastPublishedHeadSha: params.lastPublishedHeadSha ?? null,
322
339
  ciRepairAttempts: params.ciRepairAttempts ?? 0,
323
340
  queueRepairAttempts: params.queueRepairAttempts ?? 0,
324
341
  reviewFixAttempts: params.reviewFixAttempts ?? 0,
@@ -705,6 +722,15 @@ export function mapIssueRow(row) {
705
722
  ...(row.last_attempted_failure_at !== null && row.last_attempted_failure_at !== undefined
706
723
  ? { lastAttemptedFailureAt: String(row.last_attempted_failure_at) }
707
724
  : {}),
725
+ ...(row.last_published_patch_id !== null && row.last_published_patch_id !== undefined
726
+ ? { lastPublishedPatchId: String(row.last_published_patch_id) }
727
+ : {}),
728
+ ...(row.last_published_integration_tree_id !== null && row.last_published_integration_tree_id !== undefined
729
+ ? { lastPublishedIntegrationTreeId: String(row.last_published_integration_tree_id) }
730
+ : {}),
731
+ ...(row.last_published_head_sha !== null && row.last_published_head_sha !== undefined
732
+ ? { lastPublishedHeadSha: String(row.last_published_head_sha) }
733
+ : {}),
708
734
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
709
735
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
710
736
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
@@ -333,6 +333,14 @@ export function runPatchRelayMigrations(connection) {
333
333
  addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
334
334
  addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
335
335
  addColumnIfMissing(connection, "issues", "last_attempted_failure_at", "TEXT");
336
+ // Plan §4.1: track the last published change identity so future
337
+ // runs can detect patch-id-equivalent re-publishes (no-op pushes).
338
+ // Currently observability-only — populated when patchrelay observes
339
+ // a push it can attribute to itself; consumers (prompt assembly,
340
+ // post-hoc detection) layer in follow-up PRs.
341
+ addColumnIfMissing(connection, "issues", "last_published_patch_id", "TEXT");
342
+ addColumnIfMissing(connection, "issues", "last_published_integration_tree_id", "TEXT");
343
+ addColumnIfMissing(connection, "issues", "last_published_head_sha", "TEXT");
336
344
  addColumnIfMissing(connection, "linear_installations", "health_status", "TEXT NOT NULL DEFAULT 'ok'");
337
345
  addColumnIfMissing(connection, "linear_installations", "health_reason", "TEXT");
338
346
  addColumnIfMissing(connection, "linear_installations", "health_updated_at", "TEXT");
@@ -407,6 +415,9 @@ function removeRetiredIssueColumnsIfPresent(connection) {
407
415
  last_attempted_failure_head_sha TEXT,
408
416
  last_attempted_failure_signature TEXT,
409
417
  last_attempted_failure_at TEXT,
418
+ last_published_patch_id TEXT,
419
+ last_published_integration_tree_id TEXT,
420
+ last_published_head_sha TEXT,
410
421
  ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
411
422
  queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
412
423
  review_fix_attempts INTEGER NOT NULL DEFAULT 0,
@@ -470,6 +481,9 @@ function removeRetiredIssueColumnsIfPresent(connection) {
470
481
  last_attempted_failure_head_sha,
471
482
  last_attempted_failure_signature,
472
483
  last_attempted_failure_at,
484
+ last_published_patch_id,
485
+ last_published_integration_tree_id,
486
+ last_published_head_sha,
473
487
  ci_repair_attempts,
474
488
  queue_repair_attempts,
475
489
  review_fix_attempts,
@@ -531,6 +545,9 @@ function removeRetiredIssueColumnsIfPresent(connection) {
531
545
  last_attempted_failure_head_sha,
532
546
  last_attempted_failure_signature,
533
547
  last_attempted_failure_at,
548
+ last_published_patch_id,
549
+ last_published_integration_tree_id,
550
+ last_published_head_sha,
534
551
  COALESCE(ci_repair_attempts, 0),
535
552
  COALESCE(queue_repair_attempts, 0),
536
553
  COALESCE(review_fix_attempts, 0),
@@ -37,7 +37,15 @@ export class LinearSessionSync {
37
37
  prUrl: syncedIssue.prUrl,
38
38
  }
39
39
  : syncedIssue;
40
- await syncActiveWorkflowState({ db: this.db, issue: syncedIssue, linear, ...(trackedIssue ? { trackedIssue } : {}), ...(options ? { options } : {}) });
40
+ const project = this.config.projects.find((p) => p.id === syncedIssue.projectId);
41
+ await syncActiveWorkflowState({
42
+ db: this.db,
43
+ issue: syncedIssue,
44
+ linear,
45
+ ...(trackedIssue ? { trackedIssue } : {}),
46
+ ...(options ? { options } : {}),
47
+ ...(project ? { project } : {}),
48
+ });
41
49
  await this.agentSessions.syncSessionPlan(syncedIssue, linear, options);
42
50
  if (shouldSyncVisibleIssueComment(visibleIssue, Boolean(syncedIssue.agentSessionId))) {
43
51
  await syncVisibleStatusComment({
@@ -1,8 +1,9 @@
1
- import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
1
+ import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredDeployLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
2
+ import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
3
  import { isCompletedLinearState } from "./pr-state.js";
3
4
  import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
4
5
  export async function syncActiveWorkflowState(params) {
5
- const { db, issue, linear, trackedIssue, options } = params;
6
+ const { db, issue, linear, trackedIssue, options, project } = params;
6
7
  const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
7
8
  if (!liveIssue)
8
9
  return;
@@ -21,6 +22,15 @@ export async function syncActiveWorkflowState(params) {
21
22
  refreshCachedLinearState(db, issue, liveIssue.stateName, liveIssue.stateType);
22
23
  return;
23
24
  }
25
+ // Plan §4.6: keep the queued-for-deploy label in sync UNCONDITIONALLY,
26
+ // before the state-equality early-return. When a project lacks an
27
+ // In Deploy state the deploying-Linear-state collapses to the same
28
+ // value as In Review — meaning when an awaiting_queue issue is sitting
29
+ // in the In Review state, the early-return below skips the state
30
+ // write but the label still needs to be added/removed to reflect
31
+ // factoryState. Running first guarantees the label tracks reality
32
+ // even when the state name doesn't change.
33
+ await syncQueuedForDeployLabel({ issue, liveIssue, linear, project }).catch(() => undefined);
24
34
  const targetState = resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue);
25
35
  if (!targetState)
26
36
  return;
@@ -32,6 +42,50 @@ export async function syncActiveWorkflowState(params) {
32
42
  const updated = await linear.setIssueState(issue.linearIssueId, targetState);
33
43
  refreshCachedLinearState(db, issue, updated.stateName, updated.stateType);
34
44
  }
45
+ // Plan §4.6: when the issue's factoryState says it's In Deploy but the
46
+ // project's Linear workflow has no In Deploy-equivalent state, we want
47
+ // the dashboard to be able to distinguish "in review, awaiting verdict"
48
+ // from "in review, queued for landing". A configurable PR/Linear label
49
+ // (`queuedForDeployLabel`, default `queued-for-deploy`) carries that
50
+ // signal idempotently. The helper computes the desired present/absent
51
+ // state and only calls the API when there's a delta — safe to run on
52
+ // every sync invocation.
53
+ async function syncQueuedForDeployLabel(params) {
54
+ const { issue, liveIssue, linear, project } = params;
55
+ const labelName = resolveMergeQueueProtocol(project).queuedForDeployLabel;
56
+ const want = isQueuedForDeployFallback(issue, liveIssue);
57
+ const currentLabels = (liveIssue.labels ?? [])
58
+ .map((label) => label.name.trim().toLowerCase())
59
+ .filter(Boolean);
60
+ const have = currentLabels.includes(labelName.trim().toLowerCase());
61
+ if (want === have)
62
+ return;
63
+ if (want) {
64
+ await linear.updateIssueLabels({ issueId: issue.linearIssueId, addNames: [labelName] });
65
+ }
66
+ else {
67
+ await linear.updateIssueLabels({ issueId: issue.linearIssueId, removeNames: [labelName] });
68
+ }
69
+ }
70
+ // True only when (a) the issue is In Deploy AND (b) the project's
71
+ // Linear workflow has no In Deploy-equivalent state — detected by the
72
+ // preferred-deploying state collapsing to the same name as the
73
+ // preferred-review state. When the project does have a real In Deploy
74
+ // state, `setIssueState` flows the issue there and the label is
75
+ // unnecessary.
76
+ function isQueuedForDeployFallback(issue, liveIssue) {
77
+ if (issue.factoryState !== "awaiting_queue")
78
+ return false;
79
+ const deploying = resolvePreferredDeployingLinearState(liveIssue);
80
+ const review = resolvePreferredReviewLinearState(liveIssue);
81
+ const deployUnstarted = resolvePreferredDeployLinearState(liveIssue);
82
+ if (!deploying || !review)
83
+ return false;
84
+ // No "deploying"/"deploy" state in the workflow → both resolve to
85
+ // a review state. That's the fallback condition.
86
+ return deploying.trim().toLowerCase() === review.trim().toLowerCase()
87
+ && (deployUnstarted ?? "").trim().toLowerCase() === review.trim().toLowerCase();
88
+ }
35
89
  async function syncCompletedLinearState(params) {
36
90
  const { db, issue, linear, liveIssue } = params;
37
91
  if (isCompletedLinearState(liveIssue.stateType, liveIssue.stateName)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {