patchrelay 0.68.1 → 0.68.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.68.1",
4
- "commit": "1cc1884e5fe9",
5
- "builtAt": "2026-05-15T07:24:40.081Z"
3
+ "version": "0.68.2",
4
+ "commit": "396b8e7a3462",
5
+ "builtAt": "2026-05-15T11:40:22.942Z"
6
6
  }
@@ -1,3 +1,4 @@
1
+ import { buildInsertBindings, buildUpdateAssignments } from "./issue-upsert-columns.js";
1
2
  import { isoNow } from "./shared.js";
2
3
  export class IssueStore {
3
4
  connection;
@@ -10,355 +11,22 @@ export class IssueStore {
10
11
  const now = isoNow();
11
12
  const existing = this.getIssue(params.projectId, params.linearIssueId);
12
13
  if (existing) {
13
- const sets = ["updated_at = @now"];
14
- const values = {
14
+ const { assignments, values } = buildUpdateAssignments(params);
15
+ const sql = `UPDATE issues SET ${["updated_at = @now", ...assignments].join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`;
16
+ this.connection.prepare(sql).run({
17
+ ...values,
15
18
  now,
16
19
  projectId: params.projectId,
17
20
  linearIssueId: params.linearIssueId,
18
- };
19
- if (params.delegatedToPatchRelay !== undefined) {
20
- sets.push("delegated_to_patchrelay = @delegatedToPatchRelay");
21
- values.delegatedToPatchRelay = params.delegatedToPatchRelay ? 1 : 0;
22
- }
23
- if (params.issueClass !== undefined) {
24
- sets.push("issue_class = @issueClass");
25
- values.issueClass = params.issueClass;
26
- }
27
- if (params.issueClassSource !== undefined) {
28
- sets.push("issue_class_source = @issueClassSource");
29
- values.issueClassSource = params.issueClassSource;
30
- }
31
- if (params.issueTriageHash !== undefined) {
32
- sets.push("issue_triage_hash = @issueTriageHash");
33
- values.issueTriageHash = params.issueTriageHash;
34
- }
35
- if (params.issueTriageResultJson !== undefined) {
36
- sets.push("issue_triage_result_json = @issueTriageResultJson");
37
- values.issueTriageResultJson = params.issueTriageResultJson;
38
- }
39
- if (params.parentLinearIssueId !== undefined) {
40
- sets.push("parent_linear_issue_id = @parentLinearIssueId");
41
- values.parentLinearIssueId = params.parentLinearIssueId;
42
- }
43
- if (params.parentIssueKey !== undefined) {
44
- sets.push("parent_issue_key = @parentIssueKey");
45
- values.parentIssueKey = params.parentIssueKey;
46
- }
47
- if (params.issueKey !== undefined) {
48
- sets.push("issue_key = COALESCE(@issueKey, issue_key)");
49
- values.issueKey = params.issueKey;
50
- }
51
- if (params.title !== undefined) {
52
- sets.push("title = COALESCE(@title, title)");
53
- values.title = params.title;
54
- }
55
- if (params.description !== undefined) {
56
- sets.push("description = COALESCE(@description, description)");
57
- values.description = params.description;
58
- }
59
- if (params.url !== undefined) {
60
- sets.push("url = COALESCE(@url, url)");
61
- values.url = params.url;
62
- }
63
- if (params.priority !== undefined) {
64
- sets.push("priority = @priority");
65
- values.priority = params.priority;
66
- }
67
- if (params.estimate !== undefined) {
68
- sets.push("estimate = @estimate");
69
- values.estimate = params.estimate;
70
- }
71
- if (params.currentLinearState !== undefined) {
72
- sets.push("current_linear_state = COALESCE(@currentLinearState, current_linear_state)");
73
- values.currentLinearState = params.currentLinearState;
74
- }
75
- if (params.currentLinearStateType !== undefined) {
76
- sets.push("current_linear_state_type = COALESCE(@currentLinearStateType, current_linear_state_type)");
77
- values.currentLinearStateType = params.currentLinearStateType;
78
- }
79
- if (params.factoryState !== undefined) {
80
- sets.push("factory_state = @factoryState");
81
- values.factoryState = params.factoryState;
82
- }
83
- if (params.pendingRunType !== undefined) {
84
- sets.push("pending_run_type = @pendingRunType");
85
- values.pendingRunType = params.pendingRunType;
86
- }
87
- if (params.pendingRunContextJson !== undefined) {
88
- sets.push("pending_run_context_json = @pendingRunContextJson");
89
- values.pendingRunContextJson = params.pendingRunContextJson;
90
- }
91
- if (params.branchName !== undefined) {
92
- sets.push("branch_name = COALESCE(@branchName, branch_name)");
93
- values.branchName = params.branchName;
94
- }
95
- if (params.worktreePath !== undefined) {
96
- sets.push("worktree_path = COALESCE(@worktreePath, worktree_path)");
97
- values.worktreePath = params.worktreePath;
98
- }
99
- if (params.threadId !== undefined) {
100
- sets.push("thread_id = @threadId");
101
- values.threadId = params.threadId;
102
- }
103
- if (params.activeRunId !== undefined) {
104
- sets.push("active_run_id = @activeRunId");
105
- values.activeRunId = params.activeRunId;
106
- }
107
- if (params.statusCommentId !== undefined) {
108
- sets.push("status_comment_id = @statusCommentId");
109
- values.statusCommentId = params.statusCommentId;
110
- }
111
- if (params.agentSessionId !== undefined) {
112
- sets.push("agent_session_id = @agentSessionId");
113
- values.agentSessionId = params.agentSessionId;
114
- }
115
- if (params.lastLinearActivityKey !== undefined) {
116
- sets.push("last_linear_activity_key = @lastLinearActivityKey");
117
- values.lastLinearActivityKey = params.lastLinearActivityKey;
118
- }
119
- if (params.prNumber !== undefined) {
120
- sets.push("pr_number = @prNumber");
121
- values.prNumber = params.prNumber;
122
- }
123
- if (params.prUrl !== undefined) {
124
- sets.push("pr_url = @prUrl");
125
- values.prUrl = params.prUrl;
126
- }
127
- if (params.prState !== undefined) {
128
- sets.push("pr_state = @prState");
129
- values.prState = params.prState;
130
- }
131
- if (params.prIsDraft !== undefined) {
132
- sets.push("pr_is_draft = @prIsDraft");
133
- values.prIsDraft = params.prIsDraft == null ? null : params.prIsDraft ? 1 : 0;
134
- }
135
- if (params.prHeadSha !== undefined) {
136
- sets.push("pr_head_sha = @prHeadSha");
137
- values.prHeadSha = params.prHeadSha;
138
- }
139
- if (params.prAuthorLogin !== undefined) {
140
- sets.push("pr_author_login = @prAuthorLogin");
141
- values.prAuthorLogin = params.prAuthorLogin;
142
- }
143
- if (params.prReviewState !== undefined) {
144
- sets.push("pr_review_state = @prReviewState");
145
- values.prReviewState = params.prReviewState;
146
- }
147
- if (params.prCheckStatus !== undefined) {
148
- sets.push("pr_check_status = @prCheckStatus");
149
- values.prCheckStatus = params.prCheckStatus;
150
- }
151
- if (params.lastBlockingReviewHeadSha !== undefined) {
152
- sets.push("last_blocking_review_head_sha = @lastBlockingReviewHeadSha");
153
- values.lastBlockingReviewHeadSha = params.lastBlockingReviewHeadSha;
154
- }
155
- if (params.lastGitHubFailureSource !== undefined) {
156
- sets.push("last_github_failure_source = @lastGitHubFailureSource");
157
- values.lastGitHubFailureSource = params.lastGitHubFailureSource;
158
- }
159
- if (params.lastGitHubFailureHeadSha !== undefined) {
160
- sets.push("last_github_failure_head_sha = @lastGitHubFailureHeadSha");
161
- values.lastGitHubFailureHeadSha = params.lastGitHubFailureHeadSha;
162
- }
163
- if (params.lastGitHubFailureSignature !== undefined) {
164
- sets.push("last_github_failure_signature = @lastGitHubFailureSignature");
165
- values.lastGitHubFailureSignature = params.lastGitHubFailureSignature;
166
- }
167
- if (params.lastGitHubFailureCheckName !== undefined) {
168
- sets.push("last_github_failure_check_name = @lastGitHubFailureCheckName");
169
- values.lastGitHubFailureCheckName = params.lastGitHubFailureCheckName;
170
- }
171
- if (params.lastGitHubFailureCheckUrl !== undefined) {
172
- sets.push("last_github_failure_check_url = @lastGitHubFailureCheckUrl");
173
- values.lastGitHubFailureCheckUrl = params.lastGitHubFailureCheckUrl;
174
- }
175
- if (params.lastGitHubFailureContextJson !== undefined) {
176
- sets.push("last_github_failure_context_json = @lastGitHubFailureContextJson");
177
- values.lastGitHubFailureContextJson = params.lastGitHubFailureContextJson;
178
- }
179
- if (params.lastGitHubFailureAt !== undefined) {
180
- sets.push("last_github_failure_at = @lastGitHubFailureAt");
181
- values.lastGitHubFailureAt = params.lastGitHubFailureAt;
182
- }
183
- if (params.lastGitHubCiSnapshotHeadSha !== undefined) {
184
- sets.push("last_github_ci_snapshot_head_sha = @lastGitHubCiSnapshotHeadSha");
185
- values.lastGitHubCiSnapshotHeadSha = params.lastGitHubCiSnapshotHeadSha;
186
- }
187
- if (params.lastGitHubCiSnapshotGateCheckName !== undefined) {
188
- sets.push("last_github_ci_snapshot_gate_check_name = @lastGitHubCiSnapshotGateCheckName");
189
- values.lastGitHubCiSnapshotGateCheckName = params.lastGitHubCiSnapshotGateCheckName;
190
- }
191
- if (params.lastGitHubCiSnapshotGateCheckStatus !== undefined) {
192
- sets.push("last_github_ci_snapshot_gate_check_status = @lastGitHubCiSnapshotGateCheckStatus");
193
- values.lastGitHubCiSnapshotGateCheckStatus = params.lastGitHubCiSnapshotGateCheckStatus;
194
- }
195
- if (params.lastGitHubCiSnapshotJson !== undefined) {
196
- sets.push("last_github_ci_snapshot_json = @lastGitHubCiSnapshotJson");
197
- values.lastGitHubCiSnapshotJson = params.lastGitHubCiSnapshotJson;
198
- }
199
- if (params.lastGitHubCiSnapshotSettledAt !== undefined) {
200
- sets.push("last_github_ci_snapshot_settled_at = @lastGitHubCiSnapshotSettledAt");
201
- values.lastGitHubCiSnapshotSettledAt = params.lastGitHubCiSnapshotSettledAt;
202
- }
203
- if (params.lastQueueSignalAt !== undefined) {
204
- sets.push("last_queue_signal_at = @lastQueueSignalAt");
205
- values.lastQueueSignalAt = params.lastQueueSignalAt;
206
- }
207
- if (params.lastQueueIncidentJson !== undefined) {
208
- sets.push("last_queue_incident_json = @lastQueueIncidentJson");
209
- values.lastQueueIncidentJson = params.lastQueueIncidentJson;
210
- }
211
- if (params.lastAttemptedFailureHeadSha !== undefined) {
212
- sets.push("last_attempted_failure_head_sha = @lastAttemptedFailureHeadSha");
213
- values.lastAttemptedFailureHeadSha = params.lastAttemptedFailureHeadSha;
214
- }
215
- if (params.lastAttemptedFailureSignature !== undefined) {
216
- sets.push("last_attempted_failure_signature = @lastAttemptedFailureSignature");
217
- values.lastAttemptedFailureSignature = params.lastAttemptedFailureSignature;
218
- }
219
- if (params.lastAttemptedFailureAt !== undefined) {
220
- sets.push("last_attempted_failure_at = @lastAttemptedFailureAt");
221
- values.lastAttemptedFailureAt = params.lastAttemptedFailureAt;
222
- }
223
- if (params.lastPublishedPatchId !== undefined) {
224
- sets.push("last_published_patch_id = @lastPublishedPatchId");
225
- values.lastPublishedPatchId = params.lastPublishedPatchId;
226
- }
227
- if (params.lastPublishedIntegrationTreeId !== undefined) {
228
- sets.push("last_published_integration_tree_id = @lastPublishedIntegrationTreeId");
229
- values.lastPublishedIntegrationTreeId = params.lastPublishedIntegrationTreeId;
230
- }
231
- if (params.lastPublishedHeadSha !== undefined) {
232
- sets.push("last_published_head_sha = @lastPublishedHeadSha");
233
- values.lastPublishedHeadSha = params.lastPublishedHeadSha;
234
- }
235
- if (params.parentPrBranch !== undefined) {
236
- sets.push("parent_pr_branch = @parentPrBranch");
237
- values.parentPrBranch = params.parentPrBranch;
238
- }
239
- if (params.ciRepairAttempts !== undefined) {
240
- sets.push("ci_repair_attempts = @ciRepairAttempts");
241
- values.ciRepairAttempts = params.ciRepairAttempts;
242
- }
243
- if (params.queueRepairAttempts !== undefined) {
244
- sets.push("queue_repair_attempts = @queueRepairAttempts");
245
- values.queueRepairAttempts = params.queueRepairAttempts;
246
- }
247
- if (params.reviewFixAttempts !== undefined) {
248
- sets.push("review_fix_attempts = @reviewFixAttempts");
249
- values.reviewFixAttempts = params.reviewFixAttempts;
250
- }
251
- if (params.zombieRecoveryAttempts !== undefined) {
252
- sets.push("zombie_recovery_attempts = @zombieRecoveryAttempts");
253
- values.zombieRecoveryAttempts = params.zombieRecoveryAttempts;
254
- }
255
- if (params.lastZombieRecoveryAt !== undefined) {
256
- sets.push("last_zombie_recovery_at = @lastZombieRecoveryAt");
257
- values.lastZombieRecoveryAt = params.lastZombieRecoveryAt;
258
- }
259
- if (params.orchestrationSettleUntil !== undefined) {
260
- sets.push("orchestration_settle_until = @orchestrationSettleUntil");
261
- values.orchestrationSettleUntil = params.orchestrationSettleUntil;
262
- }
263
- this.connection.prepare(`UPDATE issues SET ${sets.join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`).run(values);
21
+ });
264
22
  }
265
23
  else {
266
- this.connection.prepare(`
267
- INSERT INTO issues (
268
- project_id, linear_issue_id, delegated_to_patchrelay, issue_class, issue_class_source, issue_triage_hash, issue_triage_result_json, parent_linear_issue_id, parent_issue_key, issue_key, title, description, url,
269
- priority, estimate,
270
- current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
271
- branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
272
- agent_session_id, last_linear_activity_key,
273
- pr_number, pr_url, pr_state, pr_is_draft, pr_head_sha, pr_author_login, pr_review_state, pr_check_status, last_blocking_review_head_sha,
274
- last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
275
- 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,
276
- last_queue_signal_at, last_queue_incident_json,
277
- last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
278
- last_published_patch_id, last_published_integration_tree_id, last_published_head_sha,
279
- parent_pr_branch,
280
- ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at, orchestration_settle_until,
281
- updated_at
282
- ) VALUES (
283
- @projectId, @linearIssueId, @delegatedToPatchRelay, @issueClass, @issueClassSource, @issueTriageHash, @issueTriageResultJson, @parentLinearIssueId, @parentIssueKey, @issueKey, @title, @description, @url,
284
- @priority, @estimate,
285
- @currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
286
- @branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
287
- @agentSessionId, @lastLinearActivityKey,
288
- @prNumber, @prUrl, @prState, @prIsDraft, @prHeadSha, @prAuthorLogin, @prReviewState, @prCheckStatus, @lastBlockingReviewHeadSha,
289
- @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
290
- @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
291
- @lastQueueSignalAt, @lastQueueIncidentJson,
292
- @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
293
- @lastPublishedPatchId, @lastPublishedIntegrationTreeId, @lastPublishedHeadSha,
294
- @parentPrBranch,
295
- @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt, @orchestrationSettleUntil,
296
- @now
297
- )
298
- `).run({
24
+ const { columns, placeholders, values } = buildInsertBindings(params);
25
+ const sql = `INSERT INTO issues (project_id, linear_issue_id, ${columns.join(", ")}, updated_at) VALUES (@projectId, @linearIssueId, ${placeholders.join(", ")}, @now)`;
26
+ this.connection.prepare(sql).run({
27
+ ...values,
299
28
  projectId: params.projectId,
300
29
  linearIssueId: params.linearIssueId,
301
- delegatedToPatchRelay: params.delegatedToPatchRelay === false ? 0 : 1,
302
- issueClass: params.issueClass ?? null,
303
- issueClassSource: params.issueClassSource ?? null,
304
- issueTriageHash: params.issueTriageHash ?? null,
305
- issueTriageResultJson: params.issueTriageResultJson ?? null,
306
- parentLinearIssueId: params.parentLinearIssueId ?? null,
307
- parentIssueKey: params.parentIssueKey ?? null,
308
- issueKey: params.issueKey ?? null,
309
- title: params.title ?? null,
310
- description: params.description ?? null,
311
- url: params.url ?? null,
312
- priority: params.priority ?? null,
313
- estimate: params.estimate ?? null,
314
- currentLinearState: params.currentLinearState ?? null,
315
- currentLinearStateType: params.currentLinearStateType ?? null,
316
- factoryState: params.factoryState ?? "delegated",
317
- pendingRunType: params.pendingRunType ?? null,
318
- pendingRunContextJson: params.pendingRunContextJson ?? null,
319
- branchName: params.branchName ?? null,
320
- worktreePath: params.worktreePath ?? null,
321
- threadId: params.threadId ?? null,
322
- activeRunId: params.activeRunId ?? null,
323
- statusCommentId: params.statusCommentId ?? null,
324
- agentSessionId: params.agentSessionId ?? null,
325
- lastLinearActivityKey: params.lastLinearActivityKey ?? null,
326
- prNumber: params.prNumber ?? null,
327
- prUrl: params.prUrl ?? null,
328
- prState: params.prState ?? null,
329
- prIsDraft: params.prIsDraft == null ? null : params.prIsDraft ? 1 : 0,
330
- prHeadSha: params.prHeadSha ?? null,
331
- prAuthorLogin: params.prAuthorLogin ?? null,
332
- prReviewState: params.prReviewState ?? null,
333
- prCheckStatus: params.prCheckStatus ?? null,
334
- lastBlockingReviewHeadSha: params.lastBlockingReviewHeadSha ?? null,
335
- lastGitHubFailureSource: params.lastGitHubFailureSource ?? null,
336
- lastGitHubFailureHeadSha: params.lastGitHubFailureHeadSha ?? null,
337
- lastGitHubFailureSignature: params.lastGitHubFailureSignature ?? null,
338
- lastGitHubFailureCheckName: params.lastGitHubFailureCheckName ?? null,
339
- lastGitHubFailureCheckUrl: params.lastGitHubFailureCheckUrl ?? null,
340
- lastGitHubFailureContextJson: params.lastGitHubFailureContextJson ?? null,
341
- lastGitHubFailureAt: params.lastGitHubFailureAt ?? null,
342
- lastGitHubCiSnapshotHeadSha: params.lastGitHubCiSnapshotHeadSha ?? null,
343
- lastGitHubCiSnapshotGateCheckName: params.lastGitHubCiSnapshotGateCheckName ?? null,
344
- lastGitHubCiSnapshotGateCheckStatus: params.lastGitHubCiSnapshotGateCheckStatus ?? null,
345
- lastGitHubCiSnapshotJson: params.lastGitHubCiSnapshotJson ?? null,
346
- lastGitHubCiSnapshotSettledAt: params.lastGitHubCiSnapshotSettledAt ?? null,
347
- lastQueueSignalAt: params.lastQueueSignalAt ?? null,
348
- lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
349
- lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
350
- lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
351
- lastAttemptedFailureAt: params.lastAttemptedFailureAt ?? null,
352
- lastPublishedPatchId: params.lastPublishedPatchId ?? null,
353
- lastPublishedIntegrationTreeId: params.lastPublishedIntegrationTreeId ?? null,
354
- lastPublishedHeadSha: params.lastPublishedHeadSha ?? null,
355
- parentPrBranch: params.parentPrBranch ?? null,
356
- ciRepairAttempts: params.ciRepairAttempts ?? 0,
357
- queueRepairAttempts: params.queueRepairAttempts ?? 0,
358
- reviewFixAttempts: params.reviewFixAttempts ?? 0,
359
- zombieRecoveryAttempts: params.zombieRecoveryAttempts ?? 0,
360
- lastZombieRecoveryAt: params.lastZombieRecoveryAt ?? null,
361
- orchestrationSettleUntil: params.orchestrationSettleUntil ?? null,
362
30
  now,
363
31
  });
364
32
  }
@@ -0,0 +1,119 @@
1
+ const booleanToInt = (value) => value === true || value === 1 ? 1 : 0;
2
+ const nullableBooleanToInt = (value) => {
3
+ if (value == null)
4
+ return null;
5
+ return value === true || value === 1 ? 1 : 0;
6
+ };
7
+ /**
8
+ * Source-of-truth column map for `upsertIssue`. Ordered to match the INSERT
9
+ * column list so a quick read shows the table shape end-to-end. Adding a new
10
+ * field is a one-line addition here instead of editing three parallel lists.
11
+ */
12
+ export const ISSUE_COLUMN_DEFS = {
13
+ delegatedToPatchRelay: { column: "delegated_to_patchrelay", transform: booleanToInt, insertDefault: 1 },
14
+ issueClass: { column: "issue_class" },
15
+ issueClassSource: { column: "issue_class_source" },
16
+ issueTriageHash: { column: "issue_triage_hash" },
17
+ issueTriageResultJson: { column: "issue_triage_result_json" },
18
+ parentLinearIssueId: { column: "parent_linear_issue_id" },
19
+ parentIssueKey: { column: "parent_issue_key" },
20
+ issueKey: { column: "issue_key", coalesce: true },
21
+ title: { column: "title", coalesce: true },
22
+ description: { column: "description", coalesce: true },
23
+ url: { column: "url", coalesce: true },
24
+ priority: { column: "priority" },
25
+ estimate: { column: "estimate" },
26
+ currentLinearState: { column: "current_linear_state", coalesce: true },
27
+ currentLinearStateType: { column: "current_linear_state_type", coalesce: true },
28
+ factoryState: { column: "factory_state", insertDefault: "delegated" },
29
+ pendingRunType: { column: "pending_run_type" },
30
+ pendingRunContextJson: { column: "pending_run_context_json" },
31
+ branchName: { column: "branch_name", coalesce: true },
32
+ worktreePath: { column: "worktree_path", coalesce: true },
33
+ threadId: { column: "thread_id" },
34
+ activeRunId: { column: "active_run_id" },
35
+ statusCommentId: { column: "status_comment_id" },
36
+ agentSessionId: { column: "agent_session_id" },
37
+ lastLinearActivityKey: { column: "last_linear_activity_key" },
38
+ prNumber: { column: "pr_number" },
39
+ prUrl: { column: "pr_url" },
40
+ prState: { column: "pr_state" },
41
+ prIsDraft: { column: "pr_is_draft", transform: nullableBooleanToInt },
42
+ prHeadSha: { column: "pr_head_sha" },
43
+ prAuthorLogin: { column: "pr_author_login" },
44
+ prReviewState: { column: "pr_review_state" },
45
+ prCheckStatus: { column: "pr_check_status" },
46
+ lastBlockingReviewHeadSha: { column: "last_blocking_review_head_sha" },
47
+ lastGitHubFailureSource: { column: "last_github_failure_source" },
48
+ lastGitHubFailureHeadSha: { column: "last_github_failure_head_sha" },
49
+ lastGitHubFailureSignature: { column: "last_github_failure_signature" },
50
+ lastGitHubFailureCheckName: { column: "last_github_failure_check_name" },
51
+ lastGitHubFailureCheckUrl: { column: "last_github_failure_check_url" },
52
+ lastGitHubFailureContextJson: { column: "last_github_failure_context_json" },
53
+ lastGitHubFailureAt: { column: "last_github_failure_at" },
54
+ lastGitHubCiSnapshotHeadSha: { column: "last_github_ci_snapshot_head_sha" },
55
+ lastGitHubCiSnapshotGateCheckName: { column: "last_github_ci_snapshot_gate_check_name" },
56
+ lastGitHubCiSnapshotGateCheckStatus: { column: "last_github_ci_snapshot_gate_check_status" },
57
+ lastGitHubCiSnapshotJson: { column: "last_github_ci_snapshot_json" },
58
+ lastGitHubCiSnapshotSettledAt: { column: "last_github_ci_snapshot_settled_at" },
59
+ lastQueueSignalAt: { column: "last_queue_signal_at" },
60
+ lastQueueIncidentJson: { column: "last_queue_incident_json" },
61
+ lastAttemptedFailureHeadSha: { column: "last_attempted_failure_head_sha" },
62
+ lastAttemptedFailureSignature: { column: "last_attempted_failure_signature" },
63
+ lastAttemptedFailureAt: { column: "last_attempted_failure_at" },
64
+ lastPublishedPatchId: { column: "last_published_patch_id" },
65
+ lastPublishedIntegrationTreeId: { column: "last_published_integration_tree_id" },
66
+ lastPublishedHeadSha: { column: "last_published_head_sha" },
67
+ parentPrBranch: { column: "parent_pr_branch" },
68
+ ciRepairAttempts: { column: "ci_repair_attempts", insertDefault: 0 },
69
+ queueRepairAttempts: { column: "queue_repair_attempts", insertDefault: 0 },
70
+ reviewFixAttempts: { column: "review_fix_attempts", insertDefault: 0 },
71
+ zombieRecoveryAttempts: { column: "zombie_recovery_attempts", insertDefault: 0 },
72
+ lastZombieRecoveryAt: { column: "last_zombie_recovery_at" },
73
+ orchestrationSettleUntil: { column: "orchestration_settle_until" },
74
+ };
75
+ export const ISSUE_COLUMN_KEYS = Object.keys(ISSUE_COLUMN_DEFS);
76
+ /**
77
+ * Builds the `SET col = @param` fragments and the bound-value bag for an
78
+ * UPDATE. Only fields explicitly set in `params` are included so we don't
79
+ * accidentally clobber other columns.
80
+ */
81
+ export function buildUpdateAssignments(params) {
82
+ const assignments = [];
83
+ const values = {};
84
+ for (const key of ISSUE_COLUMN_KEYS) {
85
+ const value = params[key];
86
+ if (value === undefined)
87
+ continue;
88
+ const def = ISSUE_COLUMN_DEFS[key];
89
+ const bound = def.transform ? def.transform(value) : value;
90
+ assignments.push(def.coalesce
91
+ ? `${def.column} = COALESCE(@${key}, ${def.column})`
92
+ : `${def.column} = @${key}`);
93
+ values[key] = bound;
94
+ }
95
+ return { assignments, values };
96
+ }
97
+ /**
98
+ * Builds the columns list, the placeholders list, and the bound-value bag for
99
+ * an INSERT. Every column is emitted (NULL if the param is omitted and no
100
+ * `insertDefault` is set) so the SQL stays stable across calls.
101
+ */
102
+ export function buildInsertBindings(params) {
103
+ const columns = [];
104
+ const placeholders = [];
105
+ const values = {};
106
+ for (const key of ISSUE_COLUMN_KEYS) {
107
+ const def = ISSUE_COLUMN_DEFS[key];
108
+ columns.push(def.column);
109
+ placeholders.push(`@${key}`);
110
+ const raw = params[key];
111
+ const resolved = raw !== undefined ? raw : def.insertDefault;
112
+ if (resolved === undefined) {
113
+ values[key] = null;
114
+ continue;
115
+ }
116
+ values[key] = def.transform ? def.transform(resolved) : resolved;
117
+ }
118
+ return { columns, placeholders, values };
119
+ }
@@ -0,0 +1,100 @@
1
+ import { parseGitHubFailureContext } from "./github-failure-context.js";
2
+ import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
3
+ export function isFailingCheckStatus(status) {
4
+ return status === "failed" || status === "failure";
5
+ }
6
+ export function isReviewDecisionApproved(value) {
7
+ return value?.trim().toUpperCase() === "APPROVED";
8
+ }
9
+ export function isReviewDecisionChangesRequested(value) {
10
+ return value?.trim().toUpperCase() === "CHANGES_REQUESTED";
11
+ }
12
+ export function isReviewDecisionReviewRequired(value) {
13
+ return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
14
+ }
15
+ export function buildBranchUpkeepContext(prNumber, baseBranch, mergeStateStatus, headSha) {
16
+ const promptContext = [
17
+ `The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${mergeStateStatus ?? "DIRTY"} against latest ${baseBranch}.`,
18
+ `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.`,
19
+ "Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
20
+ ].join(" ");
21
+ return {
22
+ branchUpkeepRequired: true,
23
+ reviewFixMode: "branch_upkeep",
24
+ wakeReason: "branch_upkeep",
25
+ promptContext,
26
+ ...(mergeStateStatus ? { mergeStateStatus } : {}),
27
+ ...(headSha ? { failingHeadSha: headSha } : {}),
28
+ baseBranch,
29
+ };
30
+ }
31
+ export function hasCompletedReviewQuillVerdict(entries) {
32
+ return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
33
+ && entry.name === "review-quill/verdict"
34
+ && entry.status === "COMPLETED");
35
+ }
36
+ export function getGateCheckNames(project) {
37
+ const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
38
+ return configured.length > 0 ? configured : ["verify"];
39
+ }
40
+ /**
41
+ * A repair attempt is "duplicate" when we have already tried to repair the
42
+ * exact same failure (same signature, same head SHA) AND no newer failure
43
+ * has been observed since that attempt was recorded. For queue evictions
44
+ * the PR head doesn't advance between attempts, so we additionally compare
45
+ * the timestamps: a fresh incident after `main` advances looks identical
46
+ * to a stale one without the timestamp check.
47
+ */
48
+ export function isDuplicateRepairAttempt(issue, context) {
49
+ const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
50
+ const headSha = typeof context?.failureHeadSha === "string"
51
+ ? context.failureHeadSha
52
+ : typeof context?.headSha === "string" ? context.headSha : undefined;
53
+ if (!signature)
54
+ return false;
55
+ if (issue.lastAttemptedFailureSignature !== signature)
56
+ return false;
57
+ if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
58
+ return false;
59
+ if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
60
+ && issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
61
+ return false;
62
+ }
63
+ return true;
64
+ }
65
+ export function buildFailureContext(issue) {
66
+ const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
67
+ const queueRepairContext = issue.lastQueueIncidentJson
68
+ ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
69
+ : undefined;
70
+ if (!queueRepairContext
71
+ && !issue.lastGitHubFailureSource
72
+ && !issue.lastGitHubFailureHeadSha
73
+ && !issue.lastGitHubFailureSignature
74
+ && !issue.lastGitHubFailureCheckName
75
+ && !issue.lastGitHubFailureCheckUrl
76
+ && !storedFailureContext) {
77
+ return undefined;
78
+ }
79
+ return {
80
+ ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
81
+ ...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
82
+ ...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
83
+ ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
84
+ ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
85
+ ...(storedFailureContext ? storedFailureContext : {}),
86
+ ...(queueRepairContext ? queueRepairContext : {}),
87
+ };
88
+ }
89
+ export function hasFailureProvenance(issue) {
90
+ return Boolean(issue.lastGitHubFailureSource
91
+ || issue.lastGitHubFailureHeadSha
92
+ || issue.lastGitHubFailureSignature
93
+ || issue.lastGitHubFailureCheckName
94
+ || issue.lastGitHubFailureCheckUrl
95
+ || issue.lastGitHubFailureContextJson
96
+ || issue.lastGitHubFailureAt
97
+ || issue.lastQueueIncidentJson
98
+ || issue.lastAttemptedFailureHeadSha
99
+ || issue.lastAttemptedFailureSignature);
100
+ }
@@ -1,107 +1,12 @@
1
+ import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCompletedReviewQuillVerdict, hasFailureProvenance, isDuplicateRepairAttempt, isFailingCheckStatus, isReviewDecisionApproved, isReviewDecisionChangesRequested, isReviewDecisionReviewRequired, } from "./idle-reconciliation-helpers.js";
1
2
  import { isMainRepairIssue } from "./main-repair.js";
2
3
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
3
- import { parseGitHubFailureContext } from "./github-failure-context.js";
4
4
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
5
5
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
6
- import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
7
6
  import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
8
7
  import { getReviewFixBudget } from "./run-budgets.js";
9
8
  import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
10
9
  import { execCommand } from "./utils.js";
11
- function isFailingCheckStatus(status) {
12
- return status === "failed" || status === "failure";
13
- }
14
- function isReviewDecisionApproved(value) {
15
- return value?.trim().toUpperCase() === "APPROVED";
16
- }
17
- function isReviewDecisionChangesRequested(value) {
18
- return value?.trim().toUpperCase() === "CHANGES_REQUESTED";
19
- }
20
- function isReviewDecisionReviewRequired(value) {
21
- return value?.trim().toUpperCase() === "REVIEW_REQUIRED";
22
- }
23
- function buildBranchUpkeepContext(prNumber, baseBranch, mergeStateStatus, headSha) {
24
- const promptContext = [
25
- `The requested code change may already be present, but GitHub still reports PR #${prNumber} as ${mergeStateStatus ?? "DIRTY"} against latest ${baseBranch}.`,
26
- `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.`,
27
- "Do not stop just because the requested code change is already present. Review can only move forward after a new pushed head.",
28
- ].join(" ");
29
- return {
30
- branchUpkeepRequired: true,
31
- reviewFixMode: "branch_upkeep",
32
- wakeReason: "branch_upkeep",
33
- promptContext,
34
- ...(mergeStateStatus ? { mergeStateStatus } : {}),
35
- ...(headSha ? { failingHeadSha: headSha } : {}),
36
- baseBranch,
37
- };
38
- }
39
- function hasCompletedReviewQuillVerdict(entries) {
40
- return (entries ?? []).some((entry) => entry.__typename === "CheckRun"
41
- && entry.name === "review-quill/verdict"
42
- && entry.status === "COMPLETED");
43
- }
44
- function getGateCheckNames(project) {
45
- const configured = project?.gateChecks?.map((entry) => entry.trim()).filter(Boolean) ?? [];
46
- return configured.length > 0 ? configured : ["verify"];
47
- }
48
- function isDuplicateRepairAttempt(issue, context) {
49
- const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
50
- const headSha = typeof context?.failureHeadSha === "string"
51
- ? context.failureHeadSha
52
- : typeof context?.headSha === "string" ? context.headSha : undefined;
53
- if (!signature)
54
- return false;
55
- if (issue.lastAttemptedFailureSignature !== signature)
56
- return false;
57
- if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
58
- return false;
59
- // A signature+headSha match alone isn't enough: for queue evictions the PR head
60
- // doesn't advance (we haven't pushed) and the steward's check name is constant,
61
- // so a fresh incident after main advances looks identical. Treat the attempt as
62
- // stale if a newer failure has been observed since it was recorded.
63
- if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
64
- && issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
65
- return false;
66
- }
67
- return true;
68
- }
69
- function buildFailureContext(issue) {
70
- const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
71
- const queueRepairContext = issue.lastQueueIncidentJson
72
- ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
73
- : undefined;
74
- if (!queueRepairContext
75
- && !issue.lastGitHubFailureSource
76
- && !issue.lastGitHubFailureHeadSha
77
- && !issue.lastGitHubFailureSignature
78
- && !issue.lastGitHubFailureCheckName
79
- && !issue.lastGitHubFailureCheckUrl
80
- && !storedFailureContext) {
81
- return undefined;
82
- }
83
- return {
84
- ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
85
- ...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
86
- ...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
87
- ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
88
- ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
89
- ...(storedFailureContext ? storedFailureContext : {}),
90
- ...(queueRepairContext ? queueRepairContext : {}),
91
- };
92
- }
93
- function hasFailureProvenance(issue) {
94
- return Boolean(issue.lastGitHubFailureSource
95
- || issue.lastGitHubFailureHeadSha
96
- || issue.lastGitHubFailureSignature
97
- || issue.lastGitHubFailureCheckName
98
- || issue.lastGitHubFailureCheckUrl
99
- || issue.lastGitHubFailureContextJson
100
- || issue.lastGitHubFailureAt
101
- || issue.lastQueueIncidentJson
102
- || issue.lastAttemptedFailureHeadSha
103
- || issue.lastAttemptedFailureSignature);
104
- }
105
10
  export class IdleIssueReconciler {
106
11
  db;
107
12
  config;
@@ -0,0 +1,52 @@
1
+ import { appendDelegationObservedEvent } from "../delegation-audit.js";
2
+ export function isDelegatedToPatchRelay(db, project, issue) {
3
+ const installation = db.linearInstallations.getLinearInstallationForProject(project.id);
4
+ if (!installation?.actorId)
5
+ return false;
6
+ return issue.delegateId === installation.actorId;
7
+ }
8
+ /**
9
+ * Resolves whether the issue is currently delegated to PatchRelay, applying
10
+ * the "preserve previous value when the webhook didn't carry a delegate
11
+ * identity" guard so a stale webhook can't accidentally un-delegate the
12
+ * issue. Emits a `delegation_observed` audit entry whenever the resolved
13
+ * value diverges from what we previously stored, so the audit log captures
14
+ * both raw observation and applied decision.
15
+ */
16
+ export function resolveDelegationTruth(input) {
17
+ const previousDelegated = input.existingIssue?.delegatedToPatchRelay;
18
+ const observedDelegated = isDelegatedToPatchRelay(input.db, input.project, input.hydratedIssue);
19
+ const explicitDelegateSignal = input.triggerEvent === "delegateChanged";
20
+ const hasObservedDelegate = input.hydratedIssue.delegateId !== undefined;
21
+ let delegated = observedDelegated;
22
+ let reason = hasObservedDelegate
23
+ ? "delegate_id_present"
24
+ : `missing_delegate_identity_after_${input.hydration}`;
25
+ if (!hasObservedDelegate && !explicitDelegateSignal && previousDelegated !== undefined) {
26
+ delegated = previousDelegated;
27
+ reason = `preserved_previous_delegation_after_${input.hydration}`;
28
+ }
29
+ if (previousDelegated !== delegated
30
+ || input.hydration === "live_linear_failed"
31
+ || (!hasObservedDelegate && previousDelegated !== undefined)) {
32
+ appendDelegationObservedEvent(input.db, {
33
+ projectId: input.project.id,
34
+ linearIssueId: input.normalizedIssue.id,
35
+ payload: {
36
+ source: "linear_webhook",
37
+ webhookId: input.webhookId,
38
+ triggerEvent: input.triggerEvent,
39
+ ...(input.actorId ? { actorId: input.actorId } : {}),
40
+ ...(input.hydratedIssue.delegateId ? { observedDelegateId: input.hydratedIssue.delegateId } : {}),
41
+ ...(previousDelegated !== undefined ? { previousDelegatedToPatchRelay: previousDelegated } : {}),
42
+ observedDelegatedToPatchRelay: observedDelegated,
43
+ appliedDelegatedToPatchRelay: delegated,
44
+ hydration: input.hydration,
45
+ ...(input.activeRunId !== undefined ? { activeRunId: input.activeRunId } : {}),
46
+ decision: "none",
47
+ reason,
48
+ },
49
+ });
50
+ }
51
+ return { delegated };
52
+ }
@@ -2,12 +2,11 @@ import { classifyIssue } from "../issue-class.js";
2
2
  import { computeOrchestrationSettleUntil, wakeOrchestrationParentsForChildEvent, } from "../orchestration-parent-wake.js";
3
3
  import { triggerEventAllowed } from "../project-resolution.js";
4
4
  import { resolveAwaitingInputReason } from "../awaiting-input-reason.js";
5
- import { appendDelegationObservedEvent } from "../delegation-audit.js";
6
- import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isResolvedLinearState, isTerminalDelegationState, mergeIssueMetadata, resolveReDelegationResume, } from "./decision-helpers.js";
5
+ import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isResolvedLinearState, isTerminalDelegationState, resolveReDelegationResume, } from "./decision-helpers.js";
6
+ import { isDelegatedToPatchRelay, resolveDelegationTruth } from "./delegation-truth.js";
7
+ import { syncIssueDependencies } from "./issue-dependency-sync.js";
8
+ import { resolveLinkedPrAdoption } from "./linked-pr-adoption.js";
7
9
  import { buildOperatorRetryEvent } from "../operator-retry-event.js";
8
- import { resolveLinkedPullRequest } from "../linear-linked-pr-reconciliation.js";
9
- import { readRemotePrState } from "../remote-pr-state.js";
10
- import { deriveLinkedPrAdoptionOutcome } from "../delegation-linked-pr.js";
11
10
  export class DesiredStageRecorder {
12
11
  db;
13
12
  linearProvider;
@@ -30,12 +29,13 @@ export class DesiredStageRecorder {
30
29
  const triggerAllowed = triggerEventAllowed(params.project, params.normalized.triggerEvent);
31
30
  const incomingAgentSessionId = params.normalized.agentSession?.id;
32
31
  const hasPendingWake = this.db.issueSessions.peekIssueSessionWake(params.project.id, normalizedIssue.id) !== undefined;
33
- if (!existingIssue && !this.isDelegatedToPatchRelay(params.project, normalizedIssue) && !incomingAgentSessionId) {
32
+ if (!existingIssue && !isDelegatedToPatchRelay(this.db, params.project, normalizedIssue) && !incomingAgentSessionId) {
34
33
  return { issue: undefined, wakeRunType: undefined, delegated: false };
35
34
  }
36
- const syncResult = await this.syncIssueDependencies(params.project.id, normalizedIssue);
35
+ const syncResult = await syncIssueDependencies(this.db, this.linearProvider, params.project.id, normalizedIssue);
37
36
  const hydratedIssue = syncResult.issue;
38
- const delegation = this.resolveDelegationTruth({
37
+ const delegation = resolveDelegationTruth({
38
+ db: this.db,
39
39
  project: params.project,
40
40
  normalizedIssue,
41
41
  hydratedIssue,
@@ -47,7 +47,7 @@ export class DesiredStageRecorder {
47
47
  activeRunId: activeRun?.id,
48
48
  });
49
49
  const delegated = delegation.delegated;
50
- const linkedPrAdoption = await this.resolveLinkedPrAdoption({
50
+ const linkedPrAdoption = await resolveLinkedPrAdoption({
51
51
  project: params.project,
52
52
  issue: hydratedIssue,
53
53
  existingIssue,
@@ -324,115 +324,4 @@ export class DesiredStageRecorder {
324
324
  delegated,
325
325
  };
326
326
  }
327
- isDelegatedToPatchRelay(project, issue) {
328
- const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
329
- if (!installation?.actorId)
330
- return false;
331
- return issue.delegateId === installation.actorId;
332
- }
333
- resolveDelegationTruth(params) {
334
- const previousDelegated = params.existingIssue?.delegatedToPatchRelay;
335
- const observedDelegated = this.isDelegatedToPatchRelay(params.project, params.hydratedIssue);
336
- const explicitDelegateSignal = params.triggerEvent === "delegateChanged";
337
- const hasObservedDelegate = params.hydratedIssue.delegateId !== undefined;
338
- let delegated = observedDelegated;
339
- let reason = hasObservedDelegate
340
- ? "delegate_id_present"
341
- : `missing_delegate_identity_after_${params.hydration}`;
342
- if (!hasObservedDelegate && !explicitDelegateSignal && previousDelegated !== undefined) {
343
- delegated = previousDelegated;
344
- reason = `preserved_previous_delegation_after_${params.hydration}`;
345
- }
346
- if (previousDelegated !== delegated
347
- || params.hydration === "live_linear_failed"
348
- || (!hasObservedDelegate && previousDelegated !== undefined)) {
349
- appendDelegationObservedEvent(this.db, {
350
- projectId: params.project.id,
351
- linearIssueId: params.normalizedIssue.id,
352
- payload: {
353
- source: "linear_webhook",
354
- webhookId: params.webhookId,
355
- triggerEvent: params.triggerEvent,
356
- ...(params.actorId ? { actorId: params.actorId } : {}),
357
- ...(params.hydratedIssue.delegateId ? { observedDelegateId: params.hydratedIssue.delegateId } : {}),
358
- ...(previousDelegated !== undefined ? { previousDelegatedToPatchRelay: previousDelegated } : {}),
359
- observedDelegatedToPatchRelay: observedDelegated,
360
- appliedDelegatedToPatchRelay: delegated,
361
- hydration: params.hydration,
362
- ...(params.activeRunId !== undefined ? { activeRunId: params.activeRunId } : {}),
363
- decision: "none",
364
- reason,
365
- },
366
- });
367
- }
368
- return { delegated };
369
- }
370
- async syncIssueDependencies(projectId, issue) {
371
- let source = issue;
372
- let hydration = "webhook_only";
373
- if (!source.relationsKnown) {
374
- const linear = await this.linearProvider.forProject(projectId);
375
- if (linear) {
376
- try {
377
- source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
378
- hydration = "live_linear";
379
- }
380
- catch {
381
- // Preserve existing dependency rows when webhook relation data is incomplete.
382
- hydration = "live_linear_failed";
383
- }
384
- }
385
- }
386
- if (source.relationsKnown) {
387
- this.db.issues.replaceIssueDependencies({
388
- projectId,
389
- linearIssueId: source.id,
390
- blockers: source.blockedBy.map((blocker) => ({
391
- blockerLinearIssueId: blocker.id,
392
- ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
393
- ...(blocker.title ? { blockerTitle: blocker.title } : {}),
394
- ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
395
- ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
396
- })),
397
- });
398
- }
399
- this.db.issues.replaceIssueParentLink({
400
- projectId,
401
- childLinearIssueId: source.id,
402
- parentLinearIssueId: source.parentId ?? null,
403
- });
404
- return { issue: source, hydration };
405
- }
406
- async resolveLinkedPrAdoption(params) {
407
- if (!params.delegated)
408
- return undefined;
409
- if (params.triggerEvent !== "delegateChanged")
410
- return undefined;
411
- if (params.existingIssue?.prNumber !== undefined)
412
- return undefined;
413
- const resolution = resolveLinkedPullRequest(params.issue.attachments, params.project.github?.repoFullName);
414
- if (resolution.kind === "none")
415
- return undefined;
416
- if (resolution.kind === "ambiguous") {
417
- return {
418
- factoryState: "awaiting_input",
419
- pendingRunType: null,
420
- pendingRunContext: undefined,
421
- issueUpdates: {},
422
- };
423
- }
424
- const remote = await readRemotePrState(resolution.reference.repoFullName, resolution.reference.prNumber);
425
- if (!remote) {
426
- return {
427
- factoryState: "awaiting_input",
428
- pendingRunType: null,
429
- pendingRunContext: undefined,
430
- issueUpdates: {
431
- prNumber: resolution.reference.prNumber,
432
- prUrl: resolution.reference.url,
433
- },
434
- };
435
- }
436
- return deriveLinkedPrAdoptionOutcome(params.project, resolution.reference.prNumber, remote);
437
- }
438
327
  }
@@ -0,0 +1,45 @@
1
+ import { mergeIssueMetadata } from "./decision-helpers.js";
2
+ /**
3
+ * Brings the local dependency / parent-link state for `issue` up to date.
4
+ * If the webhook payload doesn't already include relation data we fetch it
5
+ * from Linear directly so subsequent decisions don't operate on a
6
+ * stale-by-omission snapshot. Returns the resolved `IssueMetadata` plus a
7
+ * label describing where the relation data came from (used by the audit
8
+ * trail).
9
+ */
10
+ export async function syncIssueDependencies(db, linearProvider, projectId, issue) {
11
+ let source = issue;
12
+ let hydration = "webhook_only";
13
+ if (!source.relationsKnown) {
14
+ const linear = await linearProvider.forProject(projectId);
15
+ if (linear) {
16
+ try {
17
+ source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
18
+ hydration = "live_linear";
19
+ }
20
+ catch {
21
+ // Preserve existing dependency rows when webhook relation data is incomplete.
22
+ hydration = "live_linear_failed";
23
+ }
24
+ }
25
+ }
26
+ if (source.relationsKnown) {
27
+ db.issues.replaceIssueDependencies({
28
+ projectId,
29
+ linearIssueId: source.id,
30
+ blockers: source.blockedBy.map((blocker) => ({
31
+ blockerLinearIssueId: blocker.id,
32
+ ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
33
+ ...(blocker.title ? { blockerTitle: blocker.title } : {}),
34
+ ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
35
+ ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
36
+ })),
37
+ });
38
+ }
39
+ db.issues.replaceIssueParentLink({
40
+ projectId,
41
+ childLinearIssueId: source.id,
42
+ parentLinearIssueId: source.parentId ?? null,
43
+ });
44
+ return { issue: source, hydration };
45
+ }
@@ -0,0 +1,41 @@
1
+ import { resolveLinkedPullRequest } from "../linear-linked-pr-reconciliation.js";
2
+ import { readRemotePrState } from "../remote-pr-state.js";
3
+ import { deriveLinkedPrAdoptionOutcome } from "../delegation-linked-pr.js";
4
+ /**
5
+ * On `delegateChanged` for a newly-delegated issue with no recorded PR yet,
6
+ * try to adopt any pull request referenced in Linear attachments. Returns
7
+ * the desired stage / pending-run shape, or `undefined` if no adoption
8
+ * applies (wrong trigger, not delegated, PR already tracked, no candidate).
9
+ */
10
+ export async function resolveLinkedPrAdoption(input) {
11
+ if (!input.delegated)
12
+ return undefined;
13
+ if (input.triggerEvent !== "delegateChanged")
14
+ return undefined;
15
+ if (input.existingIssue?.prNumber !== undefined)
16
+ return undefined;
17
+ const resolution = resolveLinkedPullRequest(input.issue.attachments, input.project.github?.repoFullName);
18
+ if (resolution.kind === "none")
19
+ return undefined;
20
+ if (resolution.kind === "ambiguous") {
21
+ return {
22
+ factoryState: "awaiting_input",
23
+ pendingRunType: null,
24
+ pendingRunContext: undefined,
25
+ issueUpdates: {},
26
+ };
27
+ }
28
+ const remote = await readRemotePrState(resolution.reference.repoFullName, resolution.reference.prNumber);
29
+ if (!remote) {
30
+ return {
31
+ factoryState: "awaiting_input",
32
+ pendingRunType: null,
33
+ pendingRunContext: undefined,
34
+ issueUpdates: {
35
+ prNumber: resolution.reference.prNumber,
36
+ prUrl: resolution.reference.url,
37
+ },
38
+ };
39
+ }
40
+ return deriveLinkedPrAdoptionOutcome(input.project, resolution.reference.prNumber, remote);
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.68.1",
3
+ "version": "0.68.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {