patchrelay 0.1.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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/config/patchrelay.example.json +5 -0
  4. package/dist/build-info.js +29 -0
  5. package/dist/build-info.json +6 -0
  6. package/dist/cli/data.js +461 -0
  7. package/dist/cli/formatters/json.js +3 -0
  8. package/dist/cli/formatters/text.js +119 -0
  9. package/dist/cli/index.js +761 -0
  10. package/dist/codex-app-server.js +353 -0
  11. package/dist/codex-types.js +1 -0
  12. package/dist/config-types.js +1 -0
  13. package/dist/config.js +494 -0
  14. package/dist/db/authoritative-ledger-store.js +437 -0
  15. package/dist/db/issue-workflow-store.js +690 -0
  16. package/dist/db/linear-installation-store.js +184 -0
  17. package/dist/db/migrations.js +183 -0
  18. package/dist/db/shared.js +101 -0
  19. package/dist/db/stage-event-store.js +33 -0
  20. package/dist/db/webhook-event-store.js +46 -0
  21. package/dist/db-ports.js +5 -0
  22. package/dist/db-types.js +1 -0
  23. package/dist/db.js +40 -0
  24. package/dist/file-permissions.js +40 -0
  25. package/dist/http.js +321 -0
  26. package/dist/index.js +69 -0
  27. package/dist/install.js +302 -0
  28. package/dist/installation-ports.js +1 -0
  29. package/dist/issue-query-service.js +68 -0
  30. package/dist/ledger-ports.js +1 -0
  31. package/dist/linear-client.js +338 -0
  32. package/dist/linear-oauth-service.js +131 -0
  33. package/dist/linear-oauth.js +154 -0
  34. package/dist/linear-types.js +1 -0
  35. package/dist/linear-workflow.js +78 -0
  36. package/dist/logging.js +62 -0
  37. package/dist/preflight.js +227 -0
  38. package/dist/project-resolution.js +51 -0
  39. package/dist/reconciliation-action-applier.js +55 -0
  40. package/dist/reconciliation-actions.js +1 -0
  41. package/dist/reconciliation-engine.js +312 -0
  42. package/dist/reconciliation-snapshot-builder.js +96 -0
  43. package/dist/reconciliation-types.js +1 -0
  44. package/dist/runtime-paths.js +89 -0
  45. package/dist/service-queue.js +49 -0
  46. package/dist/service-runtime.js +96 -0
  47. package/dist/service-stage-finalizer.js +348 -0
  48. package/dist/service-stage-runner.js +233 -0
  49. package/dist/service-webhook-processor.js +181 -0
  50. package/dist/service-webhooks.js +148 -0
  51. package/dist/service.js +139 -0
  52. package/dist/stage-agent-activity-publisher.js +33 -0
  53. package/dist/stage-event-ports.js +1 -0
  54. package/dist/stage-failure.js +92 -0
  55. package/dist/stage-launch.js +54 -0
  56. package/dist/stage-lifecycle-publisher.js +213 -0
  57. package/dist/stage-reporting.js +153 -0
  58. package/dist/stage-turn-input-dispatcher.js +102 -0
  59. package/dist/token-crypto.js +21 -0
  60. package/dist/types.js +5 -0
  61. package/dist/utils.js +163 -0
  62. package/dist/webhook-agent-session-handler.js +157 -0
  63. package/dist/webhook-archive.js +24 -0
  64. package/dist/webhook-comment-handler.js +89 -0
  65. package/dist/webhook-desired-stage-recorder.js +150 -0
  66. package/dist/webhook-event-ports.js +1 -0
  67. package/dist/webhook-installation-handler.js +57 -0
  68. package/dist/webhooks.js +301 -0
  69. package/dist/workflow-policy.js +42 -0
  70. package/dist/workflow-ports.js +1 -0
  71. package/dist/workflow-types.js +1 -0
  72. package/dist/worktree-manager.js +66 -0
  73. package/infra/patchrelay-reload.service +6 -0
  74. package/infra/patchrelay.path +11 -0
  75. package/infra/patchrelay.service +28 -0
  76. package/package.json +55 -0
  77. package/runtime.env.example +8 -0
  78. package/service.env.example +7 -0
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+ import { triggerEventAllowed } from "./project-resolution.js";
3
+ import { listRunnableStates, resolveWorkflowStage } from "./workflow-policy.js";
4
+ function trimPrompt(value) {
5
+ const trimmed = value?.trim();
6
+ return trimmed ? trimmed : undefined;
7
+ }
8
+ export class AgentSessionWebhookHandler {
9
+ stores;
10
+ turnInputDispatcher;
11
+ agentActivity;
12
+ constructor(stores, turnInputDispatcher, agentActivity) {
13
+ this.stores = stores;
14
+ this.turnInputDispatcher = turnInputDispatcher;
15
+ this.agentActivity = agentActivity;
16
+ }
17
+ async handle(params) {
18
+ const { normalized, project, issue, desiredStage, delegatedToPatchRelay } = params;
19
+ if (!normalized.agentSession?.id) {
20
+ return;
21
+ }
22
+ const promptBody = trimPrompt(normalized.agentSession.promptBody);
23
+ const promptContext = trimPrompt(normalized.agentSession.promptContext);
24
+ const issueControl = normalized.issue ? this.stores.issueControl.getIssueControl(project.id, normalized.issue.id) : undefined;
25
+ const activeRunLease = issueControl?.activeRunLeaseId !== undefined ? this.stores.runLeases.getRunLease(issueControl.activeRunLeaseId) : undefined;
26
+ const activeStage = activeRunLease?.stage;
27
+ const runnableWorkflow = normalized.issue?.stateName ? resolveWorkflowStage(project, normalized.issue.stateName) : undefined;
28
+ if (normalized.triggerEvent === "agentSessionCreated") {
29
+ if (!delegatedToPatchRelay) {
30
+ if (activeStage) {
31
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
32
+ type: "thought",
33
+ body: `PatchRelay is already running the ${activeStage} workflow for this issue. Delegate it to PatchRelay if you want automation to own the workflow, or keep replying here to steer the active run.`,
34
+ });
35
+ return;
36
+ }
37
+ const body = runnableWorkflow
38
+ ? `PatchRelay received your mention. Delegate the issue to PatchRelay to start the ${runnableWorkflow} workflow from the current \`${normalized.issue?.stateName}\` state.`
39
+ : `PatchRelay received your mention, but the issue is not in a runnable workflow state yet. Move it to one of: ${listRunnableStates(project).join(", ")}, then delegate it to PatchRelay.`;
40
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
41
+ type: "elicitation",
42
+ body,
43
+ });
44
+ return;
45
+ }
46
+ if (!desiredStage && !activeStage) {
47
+ const runnableStates = listRunnableStates(project).join(", ");
48
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
49
+ type: "elicitation",
50
+ body: `PatchRelay is delegated, but the issue is not in a runnable workflow state. Move it to one of: ${runnableStates}.`,
51
+ });
52
+ return;
53
+ }
54
+ if (desiredStage) {
55
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
56
+ type: "thought",
57
+ body: `PatchRelay received the delegation and is preparing the ${desiredStage} workflow.`,
58
+ });
59
+ return;
60
+ }
61
+ if (activeStage) {
62
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
63
+ type: "thought",
64
+ body: `PatchRelay is already running the ${activeStage} workflow for this issue.`,
65
+ });
66
+ }
67
+ return;
68
+ }
69
+ if (normalized.triggerEvent !== "agentPrompted") {
70
+ return;
71
+ }
72
+ if (!triggerEventAllowed(project, normalized.triggerEvent)) {
73
+ return;
74
+ }
75
+ if (activeRunLease && promptBody) {
76
+ const dedupeKey = buildPromptDedupeKey(normalized.agentSession.id, promptBody);
77
+ if (issueControl?.activeRunLeaseId !== undefined &&
78
+ this.stores.obligations.getObligationByDedupeKey({
79
+ runLeaseId: issueControl.activeRunLeaseId,
80
+ kind: "deliver_turn_input",
81
+ dedupeKey,
82
+ })) {
83
+ return;
84
+ }
85
+ const promptInput = ["New Linear agent prompt received while you are working.", "", promptBody].join("\n");
86
+ const source = `linear-agent-prompt:${normalized.agentSession.id}:${normalized.webhookId}`;
87
+ const obligationId = this.enqueueObligation(project.id, normalized.issue.id, activeRunLease.threadId, activeRunLease.turnId, source, promptInput, dedupeKey);
88
+ const flushResult = await this.turnInputDispatcher.flush({
89
+ id: issueControl?.activeRunLeaseId ?? 0,
90
+ projectId: project.id,
91
+ linearIssueId: normalized.issue.id,
92
+ ...(activeRunLease.threadId ? { threadId: activeRunLease.threadId } : {}),
93
+ ...(activeRunLease.turnId ? { turnId: activeRunLease.turnId } : {}),
94
+ }, {
95
+ ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
96
+ failureMessage: "Failed to deliver queued Linear agent prompt to active Codex turn",
97
+ });
98
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
99
+ type: "thought",
100
+ body: obligationId !== undefined && flushResult.deliveredObligationIds.includes(obligationId)
101
+ ? `PatchRelay routed your follow-up instructions into the active ${activeRunLease.stage} workflow.`
102
+ : `PatchRelay queued your follow-up instructions for delivery into the active ${activeRunLease.stage} workflow.`,
103
+ });
104
+ return;
105
+ }
106
+ if (!delegatedToPatchRelay && (promptBody || promptContext)) {
107
+ const body = runnableWorkflow
108
+ ? `PatchRelay received your prompt. Delegate the issue to PatchRelay to start the ${runnableWorkflow} workflow from the current \`${normalized.issue?.stateName}\` state.`
109
+ : `PatchRelay received your prompt, but the issue is not in a runnable workflow state yet. Move it to one of: ${listRunnableStates(project).join(", ")}, then delegate it to PatchRelay.`;
110
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
111
+ type: "elicitation",
112
+ body,
113
+ });
114
+ return;
115
+ }
116
+ if (!activeRunLease && desiredStage) {
117
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
118
+ type: "thought",
119
+ body: `PatchRelay received your prompt and is preparing the ${desiredStage} workflow.`,
120
+ });
121
+ return;
122
+ }
123
+ if (!activeRunLease && !desiredStage && (promptBody || promptContext)) {
124
+ const runnableStates = listRunnableStates(project).join(", ");
125
+ await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
126
+ type: "elicitation",
127
+ body: `PatchRelay received your prompt, but the issue is not in a runnable workflow state yet. Move it to one of: ${runnableStates}.`,
128
+ });
129
+ }
130
+ }
131
+ enqueueObligation(projectId, linearIssueId, threadId, turnId, source, promptBody, dedupeKey) {
132
+ const activeRunLeaseId = this.stores.issueControl.getIssueControl(projectId, linearIssueId)?.activeRunLeaseId;
133
+ if (activeRunLeaseId === undefined) {
134
+ return undefined;
135
+ }
136
+ const obligation = this.stores.obligations.enqueueObligation({
137
+ projectId,
138
+ linearIssueId,
139
+ kind: "deliver_turn_input",
140
+ source,
141
+ payloadJson: JSON.stringify({
142
+ body: promptBody,
143
+ }),
144
+ runLeaseId: activeRunLeaseId,
145
+ ...(threadId ? { threadId } : {}),
146
+ ...(turnId ? { turnId } : {}),
147
+ dedupeKey,
148
+ });
149
+ return obligation.id;
150
+ }
151
+ }
152
+ function buildPromptDedupeKey(agentSessionId, promptBody) {
153
+ return `linear-agent-prompt:${agentSessionId}:${hashBody(promptBody)}`;
154
+ }
155
+ function hashBody(value) {
156
+ return createHash("sha256").update(value).digest("hex");
157
+ }
@@ -0,0 +1,24 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const REDACTED_HEADERS = new Set(["authorization", "cookie", "set-cookie", "linear-signature"]);
4
+ function sanitizePathSegment(value) {
5
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
6
+ }
7
+ export async function archiveWebhook(params) {
8
+ const datePrefix = params.receivedAt.slice(0, 10);
9
+ const directory = path.join(params.archiveDir, datePrefix);
10
+ const fileName = `${params.receivedAt.replace(/[:.]/g, "-")}-${sanitizePathSegment(params.webhookId)}.json`;
11
+ const filePath = path.join(directory, fileName);
12
+ await mkdir(directory, { recursive: true });
13
+ await writeFile(filePath, JSON.stringify({
14
+ webhookId: params.webhookId,
15
+ receivedAt: params.receivedAt,
16
+ headers: redactHeaders(params.headers),
17
+ rawBodyUtf8: params.rawBody.toString("utf8"),
18
+ payload: params.payload,
19
+ }, null, 2), "utf8");
20
+ return filePath;
21
+ }
22
+ function redactHeaders(headers) {
23
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, REDACTED_HEADERS.has(key.toLowerCase()) ? "[redacted]" : value]));
24
+ }
@@ -0,0 +1,89 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isPatchRelayStatusComment } from "./linear-workflow.js";
3
+ import { triggerEventAllowed } from "./project-resolution.js";
4
+ export class CommentWebhookHandler {
5
+ stores;
6
+ turnInputDispatcher;
7
+ constructor(stores, turnInputDispatcher) {
8
+ this.stores = stores;
9
+ this.turnInputDispatcher = turnInputDispatcher;
10
+ }
11
+ async handle(normalized, project) {
12
+ if ((normalized.triggerEvent !== "commentCreated" && normalized.triggerEvent !== "commentUpdated") || !normalized.comment?.body) {
13
+ return;
14
+ }
15
+ if (!triggerEventAllowed(project, normalized.triggerEvent)) {
16
+ return;
17
+ }
18
+ const normalizedIssue = normalized.issue;
19
+ if (!normalizedIssue) {
20
+ return;
21
+ }
22
+ const issue = this.stores.issueWorkflows.getTrackedIssue(project.id, normalizedIssue.id);
23
+ const issueControl = this.stores.issueControl.getIssueControl(project.id, normalizedIssue.id);
24
+ if (!issueControl?.activeRunLeaseId) {
25
+ return;
26
+ }
27
+ if (isPatchRelayStatusComment(normalized.comment.id, normalized.comment.body, issueControl.serviceOwnedCommentId ?? issue?.statusCommentId)) {
28
+ return;
29
+ }
30
+ const runLease = this.stores.runLeases.getRunLease(issueControl.activeRunLeaseId);
31
+ if (!runLease) {
32
+ return;
33
+ }
34
+ const body = [
35
+ "New Linear comment received while you are working.",
36
+ normalized.comment.userName ? `Author: ${normalized.comment.userName}` : undefined,
37
+ "",
38
+ normalized.comment.body.trim(),
39
+ ]
40
+ .filter(Boolean)
41
+ .join("\n");
42
+ const dedupeKey = buildCommentDedupeKey(normalized.comment.id, body);
43
+ if (issueControl.activeRunLeaseId !== undefined &&
44
+ this.stores.obligations.getObligationByDedupeKey({
45
+ runLeaseId: issueControl.activeRunLeaseId,
46
+ kind: "deliver_turn_input",
47
+ dedupeKey,
48
+ })) {
49
+ return;
50
+ }
51
+ this.enqueueObligation(project.id, normalizedIssue.id, runLease.threadId, runLease.turnId, normalized.comment.id, body, dedupeKey);
52
+ await this.turnInputDispatcher.flush({
53
+ id: issueControl.activeRunLeaseId,
54
+ projectId: project.id,
55
+ linearIssueId: normalizedIssue.id,
56
+ ...(runLease.threadId ? { threadId: runLease.threadId } : {}),
57
+ ...(runLease.turnId ? { turnId: runLease.turnId } : {}),
58
+ }, {
59
+ ...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
60
+ failureMessage: "Failed to deliver queued Linear comment to active Codex turn",
61
+ });
62
+ }
63
+ enqueueObligation(projectId, linearIssueId, threadId, turnId, commentId, body, dedupeKey) {
64
+ const activeRunLeaseId = this.stores.issueControl.getIssueControl(projectId, linearIssueId)?.activeRunLeaseId;
65
+ if (activeRunLeaseId === undefined) {
66
+ return undefined;
67
+ }
68
+ const obligation = this.stores.obligations.enqueueObligation({
69
+ projectId,
70
+ linearIssueId,
71
+ kind: "deliver_turn_input",
72
+ source: `linear-comment:${commentId}`,
73
+ payloadJson: JSON.stringify({
74
+ body,
75
+ }),
76
+ runLeaseId: activeRunLeaseId,
77
+ ...(threadId ? { threadId } : {}),
78
+ ...(turnId ? { turnId } : {}),
79
+ dedupeKey,
80
+ });
81
+ return obligation.id;
82
+ }
83
+ }
84
+ function buildCommentDedupeKey(commentId, body) {
85
+ return `linear-comment:${commentId}:${hashBody(body)}`;
86
+ }
87
+ function hashBody(value) {
88
+ return createHash("sha256").update(value).digest("hex");
89
+ }
@@ -0,0 +1,150 @@
1
+ import { triggerEventAllowed } from "./project-resolution.js";
2
+ import { resolveWorkflowStage } from "./workflow-policy.js";
3
+ function trimPrompt(value) {
4
+ const trimmed = value?.trim();
5
+ return trimmed ? trimmed : undefined;
6
+ }
7
+ export class WebhookDesiredStageRecorder {
8
+ stores;
9
+ constructor(stores) {
10
+ this.stores = stores;
11
+ }
12
+ record(project, normalized, options) {
13
+ const normalizedIssue = normalized.issue;
14
+ if (!normalizedIssue) {
15
+ return {
16
+ issue: undefined,
17
+ activeStageRun: undefined,
18
+ desiredStage: undefined,
19
+ delegatedToPatchRelay: false,
20
+ launchInput: undefined,
21
+ };
22
+ }
23
+ const issue = this.stores.issueWorkflows.getTrackedIssue(project.id, normalizedIssue.id);
24
+ const issueControl = this.stores.issueControl.getIssueControl(project.id, normalizedIssue.id);
25
+ const activeStageRun = issueControl?.activeRunLeaseId !== undefined ? this.stores.issueWorkflows.getStageRun(issueControl.activeRunLeaseId) : undefined;
26
+ const delegatedToPatchRelay = this.isDelegatedToPatchRelay(project, normalized);
27
+ const stageAllowed = triggerEventAllowed(project, normalized.triggerEvent);
28
+ const desiredStage = this.resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay);
29
+ const launchInput = this.resolveLaunchInput(normalized.agentSession);
30
+ this.persistIssueControlFirst(project.id, normalizedIssue.id, issue, activeStageRun, desiredStage, normalized.agentSession?.id, options?.eventReceiptId);
31
+ this.stores.issueWorkflows.recordDesiredStage({
32
+ projectId: project.id,
33
+ linearIssueId: normalizedIssue.id,
34
+ ...(normalizedIssue.identifier ? { issueKey: normalizedIssue.identifier } : {}),
35
+ ...(normalizedIssue.title ? { title: normalizedIssue.title } : {}),
36
+ ...(normalizedIssue.url ? { issueUrl: normalizedIssue.url } : {}),
37
+ ...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
38
+ lastWebhookAt: new Date().toISOString(),
39
+ });
40
+ if (normalized.agentSession?.id) {
41
+ this.stores.issueWorkflows.setIssueActiveAgentSession(project.id, normalizedIssue.id, normalized.agentSession.id);
42
+ }
43
+ if (launchInput && !activeStageRun && delegatedToPatchRelay && stageAllowed) {
44
+ this.stores.obligations.enqueueObligation({
45
+ projectId: project.id,
46
+ linearIssueId: normalizedIssue.id,
47
+ kind: "deliver_turn_input",
48
+ source: `linear-agent-launch:${normalized.agentSession?.id ?? normalized.webhookId}`,
49
+ payloadJson: JSON.stringify({
50
+ body: launchInput,
51
+ }),
52
+ });
53
+ }
54
+ const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(project.id, normalizedIssue.id);
55
+ this.syncIssueControl(project.id, normalizedIssue.id, refreshedIssue, desiredStage, normalized.agentSession?.id, options?.eventReceiptId);
56
+ return {
57
+ issue: refreshedIssue ?? issue,
58
+ activeStageRun,
59
+ desiredStage,
60
+ delegatedToPatchRelay,
61
+ launchInput,
62
+ };
63
+ }
64
+ isDelegatedToPatchRelay(project, normalized) {
65
+ const normalizedIssue = normalized.issue;
66
+ if (!normalizedIssue) {
67
+ return false;
68
+ }
69
+ const installation = this.stores.linearInstallations.getLinearInstallationForProject(project.id);
70
+ if (!installation?.actorId) {
71
+ return false;
72
+ }
73
+ return normalizedIssue.delegateId === installation.actorId;
74
+ }
75
+ resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay) {
76
+ const normalizedIssue = normalized.issue;
77
+ if (!normalizedIssue) {
78
+ return undefined;
79
+ }
80
+ const stageAllowed = triggerEventAllowed(project, normalized.triggerEvent);
81
+ let desiredStage;
82
+ if (normalized.triggerEvent === "delegateChanged") {
83
+ desiredStage = delegatedToPatchRelay ? resolveWorkflowStage(project, normalizedIssue.stateName) : undefined;
84
+ if (!desiredStage) {
85
+ return undefined;
86
+ }
87
+ if (!stageAllowed && !project.triggerEvents.includes("statusChanged")) {
88
+ return undefined;
89
+ }
90
+ }
91
+ else if (normalized.triggerEvent === "agentSessionCreated" || normalized.triggerEvent === "agentPrompted") {
92
+ if (!delegatedToPatchRelay || !stageAllowed) {
93
+ return undefined;
94
+ }
95
+ desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
96
+ }
97
+ else if (stageAllowed) {
98
+ desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
99
+ }
100
+ else {
101
+ return undefined;
102
+ }
103
+ if (activeStageRun && desiredStage === activeStageRun.stage) {
104
+ return undefined;
105
+ }
106
+ if (issue?.desiredStage && desiredStage === issue.desiredStage) {
107
+ return undefined;
108
+ }
109
+ return desiredStage;
110
+ }
111
+ resolveLaunchInput(agentSession) {
112
+ const promptBody = trimPrompt(agentSession?.promptBody);
113
+ if (promptBody) {
114
+ return ["New Linear agent input received.", "", promptBody].join("\n");
115
+ }
116
+ const promptContext = trimPrompt(agentSession?.promptContext);
117
+ if (promptContext) {
118
+ return ["Linear provided this initial agent context.", "", promptContext].join("\n");
119
+ }
120
+ return undefined;
121
+ }
122
+ persistIssueControlFirst(projectId, linearIssueId, issue, activeStageRun, desiredStage, activeAgentSessionId, eventReceiptId) {
123
+ if (!desiredStage) {
124
+ return;
125
+ }
126
+ const lifecycleStatus = issue?.lifecycleStatus ?? "queued";
127
+ this.stores.issueControl.upsertIssueControl({
128
+ projectId,
129
+ linearIssueId,
130
+ desiredStage,
131
+ ...(eventReceiptId !== undefined ? { desiredReceiptId: eventReceiptId } : {}),
132
+ ...(issue?.statusCommentId ? { serviceOwnedCommentId: issue.statusCommentId } : {}),
133
+ ...(activeAgentSessionId ? { activeAgentSessionId } : {}),
134
+ lifecycleStatus,
135
+ });
136
+ }
137
+ syncIssueControl(projectId, linearIssueId, issue, desiredStage, activeAgentSessionId, eventReceiptId) {
138
+ if (!issue) {
139
+ return;
140
+ }
141
+ this.stores.issueControl.upsertIssueControl({
142
+ projectId,
143
+ linearIssueId,
144
+ ...(desiredStage ? { desiredStage } : {}),
145
+ ...(eventReceiptId !== undefined && desiredStage ? { desiredReceiptId: eventReceiptId } : {}),
146
+ ...(activeAgentSessionId ? { activeAgentSessionId } : {}),
147
+ lifecycleStatus: issue.lifecycleStatus,
148
+ });
149
+ }
150
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ export class InstallationWebhookHandler {
2
+ config;
3
+ stores;
4
+ logger;
5
+ constructor(config, stores, logger) {
6
+ this.config = config;
7
+ this.stores = stores;
8
+ this.logger = logger;
9
+ }
10
+ handle(normalized) {
11
+ if (!normalized.installation) {
12
+ return;
13
+ }
14
+ if (normalized.triggerEvent === "installationPermissionsChanged") {
15
+ const matchingInstallations = normalized.installation.appUserId
16
+ ? this.stores.linearInstallations
17
+ .listLinearInstallations()
18
+ .filter((installation) => installation.actorId === normalized.installation?.appUserId)
19
+ : [];
20
+ const links = this.stores.linearInstallations.listProjectInstallations();
21
+ const impactedProjects = matchingInstallations.flatMap((installation) => links
22
+ .filter((link) => link.installationId === installation.id)
23
+ .map((link) => {
24
+ const project = this.config.projects.find((entry) => entry.id === link.projectId);
25
+ const removedMatches = normalized.installation?.removedTeamIds.some((teamId) => project?.linearTeamIds.includes(teamId)) ?? false;
26
+ const addedMatches = normalized.installation?.addedTeamIds.some((teamId) => project?.linearTeamIds.includes(teamId)) ?? false;
27
+ return {
28
+ projectId: link.projectId,
29
+ removedMatches,
30
+ addedMatches,
31
+ };
32
+ }));
33
+ this.logger.warn({
34
+ appUserId: normalized.installation.appUserId,
35
+ addedTeamIds: normalized.installation.addedTeamIds,
36
+ removedTeamIds: normalized.installation.removedTeamIds,
37
+ canAccessAllPublicTeams: normalized.installation.canAccessAllPublicTeams,
38
+ impactedProjects,
39
+ }, "Linear app-team permissions changed; reconnect or adjust project routing if PatchRelay lost required team access");
40
+ return;
41
+ }
42
+ if (normalized.triggerEvent === "installationRevoked") {
43
+ this.logger.warn({
44
+ organizationId: normalized.installation.organizationId,
45
+ oauthClientId: normalized.installation.oauthClientId,
46
+ }, "Linear OAuth app installation was revoked; reconnect affected projects with `patchrelay project apply <id> <repo-path>` or `patchrelay connect --project <id>`");
47
+ return;
48
+ }
49
+ if (normalized.triggerEvent === "appUserNotification") {
50
+ this.logger.info({
51
+ appUserId: normalized.installation.appUserId,
52
+ notificationType: normalized.installation.notificationType,
53
+ organizationId: normalized.installation.organizationId,
54
+ }, "Received Linear app-user notification webhook");
55
+ }
56
+ }
57
+ }