patchrelay 0.8.3 → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.8.3",
4
- "commit": "ef05604cd432",
5
- "builtAt": "2026-03-18T23:26:09.415Z"
3
+ "version": "0.8.5",
4
+ "commit": "1a8f1cf74d1f",
5
+ "builtAt": "2026-03-19T10:46:11.106Z"
6
6
  }
@@ -1,11 +1,53 @@
1
1
  import { resolveWorkflowStageConfig } from "./workflow-policy.js";
2
2
  const STATUS_MARKER = "<!-- patchrelay:status-comment -->";
3
+ function normalizeLinearState(value) {
4
+ const trimmed = value?.trim();
5
+ return trimmed ? trimmed.toLowerCase() : undefined;
6
+ }
3
7
  export function resolveActiveLinearState(project, stage, workflowDefinitionId) {
4
8
  return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.activeState;
5
9
  }
6
10
  export function resolveFallbackLinearState(project, stage, workflowDefinitionId) {
7
11
  return resolveWorkflowStageConfig(project, stage, workflowDefinitionId)?.fallbackState;
8
12
  }
13
+ export function resolveDoneLinearState(issue) {
14
+ const typedMatch = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
15
+ if (typedMatch?.name) {
16
+ return typedMatch.name;
17
+ }
18
+ const nameMatch = issue.workflowStates.find((state) => {
19
+ const normalized = normalizeLinearState(state.name);
20
+ return normalized === "done" || normalized === "completed" || normalized === "complete";
21
+ });
22
+ return nameMatch?.name;
23
+ }
24
+ export function resolveAuthoritativeLinearStopState(issue) {
25
+ const currentStateName = issue.stateName?.trim();
26
+ const normalizedCurrentState = normalizeLinearState(currentStateName);
27
+ if (!currentStateName || !normalizedCurrentState) {
28
+ return undefined;
29
+ }
30
+ const currentWorkflowState = issue.workflowStates.find((state) => normalizeLinearState(state.name) === normalizedCurrentState);
31
+ if (normalizeLinearState(currentWorkflowState?.type) === "completed") {
32
+ return {
33
+ stateName: currentWorkflowState?.name ?? currentStateName,
34
+ lifecycleStatus: "completed",
35
+ };
36
+ }
37
+ if (normalizedCurrentState === "human needed") {
38
+ return {
39
+ stateName: currentStateName,
40
+ lifecycleStatus: "paused",
41
+ };
42
+ }
43
+ if (normalizedCurrentState === "done" || normalizedCurrentState === "completed" || normalizedCurrentState === "complete") {
44
+ return {
45
+ stateName: currentStateName,
46
+ lifecycleStatus: "completed",
47
+ };
48
+ }
49
+ return undefined;
50
+ }
9
51
  export function buildRunningStatusComment(params) {
10
52
  return [
11
53
  STATUS_MARKER,
@@ -3,7 +3,7 @@ import { reconcileIssue } from "./reconciliation-engine.js";
3
3
  import { buildReconciliationSnapshot } from "./reconciliation-snapshot-builder.js";
4
4
  import { syncFailedStageToLinear } from "./stage-failure.js";
5
5
  import { parseStageHandoff } from "./stage-handoff.js";
6
- import { resolveFallbackLinearState } from "./linear-workflow.js";
6
+ import { resolveAuthoritativeLinearStopState, resolveDoneLinearState, resolveFallbackLinearState } from "./linear-workflow.js";
7
7
  import { resolveDefaultTransitionTarget, transitionTargetAllowed } from "./workflow-policy.js";
8
8
  import { buildFailedStageReport, buildPendingMaterializationThread, buildStageReport, countEventMethods, extractStageSummary, extractTurnId, resolveStageRunStatus, summarizeCurrentThread, } from "./stage-reporting.js";
9
9
  import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
@@ -241,7 +241,7 @@ export class ServiceStageFinalizer {
241
241
  }
242
242
  async maybeQueueAutomaticTransition(stageRun, report) {
243
243
  const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
244
- if (!refreshedIssue || refreshedIssue.desiredStage) {
244
+ if (!refreshedIssue) {
245
245
  return;
246
246
  }
247
247
  const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
@@ -260,6 +260,14 @@ export class ServiceStageFinalizer {
260
260
  if (!linearIssue) {
261
261
  return;
262
262
  }
263
+ const authoritativeStopState = resolveAuthoritativeLinearStopState(linearIssue);
264
+ if (authoritativeStopState) {
265
+ this.syncIssueToAuthoritativeLinearStopState(stageRun, refreshedIssue, authoritativeStopState);
266
+ return;
267
+ }
268
+ if (refreshedIssue.desiredStage) {
269
+ return;
270
+ }
263
271
  const continuationPrecondition = await this.checkAutomaticContinuationPreconditions(stageRun, refreshedIssue, linear, linearIssue);
264
272
  if (!continuationPrecondition.allowed) {
265
273
  this.feed?.publish({
@@ -348,6 +356,32 @@ export class ServiceStageFinalizer {
348
356
  lifecycleStatus: "queued",
349
357
  });
350
358
  }
359
+ syncIssueToAuthoritativeLinearStopState(stageRun, issue, stopState) {
360
+ this.stores.workflowCoordinator.setIssueDesiredStage(stageRun.projectId, stageRun.linearIssueId, undefined, {
361
+ lifecycleStatus: stopState.lifecycleStatus,
362
+ });
363
+ this.stores.workflowCoordinator.upsertTrackedIssue({
364
+ projectId: stageRun.projectId,
365
+ linearIssueId: stageRun.linearIssueId,
366
+ currentLinearState: stopState.stateName,
367
+ lifecycleStatus: stopState.lifecycleStatus,
368
+ });
369
+ this.feed?.publish({
370
+ level: "info",
371
+ kind: "workflow",
372
+ issueKey: issue.issueKey,
373
+ projectId: stageRun.projectId,
374
+ stage: stageRun.stage,
375
+ ...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
376
+ status: stopState.lifecycleStatus === "completed" ? "completed" : "transition_suppressed",
377
+ summary: stopState.lifecycleStatus === "completed"
378
+ ? `Kept workflow completed after ${stageRun.stage}`
379
+ : `Ignored stale ${stageRun.stage} completion`,
380
+ detail: stopState.lifecycleStatus === "completed"
381
+ ? `Live Linear state is already ${stopState.stateName}, so PatchRelay kept the issue finished.`
382
+ : `Live Linear state is already ${stopState.stateName}, so PatchRelay kept the issue paused.`,
383
+ });
384
+ }
351
385
  async checkAutomaticContinuationPreconditions(stageRun, issue, linear, linearIssue) {
352
386
  const actorProfile = await linear.getActorProfile().catch(() => undefined);
353
387
  if (actorProfile?.actorId && linearIssue.delegateId && linearIssue.delegateId !== actorProfile.actorId) {
@@ -651,14 +685,3 @@ function normalizeLinearState(value) {
651
685
  const trimmed = value?.trim();
652
686
  return trimmed ? trimmed.toLowerCase() : undefined;
653
687
  }
654
- function resolveDoneLinearState(issue) {
655
- const typedMatch = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
656
- if (typedMatch?.name) {
657
- return typedMatch.name;
658
- }
659
- const nameMatch = issue.workflowStates.find((state) => {
660
- const normalized = normalizeLinearState(state.name);
661
- return normalized === "done" || normalized === "completed" || normalized === "complete";
662
- });
663
- return nameMatch?.name;
664
- }
@@ -1,3 +1,4 @@
1
+ import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
1
2
  import { buildStageLaunchPlan, isCodexThreadId } from "./stage-launch.js";
2
3
  import { syncFailedStageToLinear } from "./stage-failure.js";
3
4
  import { buildFailedStageReport } from "./stage-reporting.js";
@@ -47,6 +48,22 @@ export class ServiceStageRunner {
47
48
  if (!issue) {
48
49
  return;
49
50
  }
51
+ if (issue.lifecycleStatus === "completed" || issue.lifecycleStatus === "paused") {
52
+ this.feed?.publish({
53
+ level: "info",
54
+ kind: "workflow",
55
+ issueKey: issue.issueKey,
56
+ projectId: item.projectId,
57
+ stage: desiredStage,
58
+ ...(issue.selectedWorkflowId ? { workflowId: issue.selectedWorkflowId } : {}),
59
+ status: issue.lifecycleStatus === "completed" ? "completed" : "transition_suppressed",
60
+ summary: issue.lifecycleStatus === "completed"
61
+ ? `Skipped ${desiredStage} because the issue is already complete`
62
+ : `Skipped ${desiredStage} because the issue is already paused`,
63
+ detail: issue.currentLinearState ? `Live Linear state is ${issue.currentLinearState}.` : undefined,
64
+ });
65
+ return;
66
+ }
50
67
  const existingWorkspace = this.stores.workspaceOwnership.getWorkspaceOwnershipForIssue(item.projectId, item.issueId);
51
68
  const stageHistory = this.stores.issueWorkflows.listStageRunsForIssue(item.projectId, item.issueId);
52
69
  const previousStageRun = stageHistory.at(-1);
@@ -201,13 +218,28 @@ export class ServiceStageRunner {
201
218
  }
202
219
  async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
203
220
  const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
204
- if (existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
205
- return existing;
206
- }
207
221
  const liveIssue = await this.linearProvider
208
222
  .forProject(project.id)
209
223
  .then((linear) => linear?.getIssue(linearIssueId))
210
224
  .catch(() => undefined);
225
+ const authoritativeStopState = liveIssue ? resolveAuthoritativeLinearStopState(liveIssue) : undefined;
226
+ if (authoritativeStopState) {
227
+ this.stores.workflowCoordinator.setIssueDesiredStage(project.id, linearIssueId, undefined, {
228
+ lifecycleStatus: authoritativeStopState.lifecycleStatus,
229
+ });
230
+ return this.stores.workflowCoordinator.upsertTrackedIssue({
231
+ projectId: project.id,
232
+ linearIssueId,
233
+ ...(liveIssue?.identifier ? { issueKey: liveIssue.identifier } : existing?.issueKey ? { issueKey: existing.issueKey } : {}),
234
+ ...(liveIssue?.title ? { title: liveIssue.title } : existing?.title ? { title: existing.title } : {}),
235
+ ...(liveIssue?.url ? { issueUrl: liveIssue.url } : existing?.issueUrl ? { issueUrl: existing.issueUrl } : {}),
236
+ currentLinearState: authoritativeStopState.stateName,
237
+ lifecycleStatus: authoritativeStopState.lifecycleStatus,
238
+ });
239
+ }
240
+ if (!liveIssue && existing?.issueKey && existing.title && existing.issueUrl && existing.currentLinearState) {
241
+ return existing;
242
+ }
211
243
  return this.stores.workflowCoordinator.recordDesiredStage({
212
244
  projectId: project.id,
213
245
  linearIssueId,
@@ -39,7 +39,7 @@ export class WebhookDesiredStageRecorder {
39
39
  ...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
40
40
  ...(selectedWorkflowId !== undefined ? { selectedWorkflowId } : {}),
41
41
  ...(desiredStage ? { desiredStage } : {}),
42
- ...(options?.eventReceiptId !== undefined ? { desiredReceiptId: options.eventReceiptId } : {}),
42
+ ...(desiredStage && options?.eventReceiptId !== undefined ? { desiredReceiptId: options.eventReceiptId } : {}),
43
43
  ...(activeAgentSessionId !== undefined ? { activeAgentSessionId } : {}),
44
44
  lastWebhookAt: new Date().toISOString(),
45
45
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {