patchrelay 0.58.0 → 0.59.1

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.58.0",
4
- "commit": "a14713655229",
5
- "builtAt": "2026-05-02T10:15:39.171Z"
3
+ "version": "0.59.1",
4
+ "commit": "1e520e0829e3",
5
+ "builtAt": "2026-05-02T23:50:20.540Z"
6
6
  }
@@ -430,6 +430,7 @@ function buildCiEntry(params) {
430
430
  const owner = deriveCiOwner({
431
431
  delegatedToPatchRelay,
432
432
  gateCheckStatus,
433
+ activeRunId: issue.activeRunId,
433
434
  factoryState: issue.factoryState,
434
435
  reviewDecision,
435
436
  reviewRequested,
@@ -461,6 +462,9 @@ function buildCiEntry(params) {
461
462
  };
462
463
  }
463
464
  function deriveCiOwner(params) {
465
+ if (params.activeRunId !== undefined) {
466
+ return "patchrelay";
467
+ }
464
468
  const headAdvancedPastBlockingReview = Boolean(params.currentHeadSha
465
469
  && params.latestBlockingReviewHeadSha
466
470
  && params.currentHeadSha !== params.latestBlockingReviewHeadSha);
@@ -28,7 +28,11 @@ export async function handleLinearCommand(params) {
28
28
  ? "No Linear workspaces connected.\n"
29
29
  : `${result.workspaces.map((workspace) => {
30
30
  const name = workspace.installation.workspaceKey ?? workspace.installation.workspaceName ?? `installation-${workspace.installation.id}`;
31
- return `${name} repos=${workspace.linkedRepos.length} teams=${workspace.teams.length} projects=${workspace.projects.length}`;
31
+ const health = workspace.installation.healthStatus && workspace.installation.healthStatus !== "ok"
32
+ ? ` health=${workspace.installation.healthStatus}`
33
+ : "";
34
+ const reason = workspace.installation.healthReason ? ` ${workspace.installation.healthReason}` : "";
35
+ return `${name} repos=${workspace.linkedRepos.length} teams=${workspace.teams.length} projects=${workspace.projects.length}${health}${reason}`;
32
36
  }).join("\n")}\n`);
33
37
  return 0;
34
38
  }
@@ -24,20 +24,24 @@ export class LinearInstallationStore {
24
24
  scopes_json = ?,
25
25
  token_type = COALESCE(?, token_type),
26
26
  expires_at = COALESCE(?, expires_at),
27
+ health_status = 'ok',
28
+ health_reason = NULL,
29
+ health_updated_at = ?,
27
30
  updated_at = ?
28
31
  WHERE id = ?
29
32
  `)
30
- .run(params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, existing.id);
33
+ .run(params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, now, existing.id);
31
34
  return this.getLinearInstallation(existing.id);
32
35
  }
33
36
  const result = this.connection
34
37
  .prepare(`
35
38
  INSERT INTO linear_installations (
36
39
  workspace_id, workspace_name, workspace_key, actor_id, actor_name,
37
- access_token_ciphertext, refresh_token_ciphertext, scopes_json, token_type, expires_at, created_at, updated_at
38
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
40
+ access_token_ciphertext, refresh_token_ciphertext, scopes_json, token_type, expires_at,
41
+ health_status, health_reason, health_updated_at, created_at, updated_at
42
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ok', NULL, ?, ?, ?)
39
43
  `)
40
- .run(params.workspaceId ?? null, params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, now);
44
+ .run(params.workspaceId ?? null, params.workspaceName ?? null, params.workspaceKey ?? null, params.actorId ?? null, params.actorName ?? null, params.accessTokenCiphertext, params.refreshTokenCiphertext ?? null, params.scopesJson, params.tokenType ?? null, params.expiresAt ?? null, now, now, now);
41
45
  return this.getLinearInstallation(Number(result.lastInsertRowid));
42
46
  }
43
47
  saveLinearInstallation(params) {
@@ -67,6 +71,20 @@ export class LinearInstallationStore {
67
71
  WHERE id = ?`)
68
72
  .run(params.workspaceName ?? null, params.workspaceKey ?? null, isoNow(), id);
69
73
  }
74
+ updateLinearInstallationHealth(id, params) {
75
+ const healthUpdatedAt = params.healthUpdatedAt ?? isoNow();
76
+ this.connection
77
+ .prepare(`
78
+ UPDATE linear_installations
79
+ SET health_status = ?,
80
+ health_reason = ?,
81
+ health_updated_at = ?,
82
+ updated_at = ?
83
+ WHERE id = ?
84
+ `)
85
+ .run(params.healthStatus, params.healthReason ?? null, healthUpdatedAt, healthUpdatedAt, id);
86
+ return this.getLinearInstallation(id);
87
+ }
70
88
  getLinearInstallation(id) {
71
89
  const row = this.connection
72
90
  .prepare("SELECT * FROM linear_installations WHERE id = ?")
@@ -252,6 +270,9 @@ function mapLinearInstallation(row) {
252
270
  scopesJson: String(row.scopes_json),
253
271
  ...(row.token_type === null ? {} : { tokenType: String(row.token_type) }),
254
272
  ...(row.expires_at === null ? {} : { expiresAt: String(row.expires_at) }),
273
+ ...(row.health_status === null || row.health_status === undefined ? {} : { healthStatus: row.health_status }),
274
+ ...(row.health_reason === null || row.health_reason === undefined ? {} : { healthReason: String(row.health_reason) }),
275
+ ...(row.health_updated_at === null || row.health_updated_at === undefined ? {} : { healthUpdatedAt: String(row.health_updated_at) }),
255
276
  createdAt: String(row.created_at),
256
277
  updatedAt: String(row.updated_at),
257
278
  };
@@ -141,6 +141,9 @@ CREATE TABLE IF NOT EXISTS linear_installations (
141
141
  scopes_json TEXT NOT NULL,
142
142
  token_type TEXT,
143
143
  expires_at TEXT,
144
+ health_status TEXT NOT NULL DEFAULT 'ok',
145
+ health_reason TEXT,
146
+ health_updated_at TEXT,
144
147
  created_at TEXT NOT NULL,
145
148
  updated_at TEXT NOT NULL
146
149
  );
@@ -330,6 +333,9 @@ export function runPatchRelayMigrations(connection) {
330
333
  addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
331
334
  addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
332
335
  addColumnIfMissing(connection, "issues", "last_attempted_failure_at", "TEXT");
336
+ addColumnIfMissing(connection, "linear_installations", "health_status", "TEXT NOT NULL DEFAULT 'ok'");
337
+ addColumnIfMissing(connection, "linear_installations", "health_reason", "TEXT");
338
+ addColumnIfMissing(connection, "linear_installations", "health_updated_at", "TEXT");
333
339
  removeRetiredIssueColumnsIfPresent(connection);
334
340
  }
335
341
  function addColumnIfMissing(connection, table, column, definition) {
@@ -84,6 +84,13 @@ export class InterruptedRunRecovery {
84
84
  queueRepairAttempts: issue.queueRepairAttempts - 1,
85
85
  });
86
86
  }
87
+ else if (isRequestedChangesRunType(run.runType) && issue.reviewFixAttempts > 0) {
88
+ this.db.issueSessions.upsertIssueWithLease(lease, {
89
+ projectId: issue.projectId,
90
+ linearIssueId: issue.linearIssueId,
91
+ reviewFixAttempts: issue.reviewFixAttempts - 1,
92
+ });
93
+ }
87
94
  if (run.runType === "ci_repair" || run.runType === "queue_repair") {
88
95
  this.db.issueSessions.upsertIssueWithLease(lease, {
89
96
  projectId: issue.projectId,
@@ -528,13 +528,34 @@ export class DatabaseBackedLinearClientProvider {
528
528
  if (!installation) {
529
529
  return undefined;
530
530
  }
531
+ if (installation.healthStatus === "revoked") {
532
+ this.logger.warn({
533
+ installationId: installation.id,
534
+ workspaceName: installation.workspaceName,
535
+ workspaceKey: installation.workspaceKey,
536
+ healthReason: installation.healthReason,
537
+ }, "Linear installation is revoked; reconnect before using it");
538
+ return undefined;
539
+ }
531
540
  const encryptionKey = this.config.linear.tokenEncryptionKey;
532
541
  let accessToken = decryptSecret(installation.accessTokenCiphertext, encryptionKey);
533
542
  const refreshToken = installation.refreshTokenCiphertext
534
543
  ? decryptSecret(installation.refreshTokenCiphertext, encryptionKey)
535
544
  : undefined;
536
545
  if (shouldRefreshToken(installation.expiresAt) && refreshToken) {
537
- const refreshed = await refreshLinearOAuthToken(this.config, refreshToken);
546
+ let refreshed;
547
+ try {
548
+ refreshed = await refreshLinearOAuthToken(this.config, refreshToken);
549
+ }
550
+ catch (error) {
551
+ const healthReason = `Linear OAuth token refresh failed: ${error instanceof Error ? error.message : String(error)}`;
552
+ this.db.linearInstallations.updateLinearInstallationHealth(installation.id, {
553
+ healthStatus: "auth_error",
554
+ healthReason,
555
+ });
556
+ this.logger.error({ installationId: installation.id, workspaceName: installation.workspaceName, error: healthReason }, "Linear OAuth token refresh failed; reconnect this installation");
557
+ throw error;
558
+ }
538
559
  accessToken = refreshed.accessToken;
539
560
  this.db.linearInstallations.updateLinearInstallationTokens(installation.id, {
540
561
  accessTokenCiphertext: encryptSecret(refreshed.accessToken, encryptionKey),
@@ -544,6 +565,12 @@ export class DatabaseBackedLinearClientProvider {
544
565
  scopesJson: JSON.stringify(refreshed.scopes),
545
566
  ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}),
546
567
  });
568
+ if (installation.healthStatus === "auth_error") {
569
+ this.db.linearInstallations.updateLinearInstallationHealth(installation.id, {
570
+ healthStatus: "ok",
571
+ healthReason: null,
572
+ });
573
+ }
547
574
  }
548
575
  return new LinearGraphqlClient({
549
576
  accessToken,
@@ -133,9 +133,20 @@ export class LinearOAuthService {
133
133
  ...(identity.workspaceKey ? { workspaceKey: identity.workspaceKey } : {}),
134
134
  });
135
135
  }
136
+ if (installation.healthStatus === "auth_error") {
137
+ this.stores.linearInstallations.updateLinearInstallationHealth(installation.id, {
138
+ healthStatus: "ok",
139
+ healthReason: null,
140
+ });
141
+ }
136
142
  }
137
143
  catch (error) {
138
- this.logger.debug({ installationId: installation.id, error: error instanceof Error ? error.message : String(error) }, "Failed to refresh installation identity (non-blocking)");
144
+ const healthReason = `Linear viewer lookup failed: ${error instanceof Error ? error.message : String(error)}`;
145
+ this.stores.linearInstallations.updateLinearInstallationHealth(installation.id, {
146
+ healthStatus: "auth_error",
147
+ healthReason,
148
+ });
149
+ this.logger.debug({ installationId: installation.id, error: healthReason }, "Failed to refresh installation identity (non-blocking)");
139
150
  }
140
151
  }
141
152
  getInstallationSummary(installation) {
@@ -146,6 +157,9 @@ export class LinearOAuthService {
146
157
  ...(installation.actorName ? { actorName: installation.actorName } : {}),
147
158
  ...(installation.actorId ? { actorId: installation.actorId } : {}),
148
159
  ...(installation.expiresAt ? { expiresAt: installation.expiresAt } : {}),
160
+ healthStatus: installation.healthStatus ?? "ok",
161
+ ...(installation.healthReason ? { healthReason: installation.healthReason } : {}),
162
+ ...(installation.healthUpdatedAt ? { healthUpdatedAt: installation.healthUpdatedAt } : {}),
149
163
  };
150
164
  }
151
165
  }
package/dist/service.js CHANGED
@@ -87,7 +87,18 @@ export class PatchRelayService {
87
87
  anyLinearConnected = true;
88
88
  }
89
89
  else {
90
- this.logger.warn({ projectId: project.id }, "No Linear installation linked — run 'patchrelay linear connect' and then 'patchrelay repo link' to authorize");
90
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
91
+ if (installation?.healthStatus && installation.healthStatus !== "ok") {
92
+ this.logger.warn({
93
+ projectId: project.id,
94
+ installationId: installation.id,
95
+ healthStatus: installation.healthStatus,
96
+ healthReason: installation.healthReason,
97
+ }, "Linear installation is unhealthy — run 'patchrelay linear connect' to re-authorize before processing this project");
98
+ }
99
+ else {
100
+ this.logger.warn({ projectId: project.id }, "No Linear installation linked — run 'patchrelay linear connect' and then 'patchrelay repo link' to authorize");
101
+ }
91
102
  }
92
103
  }
93
104
  catch (error) {
@@ -35,7 +35,7 @@ export class WebhookHandler {
35
35
  this.enqueueIssue = enqueueIssue;
36
36
  this.logger = logger;
37
37
  this.feed = feed;
38
- this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger);
38
+ this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger, feed);
39
39
  this.issueRemovalHandler = new IssueRemovalHandler(db, feed);
40
40
  this.commentWakeHandler = new CommentWakeHandler(db, codex, logger, feed);
41
41
  this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, logger, feed);
