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 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { summarizeCurrentThread } from "./stage-reporting.js";
2
+ import { safeJsonParse } from "./utils.js";
3
+ export class IssueQueryService {
4
+ stores;
5
+ codex;
6
+ stageFinalizer;
7
+ constructor(stores, codex, stageFinalizer) {
8
+ this.stores = stores;
9
+ this.codex = codex;
10
+ this.stageFinalizer = stageFinalizer;
11
+ }
12
+ async getIssueOverview(issueKey) {
13
+ const result = this.stores.issueWorkflows.getIssueOverview(issueKey);
14
+ if (!result) {
15
+ return undefined;
16
+ }
17
+ const activeStatus = await this.stageFinalizer.getActiveStageStatus(issueKey);
18
+ const activeStageRun = activeStatus?.stageRun ?? result.activeStageRun;
19
+ const latestStageRun = this.stores.issueWorkflows.getLatestStageRunForIssue(result.issue.projectId, result.issue.linearIssueId);
20
+ let liveThread;
21
+ if (activeStatus?.liveThread) {
22
+ liveThread = activeStatus.liveThread;
23
+ }
24
+ else if (activeStageRun?.threadId) {
25
+ liveThread = await this.codex.readThread(activeStageRun.threadId, true).then(summarizeCurrentThread).catch(() => undefined);
26
+ }
27
+ return {
28
+ ...result,
29
+ ...(activeStageRun ? { activeStageRun } : {}),
30
+ ...(latestStageRun ? { latestStageRun } : {}),
31
+ ...(liveThread ? { liveThread } : {}),
32
+ };
33
+ }
34
+ async getIssueReport(issueKey) {
35
+ const issue = this.stores.issueWorkflows.getTrackedIssueByKey(issueKey);
36
+ if (!issue) {
37
+ return undefined;
38
+ }
39
+ return {
40
+ issue,
41
+ stages: this.stores.issueWorkflows.listStageRunsForIssue(issue.projectId, issue.linearIssueId).map((stageRun) => ({
42
+ stageRun,
43
+ ...(stageRun.reportJson ? { report: JSON.parse(stageRun.reportJson) } : {}),
44
+ })),
45
+ };
46
+ }
47
+ async getStageEvents(issueKey, stageRunId) {
48
+ const issue = this.stores.issueWorkflows.getTrackedIssueByKey(issueKey);
49
+ if (!issue) {
50
+ return undefined;
51
+ }
52
+ const stageRun = this.stores.issueWorkflows.getStageRun(stageRunId);
53
+ if (!stageRun || stageRun.projectId !== issue.projectId || stageRun.linearIssueId !== issue.linearIssueId) {
54
+ return undefined;
55
+ }
56
+ return {
57
+ issue,
58
+ stageRun,
59
+ events: this.stores.stageEvents.listThreadEvents(stageRunId).map((event) => ({
60
+ ...event,
61
+ parsedEvent: safeJsonParse(event.eventJson),
62
+ })),
63
+ };
64
+ }
65
+ async getActiveStageStatus(issueKey) {
66
+ return await this.stageFinalizer.getActiveStageStatus(issueKey);
67
+ }
68
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,338 @@
1
+ import { refreshLinearOAuthToken } from "./linear-oauth.js";
2
+ import { decryptSecret, encryptSecret } from "./token-crypto.js";
3
+ export class LinearGraphqlClient {
4
+ options;
5
+ logger;
6
+ constructor(options, logger) {
7
+ this.options = options;
8
+ this.logger = logger;
9
+ }
10
+ async getIssue(issueId) {
11
+ const response = await this.request(`
12
+ query PatchRelayIssue($id: String!) {
13
+ issue(id: $id) {
14
+ id
15
+ identifier
16
+ title
17
+ url
18
+ state {
19
+ id
20
+ name
21
+ }
22
+ labels {
23
+ nodes {
24
+ id
25
+ name
26
+ }
27
+ }
28
+ team {
29
+ id
30
+ key
31
+ states {
32
+ nodes {
33
+ id
34
+ name
35
+ type
36
+ }
37
+ }
38
+ labels {
39
+ nodes {
40
+ id
41
+ name
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ `, { id: issueId });
48
+ if (!response.issue) {
49
+ throw new Error(`Linear issue ${issueId} was not found`);
50
+ }
51
+ return this.mapIssue(response.issue);
52
+ }
53
+ async setIssueState(issueId, stateName) {
54
+ const issue = await this.getIssue(issueId);
55
+ const state = issue.workflowStates.find((entry) => entry.name.trim().toLowerCase() === stateName.trim().toLowerCase());
56
+ if (!state) {
57
+ throw new Error(`Linear state "${stateName}" was not found for issue ${issue.identifier ?? issueId}`);
58
+ }
59
+ const response = await this.request(`
60
+ mutation PatchRelaySetIssueState($id: String!, $stateId: String!) {
61
+ issueUpdate(id: $id, input: { stateId: $stateId }) {
62
+ success
63
+ issue {
64
+ id
65
+ identifier
66
+ title
67
+ url
68
+ state {
69
+ id
70
+ name
71
+ }
72
+ labels {
73
+ nodes {
74
+ id
75
+ name
76
+ }
77
+ }
78
+ team {
79
+ id
80
+ key
81
+ states {
82
+ nodes {
83
+ id
84
+ name
85
+ type
86
+ }
87
+ }
88
+ labels {
89
+ nodes {
90
+ id
91
+ name
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ `, { id: issueId, stateId: state.id });
99
+ if (!response.issueUpdate.success || !response.issueUpdate.issue) {
100
+ throw new Error(`Linear rejected state update for issue ${issue.identifier ?? issueId}`);
101
+ }
102
+ return this.mapIssue(response.issueUpdate.issue);
103
+ }
104
+ async upsertIssueComment(params) {
105
+ if (params.commentId) {
106
+ const response = await this.request(`
107
+ mutation PatchRelayUpdateComment($id: String!, $body: String!) {
108
+ commentUpdate(id: $id, input: { body: $body }) {
109
+ success
110
+ comment {
111
+ id
112
+ body
113
+ }
114
+ }
115
+ }
116
+ `, { id: params.commentId, body: params.body });
117
+ if (response.commentUpdate.success && response.commentUpdate.comment) {
118
+ return response.commentUpdate.comment;
119
+ }
120
+ }
121
+ const response = await this.request(`
122
+ mutation PatchRelayCreateComment($issueId: String!, $body: String!) {
123
+ commentCreate(input: { issueId: $issueId, body: $body }) {
124
+ success
125
+ comment {
126
+ id
127
+ body
128
+ }
129
+ }
130
+ }
131
+ `, { issueId: params.issueId, body: params.body });
132
+ if (!response.commentCreate.success || !response.commentCreate.comment) {
133
+ throw new Error(`Linear rejected comment upsert for issue ${params.issueId}`);
134
+ }
135
+ return response.commentCreate.comment;
136
+ }
137
+ async createAgentActivity(params) {
138
+ const response = await this.request(`
139
+ mutation PatchRelayCreateAgentActivity($input: AgentActivityCreateInput!) {
140
+ agentActivityCreate(input: $input) {
141
+ success
142
+ agentActivity {
143
+ id
144
+ }
145
+ }
146
+ }
147
+ `, {
148
+ input: {
149
+ agentSessionId: params.agentSessionId,
150
+ content: params.content,
151
+ ephemeral: params.ephemeral ?? false,
152
+ },
153
+ });
154
+ if (!response.agentActivityCreate.success || !response.agentActivityCreate.agentActivity) {
155
+ throw new Error(`Linear rejected agent activity for session ${params.agentSessionId}`);
156
+ }
157
+ return response.agentActivityCreate.agentActivity;
158
+ }
159
+ async updateIssueLabels(params) {
160
+ const issue = await this.getIssue(params.issueId);
161
+ const addIds = this.resolveLabelIds(issue, params.addNames ?? []);
162
+ const removeIds = this.resolveLabelIds(issue, params.removeNames ?? []);
163
+ if (addIds.length === 0 && removeIds.length === 0) {
164
+ return issue;
165
+ }
166
+ const response = await this.request(`
167
+ mutation PatchRelayUpdateIssueLabels($id: String!, $addedLabelIds: [String!], $removedLabelIds: [String!]) {
168
+ issueUpdate(id: $id, input: { addedLabelIds: $addedLabelIds, removedLabelIds: $removedLabelIds }) {
169
+ success
170
+ issue {
171
+ id
172
+ identifier
173
+ title
174
+ url
175
+ state {
176
+ id
177
+ name
178
+ }
179
+ labels {
180
+ nodes {
181
+ id
182
+ name
183
+ }
184
+ }
185
+ team {
186
+ id
187
+ key
188
+ states {
189
+ nodes {
190
+ id
191
+ name
192
+ type
193
+ }
194
+ }
195
+ labels {
196
+ nodes {
197
+ id
198
+ name
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ `, {
206
+ id: params.issueId,
207
+ addedLabelIds: addIds,
208
+ removedLabelIds: removeIds,
209
+ });
210
+ if (!response.issueUpdate.success || !response.issueUpdate.issue) {
211
+ throw new Error(`Linear rejected label update for issue ${issue.identifier ?? params.issueId}`);
212
+ }
213
+ return this.mapIssue(response.issueUpdate.issue);
214
+ }
215
+ async getActorProfile() {
216
+ const response = await this.request(`
217
+ query PatchRelayViewer {
218
+ viewer {
219
+ id
220
+ name
221
+ }
222
+ }
223
+ `, {});
224
+ return {
225
+ ...(response.viewer?.id ? { actorId: response.viewer.id } : {}),
226
+ ...(response.viewer?.name ? { actorName: response.viewer.name } : {}),
227
+ };
228
+ }
229
+ async request(query, variables) {
230
+ const response = await fetch(this.options.graphqlUrl, {
231
+ method: "POST",
232
+ headers: {
233
+ "content-type": "application/json",
234
+ authorization: `Bearer ${this.options.accessToken}`,
235
+ },
236
+ body: JSON.stringify({
237
+ query,
238
+ variables,
239
+ }),
240
+ });
241
+ if (!response.ok) {
242
+ throw new Error(`Linear API request failed with HTTP ${response.status}`);
243
+ }
244
+ const payload = (await response.json());
245
+ if (payload.errors?.length) {
246
+ const message = payload.errors.map((error) => error.message ?? "Unknown GraphQL error").join("; ");
247
+ this.logger.warn({ message }, "Linear GraphQL returned errors");
248
+ throw new Error(message);
249
+ }
250
+ if (!payload.data) {
251
+ throw new Error("Linear API returned no data");
252
+ }
253
+ return payload.data;
254
+ }
255
+ mapIssue(issue) {
256
+ const labels = (issue.labels?.nodes ?? []).map((label) => ({ id: label.id, name: label.name }));
257
+ const teamLabels = (issue.team?.labels?.nodes ?? []).map((label) => ({ id: label.id, name: label.name }));
258
+ return {
259
+ id: issue.id,
260
+ ...(issue.identifier ? { identifier: issue.identifier } : {}),
261
+ ...(issue.title ? { title: issue.title } : {}),
262
+ ...(issue.url ? { url: issue.url } : {}),
263
+ ...(issue.state?.id ? { stateId: issue.state.id } : {}),
264
+ ...(issue.state?.name ? { stateName: issue.state.name } : {}),
265
+ ...(issue.team?.id ? { teamId: issue.team.id } : {}),
266
+ ...(issue.team?.key ? { teamKey: issue.team.key } : {}),
267
+ workflowStates: (issue.team?.states?.nodes ?? []).map((state) => ({
268
+ id: state.id,
269
+ name: state.name,
270
+ ...(state.type ? { type: state.type } : {}),
271
+ })),
272
+ labelIds: labels.map((label) => label.id),
273
+ labels,
274
+ teamLabels,
275
+ };
276
+ }
277
+ resolveLabelIds(issue, names) {
278
+ const wanted = new Set(names.map((name) => name.trim().toLowerCase()).filter(Boolean));
279
+ if (wanted.size === 0) {
280
+ return [];
281
+ }
282
+ const labelIds = issue.teamLabels
283
+ .filter((label) => wanted.has(label.name.trim().toLowerCase()))
284
+ .map((label) => label.id);
285
+ const missing = [...wanted].filter((name) => !issue.teamLabels.some((label) => label.name.trim().toLowerCase() === name));
286
+ if (missing.length > 0) {
287
+ this.logger.warn({ issueId: issue.id, missing }, "PatchRelay skipped missing configured Linear labels");
288
+ }
289
+ return labelIds;
290
+ }
291
+ }
292
+ export class DatabaseBackedLinearClientProvider {
293
+ config;
294
+ db;
295
+ logger;
296
+ constructor(config, db, logger) {
297
+ this.config = config;
298
+ this.db = db;
299
+ this.logger = logger;
300
+ }
301
+ async forProject(projectId) {
302
+ const link = this.db.linearInstallations.getProjectInstallation(projectId);
303
+ if (link) {
304
+ const installation = this.db.linearInstallations.getLinearInstallation(link.installationId);
305
+ if (!installation) {
306
+ return undefined;
307
+ }
308
+ const encryptionKey = this.config.linear.tokenEncryptionKey;
309
+ let accessToken = decryptSecret(installation.accessTokenCiphertext, encryptionKey);
310
+ const refreshToken = installation.refreshTokenCiphertext
311
+ ? decryptSecret(installation.refreshTokenCiphertext, encryptionKey)
312
+ : undefined;
313
+ if (shouldRefreshToken(installation.expiresAt) && refreshToken) {
314
+ const refreshed = await refreshLinearOAuthToken(this.config, refreshToken);
315
+ accessToken = refreshed.accessToken;
316
+ this.db.linearInstallations.updateLinearInstallationTokens(installation.id, {
317
+ accessTokenCiphertext: encryptSecret(refreshed.accessToken, encryptionKey),
318
+ ...(refreshed.refreshToken
319
+ ? { refreshTokenCiphertext: encryptSecret(refreshed.refreshToken, encryptionKey) }
320
+ : {}),
321
+ scopesJson: JSON.stringify(refreshed.scopes),
322
+ ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}),
323
+ });
324
+ }
325
+ return new LinearGraphqlClient({
326
+ accessToken,
327
+ graphqlUrl: this.config.linear.graphqlUrl,
328
+ }, this.logger);
329
+ }
330
+ return undefined;
331
+ }
332
+ }
333
+ function shouldRefreshToken(expiresAt) {
334
+ if (!expiresAt) {
335
+ return false;
336
+ }
337
+ return Date.parse(expiresAt) <= Date.now() + 5 * 60 * 1000;
338
+ }
@@ -0,0 +1,131 @@
1
+ import { createLinearOAuthUrl, createOAuthStateToken, installLinearOAuthCode } from "./linear-oauth.js";
2
+ const LINEAR_OAUTH_STATE_TTL_MS = 15 * 60 * 1000;
3
+ function oauthStateExpired(createdAt) {
4
+ const createdAtMs = Date.parse(createdAt);
5
+ return !Number.isFinite(createdAtMs) || createdAtMs + LINEAR_OAUTH_STATE_TTL_MS < Date.now();
6
+ }
7
+ export class LinearOAuthService {
8
+ config;
9
+ stores;
10
+ logger;
11
+ constructor(config, stores, logger) {
12
+ this.config = config;
13
+ this.stores = stores;
14
+ this.logger = logger;
15
+ }
16
+ createStart(params) {
17
+ if (params?.projectId && !this.config.projects.some((project) => project.id === params.projectId)) {
18
+ throw new Error(`Unknown project: ${params.projectId}`);
19
+ }
20
+ if (params?.projectId) {
21
+ const existingLink = this.stores.linearInstallations.getProjectInstallation(params.projectId);
22
+ if (existingLink) {
23
+ const installation = this.stores.linearInstallations.getLinearInstallation(existingLink.installationId);
24
+ if (installation) {
25
+ return {
26
+ completed: true,
27
+ reusedExisting: true,
28
+ projectId: params.projectId,
29
+ installation: this.getInstallationSummary(installation),
30
+ };
31
+ }
32
+ }
33
+ const installations = this.stores.linearInstallations.listLinearInstallations();
34
+ if (installations.length === 1) {
35
+ const installation = installations[0];
36
+ if (installation) {
37
+ this.stores.linearInstallations.linkProjectInstallation(params.projectId, installation.id);
38
+ return {
39
+ completed: true,
40
+ reusedExisting: true,
41
+ projectId: params.projectId,
42
+ installation: this.getInstallationSummary(installation),
43
+ };
44
+ }
45
+ }
46
+ }
47
+ const state = createOAuthStateToken();
48
+ const record = this.stores.linearInstallations.createOAuthState({
49
+ provider: "linear",
50
+ state,
51
+ redirectUri: this.config.linear.oauth.redirectUri,
52
+ actor: this.config.linear.oauth.actor,
53
+ ...(params?.projectId ? { projectId: params.projectId } : {}),
54
+ });
55
+ return {
56
+ state,
57
+ authorizeUrl: createLinearOAuthUrl(this.config, record.state, record.redirectUri, record.projectId),
58
+ redirectUri: record.redirectUri,
59
+ ...(record.projectId ? { projectId: record.projectId } : {}),
60
+ };
61
+ }
62
+ async complete(params) {
63
+ const oauthState = this.stores.linearInstallations.getOAuthState(params.state);
64
+ if (!oauthState || oauthState.consumedAt) {
65
+ throw new Error("OAuth state was not found or has already been consumed");
66
+ }
67
+ if (oauthStateExpired(oauthState.createdAt)) {
68
+ this.stores.linearInstallations.finalizeOAuthState({
69
+ state: params.state,
70
+ status: "failed",
71
+ errorMessage: "OAuth state expired",
72
+ });
73
+ throw new Error("OAuth state has expired. Start the connection flow again.");
74
+ }
75
+ try {
76
+ const installation = await installLinearOAuthCode({
77
+ config: this.config,
78
+ db: this.stores.linearInstallations,
79
+ logger: this.logger,
80
+ code: params.code,
81
+ redirectUri: oauthState.redirectUri,
82
+ ...(oauthState.projectId ? { projectId: oauthState.projectId } : {}),
83
+ });
84
+ this.stores.linearInstallations.finalizeOAuthState({
85
+ state: params.state,
86
+ status: "completed",
87
+ installationId: installation.id,
88
+ });
89
+ return installation;
90
+ }
91
+ catch (error) {
92
+ this.stores.linearInstallations.finalizeOAuthState({
93
+ state: params.state,
94
+ status: "failed",
95
+ errorMessage: error instanceof Error ? error.message : String(error),
96
+ });
97
+ throw error;
98
+ }
99
+ }
100
+ getStateStatus(state) {
101
+ const oauthState = this.stores.linearInstallations.getOAuthState(state);
102
+ if (!oauthState) {
103
+ return undefined;
104
+ }
105
+ const installation = oauthState.installationId !== undefined ? this.stores.linearInstallations.getLinearInstallation(oauthState.installationId) : undefined;
106
+ return {
107
+ state: oauthState.state,
108
+ status: oauthState.status,
109
+ ...(oauthState.projectId ? { projectId: oauthState.projectId } : {}),
110
+ ...(installation ? { installation: this.getInstallationSummary(installation) } : {}),
111
+ ...(oauthState.errorMessage ? { errorMessage: oauthState.errorMessage } : {}),
112
+ };
113
+ }
114
+ listInstallations() {
115
+ const links = this.stores.linearInstallations.listProjectInstallations();
116
+ return this.stores.linearInstallations.listLinearInstallations().map((installation) => ({
117
+ installation: this.getInstallationSummary(installation),
118
+ linkedProjects: links.filter((link) => link.installationId === installation.id).map((link) => link.projectId),
119
+ }));
120
+ }
121
+ getInstallationSummary(installation) {
122
+ return {
123
+ id: installation.id,
124
+ ...(installation.workspaceName ? { workspaceName: installation.workspaceName } : {}),
125
+ ...(installation.workspaceKey ? { workspaceKey: installation.workspaceKey } : {}),
126
+ ...(installation.actorName ? { actorName: installation.actorName } : {}),
127
+ ...(installation.actorId ? { actorId: installation.actorId } : {}),
128
+ ...(installation.expiresAt ? { expiresAt: installation.expiresAt } : {}),
129
+ };
130
+ }
131
+ }
@@ -0,0 +1,154 @@
1
+ import crypto from "node:crypto";
2
+ import { encryptSecret } from "./token-crypto.js";
3
+ const DEFAULT_LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
4
+ const DEFAULT_LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
5
+ export function createOAuthStateToken() {
6
+ return crypto.randomBytes(24).toString("hex");
7
+ }
8
+ export function createLinearOAuthUrl(config, state, redirectUri, _projectId) {
9
+ const url = new URL(DEFAULT_LINEAR_AUTHORIZE_URL);
10
+ url.searchParams.set("client_id", config.linear.oauth.clientId);
11
+ url.searchParams.set("redirect_uri", redirectUri ?? config.linear.oauth.redirectUri);
12
+ url.searchParams.set("response_type", "code");
13
+ url.searchParams.set("scope", config.linear.oauth.scopes.join(" "));
14
+ url.searchParams.set("state", state);
15
+ url.searchParams.set("prompt", "consent");
16
+ url.searchParams.set("actor", config.linear.oauth.actor);
17
+ return url.toString();
18
+ }
19
+ export async function exchangeLinearOAuthCode(config, params) {
20
+ const response = await fetch(DEFAULT_LINEAR_TOKEN_URL, {
21
+ method: "POST",
22
+ headers: {
23
+ "content-type": "application/json",
24
+ },
25
+ body: JSON.stringify({
26
+ grant_type: "authorization_code",
27
+ code: params.code,
28
+ client_id: config.linear.oauth.clientId,
29
+ client_secret: config.linear.oauth.clientSecret,
30
+ redirect_uri: params.redirectUri,
31
+ }),
32
+ });
33
+ const payload = (await response.json().catch(() => undefined));
34
+ if (!response.ok || !payload) {
35
+ throw new Error(`Linear OAuth code exchange failed with HTTP ${response.status}`);
36
+ }
37
+ const accessToken = typeof payload.access_token === "string" ? payload.access_token : undefined;
38
+ if (!accessToken) {
39
+ throw new Error("Linear OAuth response did not include access_token");
40
+ }
41
+ const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : undefined;
42
+ const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : undefined;
43
+ return {
44
+ accessToken,
45
+ ...(typeof payload.refresh_token === "string" ? { refreshToken: payload.refresh_token } : {}),
46
+ ...(expiresAt ? { expiresAt } : {}),
47
+ scopes: typeof payload.scope === "string"
48
+ ? payload.scope.split(/[,\s]+/).filter(Boolean)
49
+ : config.linear.oauth.scopes,
50
+ };
51
+ }
52
+ export async function refreshLinearOAuthToken(config, refreshToken) {
53
+ const response = await fetch(DEFAULT_LINEAR_TOKEN_URL, {
54
+ method: "POST",
55
+ headers: {
56
+ "content-type": "application/json",
57
+ },
58
+ body: JSON.stringify({
59
+ grant_type: "refresh_token",
60
+ refresh_token: refreshToken,
61
+ client_id: config.linear.oauth.clientId,
62
+ client_secret: config.linear.oauth.clientSecret,
63
+ }),
64
+ });
65
+ const payload = (await response.json().catch(() => undefined));
66
+ if (!response.ok || !payload) {
67
+ throw new Error(`Linear OAuth token refresh failed with HTTP ${response.status}`);
68
+ }
69
+ const accessToken = typeof payload.access_token === "string" ? payload.access_token : undefined;
70
+ if (!accessToken) {
71
+ throw new Error("Linear OAuth refresh response did not include access_token");
72
+ }
73
+ const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : undefined;
74
+ const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : undefined;
75
+ return {
76
+ accessToken,
77
+ ...(typeof payload.refresh_token === "string" ? { refreshToken: payload.refresh_token } : {}),
78
+ ...(expiresAt ? { expiresAt } : {}),
79
+ scopes: typeof payload.scope === "string"
80
+ ? payload.scope.split(/[,\s]+/).filter(Boolean)
81
+ : config.linear.oauth.scopes,
82
+ };
83
+ }
84
+ export async function fetchLinearViewerIdentity(graphqlUrl, accessToken, logger) {
85
+ const response = await fetch(graphqlUrl, {
86
+ method: "POST",
87
+ headers: {
88
+ "content-type": "application/json",
89
+ authorization: `Bearer ${accessToken}`,
90
+ },
91
+ body: JSON.stringify({
92
+ query: `
93
+ query PatchRelayLinearViewer {
94
+ viewer {
95
+ id
96
+ name
97
+ }
98
+ teams {
99
+ nodes {
100
+ id
101
+ name
102
+ key
103
+ }
104
+ }
105
+ }
106
+ `,
107
+ }),
108
+ });
109
+ const payload = (await response.json().catch(() => undefined));
110
+ if (!response.ok || !payload?.data) {
111
+ throw new Error(`Linear viewer lookup failed with HTTP ${response.status}`);
112
+ }
113
+ const teams = payload.data.teams?.nodes ?? [];
114
+ const firstTeam = teams.find((team) => team?.id || team?.name || team?.key);
115
+ const result = {
116
+ ...(firstTeam?.id ? { workspaceId: firstTeam.id } : {}),
117
+ ...(firstTeam?.name ? { workspaceName: firstTeam.name } : {}),
118
+ ...(firstTeam?.key ? { workspaceKey: firstTeam.key } : {}),
119
+ ...(payload.data.viewer?.id ? { actorId: payload.data.viewer.id } : {}),
120
+ ...(payload.data.viewer?.name ? { actorName: payload.data.viewer.name } : {}),
121
+ };
122
+ logger.debug({
123
+ workspaceId: result.workspaceId,
124
+ workspaceName: result.workspaceName,
125
+ actorId: result.actorId,
126
+ actorName: result.actorName,
127
+ }, "Resolved Linear OAuth identity");
128
+ return result;
129
+ }
130
+ export async function installLinearOAuthCode(params) {
131
+ const tokenSet = await exchangeLinearOAuthCode(params.config, {
132
+ code: params.code,
133
+ redirectUri: params.redirectUri,
134
+ });
135
+ const identity = await fetchLinearViewerIdentity(params.config.linear.graphqlUrl, tokenSet.accessToken, params.logger);
136
+ const installation = params.db.upsertLinearInstallation({
137
+ ...(identity.workspaceId ? { workspaceId: identity.workspaceId } : {}),
138
+ ...(identity.workspaceName ? { workspaceName: identity.workspaceName } : {}),
139
+ ...(identity.workspaceKey ? { workspaceKey: identity.workspaceKey } : {}),
140
+ ...(identity.actorId ? { actorId: identity.actorId } : {}),
141
+ ...(identity.actorName ? { actorName: identity.actorName } : {}),
142
+ accessTokenCiphertext: encryptSecret(tokenSet.accessToken, params.config.linear.tokenEncryptionKey),
143
+ ...(tokenSet.refreshToken
144
+ ? { refreshTokenCiphertext: encryptSecret(tokenSet.refreshToken, params.config.linear.tokenEncryptionKey) }
145
+ : {}),
146
+ scopesJson: JSON.stringify(tokenSet.scopes),
147
+ ...(tokenSet.tokenType ? { tokenType: tokenSet.tokenType } : { tokenType: "Bearer" }),
148
+ ...(tokenSet.expiresAt ? { expiresAt: tokenSet.expiresAt } : {}),
149
+ });
150
+ if (params.projectId) {
151
+ params.db.linkProjectInstallation(params.projectId, installation.id);
152
+ }
153
+ return installation;
154
+ }