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.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/config/patchrelay.example.json +5 -0
- package/dist/build-info.js +29 -0
- package/dist/build-info.json +6 -0
- package/dist/cli/data.js +461 -0
- package/dist/cli/formatters/json.js +3 -0
- package/dist/cli/formatters/text.js +119 -0
- package/dist/cli/index.js +761 -0
- package/dist/codex-app-server.js +353 -0
- package/dist/codex-types.js +1 -0
- package/dist/config-types.js +1 -0
- package/dist/config.js +494 -0
- package/dist/db/authoritative-ledger-store.js +437 -0
- package/dist/db/issue-workflow-store.js +690 -0
- package/dist/db/linear-installation-store.js +184 -0
- package/dist/db/migrations.js +183 -0
- package/dist/db/shared.js +101 -0
- package/dist/db/stage-event-store.js +33 -0
- package/dist/db/webhook-event-store.js +46 -0
- package/dist/db-ports.js +5 -0
- package/dist/db-types.js +1 -0
- package/dist/db.js +40 -0
- package/dist/file-permissions.js +40 -0
- package/dist/http.js +321 -0
- package/dist/index.js +69 -0
- package/dist/install.js +302 -0
- package/dist/installation-ports.js +1 -0
- package/dist/issue-query-service.js +68 -0
- package/dist/ledger-ports.js +1 -0
- package/dist/linear-client.js +338 -0
- package/dist/linear-oauth-service.js +131 -0
- package/dist/linear-oauth.js +154 -0
- package/dist/linear-types.js +1 -0
- package/dist/linear-workflow.js +78 -0
- package/dist/logging.js +62 -0
- package/dist/preflight.js +227 -0
- package/dist/project-resolution.js +51 -0
- package/dist/reconciliation-action-applier.js +55 -0
- package/dist/reconciliation-actions.js +1 -0
- package/dist/reconciliation-engine.js +312 -0
- package/dist/reconciliation-snapshot-builder.js +96 -0
- package/dist/reconciliation-types.js +1 -0
- package/dist/runtime-paths.js +89 -0
- package/dist/service-queue.js +49 -0
- package/dist/service-runtime.js +96 -0
- package/dist/service-stage-finalizer.js +348 -0
- package/dist/service-stage-runner.js +233 -0
- package/dist/service-webhook-processor.js +181 -0
- package/dist/service-webhooks.js +148 -0
- package/dist/service.js +139 -0
- package/dist/stage-agent-activity-publisher.js +33 -0
- package/dist/stage-event-ports.js +1 -0
- package/dist/stage-failure.js +92 -0
- package/dist/stage-launch.js +54 -0
- package/dist/stage-lifecycle-publisher.js +213 -0
- package/dist/stage-reporting.js +153 -0
- package/dist/stage-turn-input-dispatcher.js +102 -0
- package/dist/token-crypto.js +21 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +163 -0
- package/dist/webhook-agent-session-handler.js +157 -0
- package/dist/webhook-archive.js +24 -0
- package/dist/webhook-comment-handler.js +89 -0
- package/dist/webhook-desired-stage-recorder.js +150 -0
- package/dist/webhook-event-ports.js +1 -0
- package/dist/webhook-installation-handler.js +57 -0
- package/dist/webhooks.js +301 -0
- package/dist/workflow-policy.js +42 -0
- package/dist/workflow-ports.js +1 -0
- package/dist/workflow-types.js +1 -0
- package/dist/worktree-manager.js +66 -0
- package/infra/patchrelay-reload.service +6 -0
- package/infra/patchrelay.path +11 -0
- package/infra/patchrelay.service +28 -0
- package/package.json +55 -0
- package/runtime.env.example +8 -0
- 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
|
+
}
|