@@ -2,20 +2,18 @@ export class InstallationWebhookHandler {
2
2
  config;
3
3
  stores;
4
4
  logger;
5
- constructor(config, stores, logger) {
5
+ feed;
6
+ constructor(config, stores, logger, feed) {
6
7
  this.config = config;
7
8
  this.stores = stores;
8
9
  this.logger = logger;
10
+ this.feed = feed;
9
11
  }
10
12
  handle(normalized) {
11
13
  if (!normalized.installation)
12
14
  return;
13
15
  if (normalized.triggerEvent === "installationPermissionsChanged") {
14
- const matchingInstallations = normalized.installation.appUserId
15
- ? this.stores.linearInstallations
16
- .listLinearInstallations()
17
- .filter((installation) => installation.actorId === normalized.installation?.appUserId)
18
- : [];
16
+ const matchingInstallations = this.findMatchingInstallations(normalized.installation);
19
17
  const links = this.stores.linearInstallations.listProjectInstallations();
20
18
  const impactedProjects = matchingInstallations.flatMap((installation) => links
21
19
  .filter((link) => link.installationId === installation.id)
@@ -25,6 +23,23 @@ export class InstallationWebhookHandler {
25
23
  const addedMatches = normalized.installation?.addedTeamIds.some((teamId) => project?.linearTeamIds.includes(teamId)) ?? false;
26
24
  return { projectId: link.projectId, removedMatches, addedMatches };
27
25
  }));
26
+ const impactedProjectIds = impactedProjects
27
+ .filter((project) => project.removedMatches)
28
+ .map((project) => project.projectId);
29
+ const healthReason = buildPermissionHealthReason(normalized.installation, impactedProjectIds);
30
+ for (const installation of matchingInstallations) {
31
+ this.stores.linearInstallations.updateLinearInstallationHealth(installation.id, {
32
+ healthStatus: "permissions_changed",
33
+ healthReason,
34
+ });
35
+ }
36
+ this.feed?.publish({
37
+ level: "warn",
38
+ kind: "linear",
39
+ status: "permissions_changed",
40
+ summary: "Linear app permissions changed",
41
+ detail: healthReason,
42
+ });
28
43
  this.logger.warn({
29
44
  appUserId: normalized.installation.appUserId,
30
45
  addedTeamIds: normalized.installation.addedTeamIds,
@@ -35,9 +50,25 @@ export class InstallationWebhookHandler {
35
50
  return;
36
51
  }
37
52
  if (normalized.triggerEvent === "installationRevoked") {
53
+ const matchingInstallations = this.findMatchingInstallations(normalized.installation, { allowOauthClientFallback: true });
54
+ const healthReason = "Linear OAuth app installation was revoked. Reconnect the affected workspace before PatchRelay can update Linear.";
55
+ for (const installation of matchingInstallations) {
56
+ this.stores.linearInstallations.updateLinearInstallationHealth(installation.id, {
57
+ healthStatus: "revoked",
58
+ healthReason,
59
+ });
60
+ }
61
+ this.feed?.publish({
62
+ level: "error",
63
+ kind: "linear",
64
+ status: "revoked",
65
+ summary: "Linear OAuth app installation was revoked",
66
+ detail: healthReason,
67
+ });
38
68
  this.logger.warn({
39
69
  organizationId: normalized.installation.organizationId,
40
70
  oauthClientId: normalized.installation.oauthClientId,
71
+ installationIds: matchingInstallations.map((installation) => installation.id),
41
72
  }, "Linear OAuth app installation was revoked; reconnect affected projects");
42
73
  return;
43
74
  }
@@ -49,4 +80,32 @@ export class InstallationWebhookHandler {
49
80
  }, "Received Linear app-user notification webhook");
50
81
  }
51
82
  }
83
+ findMatchingInstallations(metadata, options) {
84
+ const installations = this.stores.linearInstallations.listLinearInstallations();
85
+ const specificMatches = installations.filter((installation) => Boolean((metadata.appUserId && installation.actorId === metadata.appUserId) ||
86
+ (metadata.organizationId && installation.workspaceId === metadata.organizationId)));
87
+ if (specificMatches.length > 0) {
88
+ return specificMatches;
89
+ }
90
+ if (options?.allowOauthClientFallback &&
91
+ metadata.oauthClientId &&
92
+ metadata.oauthClientId === this.config.linear.oauth?.clientId) {
93
+ return installations;
94
+ }
95
+ return [];
96
+ }
97
+ }
98
+ function buildPermissionHealthReason(metadata, impactedProjectIds) {
99
+ const accessChange = metadata.removedTeamIds.length > 0
100
+ ? `removed team access: ${metadata.removedTeamIds.join(", ")}`
101
+ : metadata.addedTeamIds.length > 0
102
+ ? `added team access: ${metadata.addedTeamIds.join(", ")}`
103
+ : "team access changed";
104
+ const allPublicTeams = metadata.canAccessAllPublicTeams === false
105
+ ? " App no longer has access to all public teams."
106
+ : "";
107
+ const impactedProjects = impactedProjectIds.length > 0
108
+ ? ` Impacted PatchRelay projects: ${impactedProjectIds.join(", ")}.`
109
+ : " Verify routed teams and reconnect if updates start failing.";
110
+ return `Linear app permissions changed (${accessChange}).${allPublicTeams}${impactedProjects}`;
52
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {