@ship-cli/core 0.0.2 → 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/README.md +90 -0
- package/dist/bin.js +43263 -30230
- package/package.json +47 -23
- package/.tsbuildinfo/src.tsbuildinfo +0 -1
- package/.tsbuildinfo/test.tsbuildinfo +0 -1
- package/LICENSE +0 -21
- package/src/adapters/driven/auth/AuthServiceLive.ts +0 -125
- package/src/adapters/driven/config/ConfigRepositoryLive.ts +0 -366
- package/src/adapters/driven/linear/IssueRepositoryLive.ts +0 -499
- package/src/adapters/driven/linear/LinearClient.ts +0 -33
- package/src/adapters/driven/linear/Mapper.ts +0 -142
- package/src/adapters/driven/linear/ProjectRepositoryLive.ts +0 -98
- package/src/adapters/driven/linear/TeamRepositoryLive.ts +0 -101
- package/src/adapters/driving/cli/commands/block.ts +0 -63
- package/src/adapters/driving/cli/commands/blocked.ts +0 -61
- package/src/adapters/driving/cli/commands/create.ts +0 -83
- package/src/adapters/driving/cli/commands/done.ts +0 -82
- package/src/adapters/driving/cli/commands/init.ts +0 -194
- package/src/adapters/driving/cli/commands/list.ts +0 -87
- package/src/adapters/driving/cli/commands/login.ts +0 -46
- package/src/adapters/driving/cli/commands/prime.ts +0 -83
- package/src/adapters/driving/cli/commands/project.ts +0 -155
- package/src/adapters/driving/cli/commands/ready.ts +0 -73
- package/src/adapters/driving/cli/commands/show.ts +0 -94
- package/src/adapters/driving/cli/commands/start.ts +0 -101
- package/src/adapters/driving/cli/commands/team.ts +0 -135
- package/src/adapters/driving/cli/commands/unblock.ts +0 -63
- package/src/adapters/driving/cli/main.ts +0 -70
- package/src/bin.ts +0 -12
- package/src/domain/Config.ts +0 -42
- package/src/domain/Errors.ts +0 -89
- package/src/domain/Task.ts +0 -124
- package/src/domain/index.ts +0 -3
- package/src/infrastructure/Layers.ts +0 -45
- package/src/ports/AuthService.ts +0 -19
- package/src/ports/ConfigRepository.ts +0 -20
- package/src/ports/IssueRepository.ts +0 -69
- package/src/ports/PrService.ts +0 -52
- package/src/ports/ProjectRepository.ts +0 -19
- package/src/ports/TeamRepository.ts +0 -17
- package/src/ports/VcsService.ts +0 -87
- package/src/ports/index.ts +0 -7
- package/test/Dummy.test.ts +0 -7
- package/tsconfig.base.json +0 -45
- package/tsconfig.json +0 -7
- package/tsconfig.src.json +0 -11
- package/tsconfig.test.json +0 -10
- package/tsup.config.ts +0 -14
- package/vitest.config.ts +0 -12
|
@@ -1,499 +0,0 @@
|
|
|
1
|
-
import * as Effect from "effect/Effect";
|
|
2
|
-
import * as Layer from "effect/Layer";
|
|
3
|
-
import * as Option from "effect/Option";
|
|
4
|
-
import * as Schedule from "effect/Schedule";
|
|
5
|
-
import * as Duration from "effect/Duration";
|
|
6
|
-
import { LinearDocument, type Issue, type WorkflowState, type IssueRelation } from "@linear/sdk";
|
|
7
|
-
import { IssueRepository } from "../../../ports/IssueRepository.js";
|
|
8
|
-
import { LinearClientService } from "./LinearClient.js";
|
|
9
|
-
import {
|
|
10
|
-
Task,
|
|
11
|
-
TaskId,
|
|
12
|
-
TeamId,
|
|
13
|
-
CreateTaskInput,
|
|
14
|
-
UpdateTaskInput,
|
|
15
|
-
TaskFilter,
|
|
16
|
-
type ProjectId,
|
|
17
|
-
} from "../../../domain/Task.js";
|
|
18
|
-
import { LinearApiError, TaskError, TaskNotFoundError } from "../../../domain/Errors.js";
|
|
19
|
-
import { mapIssueToTask, priorityToLinear, statusToLinearStateType } from "./Mapper.js";
|
|
20
|
-
|
|
21
|
-
// Retry policy: exponential backoff with max 3 retries
|
|
22
|
-
const retryPolicy = Schedule.intersect(
|
|
23
|
-
Schedule.exponential(Duration.millis(100)),
|
|
24
|
-
Schedule.recurs(3),
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
// Timeout for API calls: 30 seconds
|
|
28
|
-
const API_TIMEOUT = Duration.seconds(30);
|
|
29
|
-
|
|
30
|
-
const withRetryAndTimeout = <A, E>(
|
|
31
|
-
effect: Effect.Effect<A, E>,
|
|
32
|
-
operation: string,
|
|
33
|
-
): Effect.Effect<A, E | LinearApiError> =>
|
|
34
|
-
effect.pipe(
|
|
35
|
-
Effect.timeoutFail({
|
|
36
|
-
duration: API_TIMEOUT,
|
|
37
|
-
onTimeout: () => new LinearApiError({ message: `${operation} timed out after 30 seconds` }),
|
|
38
|
-
}),
|
|
39
|
-
Effect.retry(retryPolicy),
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
const make = Effect.gen(function* () {
|
|
43
|
-
const linearClient = yield* LinearClientService;
|
|
44
|
-
|
|
45
|
-
const getTask = (id: TaskId): Effect.Effect<Task, TaskNotFoundError | LinearApiError> =>
|
|
46
|
-
withRetryAndTimeout(
|
|
47
|
-
Effect.gen(function* () {
|
|
48
|
-
const client = yield* linearClient.client();
|
|
49
|
-
const issue = yield* Effect.tryPromise({
|
|
50
|
-
try: () => client.issue(id),
|
|
51
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch issue: ${e}`, cause: e }),
|
|
52
|
-
});
|
|
53
|
-
if (!issue) {
|
|
54
|
-
return yield* Effect.fail(new TaskNotFoundError({ taskId: id }));
|
|
55
|
-
}
|
|
56
|
-
return yield* Effect.tryPromise({
|
|
57
|
-
try: () => mapIssueToTask(issue),
|
|
58
|
-
catch: (e) => new LinearApiError({ message: `Failed to map issue: ${e}`, cause: e }),
|
|
59
|
-
});
|
|
60
|
-
}),
|
|
61
|
-
"Fetching task",
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
const getTaskByIdentifier = (
|
|
65
|
-
identifier: string,
|
|
66
|
-
): Effect.Effect<Task, TaskNotFoundError | LinearApiError> =>
|
|
67
|
-
withRetryAndTimeout(
|
|
68
|
-
Effect.gen(function* () {
|
|
69
|
-
const client = yield* linearClient.client();
|
|
70
|
-
|
|
71
|
-
// Validate identifier format (e.g., "BRI-123")
|
|
72
|
-
const match = identifier.match(/^([A-Z]+)-(\d+)$/i);
|
|
73
|
-
if (!match) {
|
|
74
|
-
return yield* Effect.fail(new TaskNotFoundError({ taskId: identifier }));
|
|
75
|
-
}
|
|
76
|
-
const [, teamKey, numberStr] = match;
|
|
77
|
-
const number = parseInt(numberStr, 10);
|
|
78
|
-
|
|
79
|
-
const issues = yield* Effect.tryPromise({
|
|
80
|
-
try: () =>
|
|
81
|
-
client.issues({
|
|
82
|
-
filter: {
|
|
83
|
-
number: { eq: number },
|
|
84
|
-
team: { key: { eq: teamKey.toUpperCase() } },
|
|
85
|
-
},
|
|
86
|
-
}),
|
|
87
|
-
catch: (e) => new LinearApiError({ message: `Failed to search issues: ${e}`, cause: e }),
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const issue = issues.nodes[0];
|
|
91
|
-
if (!issue) {
|
|
92
|
-
return yield* Effect.fail(new TaskNotFoundError({ taskId: identifier }));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return yield* Effect.tryPromise({
|
|
96
|
-
try: () => mapIssueToTask(issue),
|
|
97
|
-
catch: (e) => new LinearApiError({ message: `Failed to map issue: ${e}`, cause: e }),
|
|
98
|
-
});
|
|
99
|
-
}),
|
|
100
|
-
"Fetching task by identifier",
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const createTask = (
|
|
104
|
-
teamId: TeamId,
|
|
105
|
-
input: CreateTaskInput,
|
|
106
|
-
): Effect.Effect<Task, TaskError | LinearApiError> =>
|
|
107
|
-
withRetryAndTimeout(
|
|
108
|
-
Effect.gen(function* () {
|
|
109
|
-
const client = yield* linearClient.client();
|
|
110
|
-
|
|
111
|
-
const createInput: Parameters<typeof client.createIssue>[0] = {
|
|
112
|
-
teamId,
|
|
113
|
-
title: input.title,
|
|
114
|
-
priority: priorityToLinear(input.priority),
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
if (Option.isSome(input.description)) {
|
|
118
|
-
createInput.description = input.description.value;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (Option.isSome(input.projectId)) {
|
|
122
|
-
createInput.projectId = input.projectId.value;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const issuePayload = yield* Effect.tryPromise({
|
|
126
|
-
try: () => client.createIssue(createInput),
|
|
127
|
-
catch: (e) => new LinearApiError({ message: `Failed to create issue: ${e}`, cause: e }),
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
if (!issuePayload.success) {
|
|
131
|
-
return yield* Effect.fail(new TaskError({ message: "Failed to create issue" }));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const issue = yield* Effect.tryPromise({
|
|
135
|
-
try: async () => {
|
|
136
|
-
const i = await issuePayload.issue;
|
|
137
|
-
if (!i) throw new Error("Issue not returned");
|
|
138
|
-
return i;
|
|
139
|
-
},
|
|
140
|
-
catch: (e) =>
|
|
141
|
-
new LinearApiError({ message: `Failed to get created issue: ${e}`, cause: e }),
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
return yield* Effect.tryPromise({
|
|
145
|
-
try: () => mapIssueToTask(issue),
|
|
146
|
-
catch: (e) => new LinearApiError({ message: `Failed to map issue: ${e}`, cause: e }),
|
|
147
|
-
});
|
|
148
|
-
}),
|
|
149
|
-
"Creating task",
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
const updateTask = (
|
|
153
|
-
id: TaskId,
|
|
154
|
-
input: UpdateTaskInput,
|
|
155
|
-
): Effect.Effect<Task, TaskNotFoundError | TaskError | LinearApiError> =>
|
|
156
|
-
withRetryAndTimeout(
|
|
157
|
-
Effect.gen(function* () {
|
|
158
|
-
const client = yield* linearClient.client();
|
|
159
|
-
|
|
160
|
-
const updatePayload: Record<string, unknown> = {};
|
|
161
|
-
|
|
162
|
-
if (Option.isSome(input.title)) {
|
|
163
|
-
updatePayload.title = input.title.value;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (Option.isSome(input.description)) {
|
|
167
|
-
updatePayload.description = input.description.value;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (Option.isSome(input.priority)) {
|
|
171
|
-
updatePayload.priority = priorityToLinear(input.priority.value);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (Option.isSome(input.status)) {
|
|
175
|
-
const issue = yield* Effect.tryPromise({
|
|
176
|
-
try: () => client.issue(id),
|
|
177
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch issue: ${e}`, cause: e }),
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
if (!issue) {
|
|
181
|
-
return yield* Effect.fail(new TaskNotFoundError({ taskId: id }));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const teamFetch = issue.team;
|
|
185
|
-
if (!teamFetch) {
|
|
186
|
-
return yield* Effect.fail(new TaskError({ message: "Issue has no team" }));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const team = yield* Effect.tryPromise({
|
|
190
|
-
try: () => teamFetch,
|
|
191
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch team: ${e}`, cause: e }),
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
const states = yield* Effect.tryPromise({
|
|
195
|
-
try: () => team.states(),
|
|
196
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch states: ${e}`, cause: e }),
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
const targetStateType = statusToLinearStateType(input.status.value);
|
|
200
|
-
const targetState = states.nodes.find((s: WorkflowState) => s.type === targetStateType);
|
|
201
|
-
|
|
202
|
-
if (targetState) {
|
|
203
|
-
updatePayload.stateId = targetState.id;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const result = yield* Effect.tryPromise({
|
|
208
|
-
try: () => client.updateIssue(id, updatePayload),
|
|
209
|
-
catch: (e) => new LinearApiError({ message: `Failed to update issue: ${e}`, cause: e }),
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
if (!result.success) {
|
|
213
|
-
return yield* Effect.fail(new TaskError({ message: "Failed to update issue" }));
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const updatedIssue = yield* Effect.tryPromise({
|
|
217
|
-
try: async () => {
|
|
218
|
-
const i = await result.issue;
|
|
219
|
-
if (!i) throw new Error("Issue not returned");
|
|
220
|
-
return i;
|
|
221
|
-
},
|
|
222
|
-
catch: (e) =>
|
|
223
|
-
new LinearApiError({ message: `Failed to get updated issue: ${e}`, cause: e }),
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
return yield* Effect.tryPromise({
|
|
227
|
-
try: () => mapIssueToTask(updatedIssue),
|
|
228
|
-
catch: (e) => new LinearApiError({ message: `Failed to map issue: ${e}`, cause: e }),
|
|
229
|
-
});
|
|
230
|
-
}),
|
|
231
|
-
"Updating task",
|
|
232
|
-
);
|
|
233
|
-
|
|
234
|
-
const listTasks = (
|
|
235
|
-
teamId: TeamId,
|
|
236
|
-
filter: TaskFilter,
|
|
237
|
-
): Effect.Effect<ReadonlyArray<Task>, LinearApiError> =>
|
|
238
|
-
withRetryAndTimeout(
|
|
239
|
-
Effect.gen(function* () {
|
|
240
|
-
const client = yield* linearClient.client();
|
|
241
|
-
|
|
242
|
-
const linearFilter: Record<string, unknown> = {
|
|
243
|
-
team: { id: { eq: teamId } },
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
if (Option.isSome(filter.status)) {
|
|
247
|
-
const stateType = statusToLinearStateType(filter.status.value);
|
|
248
|
-
linearFilter.state = { type: { eq: stateType } };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
if (Option.isSome(filter.priority)) {
|
|
252
|
-
linearFilter.priority = { eq: priorityToLinear(filter.priority.value) };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (Option.isSome(filter.projectId)) {
|
|
256
|
-
linearFilter.project = { id: { eq: filter.projectId.value } };
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (filter.assignedToMe) {
|
|
260
|
-
const viewer = yield* Effect.tryPromise({
|
|
261
|
-
try: () => client.viewer,
|
|
262
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch viewer: ${e}`, cause: e }),
|
|
263
|
-
});
|
|
264
|
-
linearFilter.assignee = { id: { eq: viewer.id } };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const issues = yield* Effect.tryPromise({
|
|
268
|
-
try: () => client.issues({ filter: linearFilter }),
|
|
269
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch issues: ${e}`, cause: e }),
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
return yield* Effect.all(
|
|
273
|
-
issues.nodes.map((issue: Issue) =>
|
|
274
|
-
Effect.tryPromise({
|
|
275
|
-
try: () => mapIssueToTask(issue),
|
|
276
|
-
catch: (e) => new LinearApiError({ message: `Failed to map issue: ${e}`, cause: e }),
|
|
277
|
-
}),
|
|
278
|
-
),
|
|
279
|
-
);
|
|
280
|
-
}),
|
|
281
|
-
"Listing tasks",
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
const getReadyTasks = (
|
|
285
|
-
teamId: TeamId,
|
|
286
|
-
projectId?: ProjectId,
|
|
287
|
-
): Effect.Effect<ReadonlyArray<Task>, LinearApiError> =>
|
|
288
|
-
withRetryAndTimeout(
|
|
289
|
-
Effect.gen(function* () {
|
|
290
|
-
const client = yield* linearClient.client();
|
|
291
|
-
|
|
292
|
-
const filter: Record<string, unknown> = {
|
|
293
|
-
team: { id: { eq: teamId } },
|
|
294
|
-
state: { type: { in: ["backlog", "unstarted"] } },
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
if (projectId) {
|
|
298
|
-
filter.project = { id: { eq: projectId } };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const issues = yield* Effect.tryPromise({
|
|
302
|
-
try: () => client.issues({ filter }),
|
|
303
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch issues: ${e}`, cause: e }),
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
const tasks = yield* Effect.all(
|
|
307
|
-
issues.nodes.map((issue: Issue) =>
|
|
308
|
-
Effect.tryPromise({
|
|
309
|
-
try: async () => {
|
|
310
|
-
const task = await mapIssueToTask(issue);
|
|
311
|
-
const relations = await issue.relations();
|
|
312
|
-
const blockedByRelations = relations?.nodes?.filter(
|
|
313
|
-
(r: IssueRelation) => r.type === "blocks",
|
|
314
|
-
);
|
|
315
|
-
if (blockedByRelations && blockedByRelations.length > 0) {
|
|
316
|
-
return null;
|
|
317
|
-
}
|
|
318
|
-
return task;
|
|
319
|
-
},
|
|
320
|
-
catch: (e) => new LinearApiError({ message: `Failed to map issue: ${e}`, cause: e }),
|
|
321
|
-
}),
|
|
322
|
-
),
|
|
323
|
-
);
|
|
324
|
-
|
|
325
|
-
return tasks.filter((t): t is Task => t !== null);
|
|
326
|
-
}),
|
|
327
|
-
"Fetching ready tasks",
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
const getBlockedTasks = (
|
|
331
|
-
teamId: TeamId,
|
|
332
|
-
projectId?: ProjectId,
|
|
333
|
-
): Effect.Effect<ReadonlyArray<Task>, LinearApiError> =>
|
|
334
|
-
withRetryAndTimeout(
|
|
335
|
-
Effect.gen(function* () {
|
|
336
|
-
const client = yield* linearClient.client();
|
|
337
|
-
|
|
338
|
-
const filter: Record<string, unknown> = {
|
|
339
|
-
team: { id: { eq: teamId } },
|
|
340
|
-
state: { type: { in: ["backlog", "unstarted", "started"] } },
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
if (projectId) {
|
|
344
|
-
filter.project = { id: { eq: projectId } };
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const issues = yield* Effect.tryPromise({
|
|
348
|
-
try: () => client.issues({ filter }),
|
|
349
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch issues: ${e}`, cause: e }),
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
const tasks = yield* Effect.all(
|
|
353
|
-
issues.nodes.map((issue: Issue) =>
|
|
354
|
-
Effect.tryPromise({
|
|
355
|
-
try: async () => {
|
|
356
|
-
const relations = await issue.relations();
|
|
357
|
-
const blockedByRelations = relations?.nodes?.filter(
|
|
358
|
-
(r: IssueRelation) => r.type === "blocks",
|
|
359
|
-
);
|
|
360
|
-
if (!blockedByRelations || blockedByRelations.length === 0) {
|
|
361
|
-
return null;
|
|
362
|
-
}
|
|
363
|
-
return mapIssueToTask(issue);
|
|
364
|
-
},
|
|
365
|
-
catch: (e) =>
|
|
366
|
-
new LinearApiError({ message: `Failed to process issue: ${e}`, cause: e }),
|
|
367
|
-
}),
|
|
368
|
-
),
|
|
369
|
-
);
|
|
370
|
-
|
|
371
|
-
return tasks.filter((t): t is Task => t !== null);
|
|
372
|
-
}),
|
|
373
|
-
"Fetching blocked tasks",
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
const addBlocker = (
|
|
377
|
-
blockedId: TaskId,
|
|
378
|
-
blockerId: TaskId,
|
|
379
|
-
): Effect.Effect<void, TaskNotFoundError | TaskError | LinearApiError> =>
|
|
380
|
-
withRetryAndTimeout(
|
|
381
|
-
Effect.gen(function* () {
|
|
382
|
-
const client = yield* linearClient.client();
|
|
383
|
-
|
|
384
|
-
const result = yield* Effect.tryPromise({
|
|
385
|
-
try: () =>
|
|
386
|
-
client.createIssueRelation({
|
|
387
|
-
issueId: blockedId,
|
|
388
|
-
relatedIssueId: blockerId,
|
|
389
|
-
type: LinearDocument.IssueRelationType.Blocks,
|
|
390
|
-
}),
|
|
391
|
-
catch: (e) =>
|
|
392
|
-
new LinearApiError({ message: `Failed to create relation: ${e}`, cause: e }),
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
if (!result.success) {
|
|
396
|
-
return yield* Effect.fail(
|
|
397
|
-
new TaskError({ message: "Failed to create blocking relation" }),
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
}),
|
|
401
|
-
"Adding blocker",
|
|
402
|
-
);
|
|
403
|
-
|
|
404
|
-
const removeBlocker = (
|
|
405
|
-
blockedId: TaskId,
|
|
406
|
-
blockerId: TaskId,
|
|
407
|
-
): Effect.Effect<void, TaskNotFoundError | TaskError | LinearApiError> =>
|
|
408
|
-
withRetryAndTimeout(
|
|
409
|
-
Effect.gen(function* () {
|
|
410
|
-
const client = yield* linearClient.client();
|
|
411
|
-
|
|
412
|
-
const blocked = yield* Effect.tryPromise({
|
|
413
|
-
try: () => client.issue(blockedId),
|
|
414
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch issue: ${e}`, cause: e }),
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
if (!blocked) {
|
|
418
|
-
return yield* Effect.fail(new TaskNotFoundError({ taskId: blockedId }));
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const relations = yield* Effect.tryPromise({
|
|
422
|
-
try: () => blocked.relations(),
|
|
423
|
-
catch: (e) =>
|
|
424
|
-
new LinearApiError({ message: `Failed to fetch relations: ${e}`, cause: e }),
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
const relationToDelete = yield* Effect.tryPromise({
|
|
428
|
-
try: async () => {
|
|
429
|
-
for (const r of relations?.nodes ?? []) {
|
|
430
|
-
if (r.type === "blocks") {
|
|
431
|
-
const relatedIssue = await (r as IssueRelation & { relatedIssue: Promise<Issue> })
|
|
432
|
-
.relatedIssue;
|
|
433
|
-
if (relatedIssue?.id === blockerId) {
|
|
434
|
-
return r;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
return undefined;
|
|
439
|
-
},
|
|
440
|
-
catch: (e) => new LinearApiError({ message: `Failed to find relation: ${e}`, cause: e }),
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
if (!relationToDelete) {
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
yield* Effect.tryPromise({
|
|
448
|
-
try: () => client.deleteIssueRelation(relationToDelete.id),
|
|
449
|
-
catch: (e) =>
|
|
450
|
-
new LinearApiError({ message: `Failed to delete relation: ${e}`, cause: e }),
|
|
451
|
-
});
|
|
452
|
-
}),
|
|
453
|
-
"Removing blocker",
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
const getBranchName = (id: TaskId): Effect.Effect<string, TaskNotFoundError | LinearApiError> =>
|
|
457
|
-
withRetryAndTimeout(
|
|
458
|
-
Effect.gen(function* () {
|
|
459
|
-
const client = yield* linearClient.client();
|
|
460
|
-
|
|
461
|
-
const issue = yield* Effect.tryPromise({
|
|
462
|
-
try: () => client.issue(id),
|
|
463
|
-
catch: (e) => new LinearApiError({ message: `Failed to fetch issue: ${e}`, cause: e }),
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
if (!issue) {
|
|
467
|
-
return yield* Effect.fail(new TaskNotFoundError({ taskId: id }));
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (issue.branchName) {
|
|
471
|
-
return issue.branchName;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const slug = issue.title
|
|
475
|
-
.toLowerCase()
|
|
476
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
477
|
-
.replace(/^-|-$/g, "")
|
|
478
|
-
.slice(0, 50);
|
|
479
|
-
|
|
480
|
-
return `${issue.identifier.toLowerCase()}-${slug}`;
|
|
481
|
-
}),
|
|
482
|
-
"Getting branch name",
|
|
483
|
-
);
|
|
484
|
-
|
|
485
|
-
return {
|
|
486
|
-
getTask,
|
|
487
|
-
getTaskByIdentifier,
|
|
488
|
-
createTask,
|
|
489
|
-
updateTask,
|
|
490
|
-
listTasks,
|
|
491
|
-
getReadyTasks,
|
|
492
|
-
getBlockedTasks,
|
|
493
|
-
addBlocker,
|
|
494
|
-
removeBlocker,
|
|
495
|
-
getBranchName,
|
|
496
|
-
};
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
export const IssueRepositoryLive = Layer.effect(IssueRepository, make);
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import * as Effect from "effect/Effect";
|
|
2
|
-
import * as Context from "effect/Context";
|
|
3
|
-
import * as Layer from "effect/Layer";
|
|
4
|
-
import { LinearClient as LinearSDK } from "@linear/sdk";
|
|
5
|
-
import { AuthService } from "../../../ports/AuthService.js";
|
|
6
|
-
import { LinearApiError } from "../../../domain/Errors.js";
|
|
7
|
-
|
|
8
|
-
export interface LinearClientService {
|
|
9
|
-
readonly client: () => Effect.Effect<LinearSDK, LinearApiError>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export const LinearClientService = Context.GenericTag<LinearClientService>("LinearClientService");
|
|
13
|
-
|
|
14
|
-
const make = Effect.gen(function* () {
|
|
15
|
-
const auth = yield* AuthService;
|
|
16
|
-
|
|
17
|
-
const client = (): Effect.Effect<LinearSDK, LinearApiError> =>
|
|
18
|
-
Effect.gen(function* () {
|
|
19
|
-
const apiKey = yield* auth
|
|
20
|
-
.getApiKey()
|
|
21
|
-
.pipe(
|
|
22
|
-
Effect.mapError(
|
|
23
|
-
(e) => new LinearApiError({ message: `Authentication required: ${e.message}` }),
|
|
24
|
-
),
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
return new LinearSDK({ apiKey });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
return { client };
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
export const LinearClientLive = Layer.effect(LinearClientService, make);
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import * as Option from "effect/Option";
|
|
2
|
-
import type {
|
|
3
|
-
Issue,
|
|
4
|
-
Team as LinearTeam,
|
|
5
|
-
Project as LinearProject,
|
|
6
|
-
WorkflowState as LinearWorkflowState,
|
|
7
|
-
} from "@linear/sdk";
|
|
8
|
-
import {
|
|
9
|
-
Task,
|
|
10
|
-
TaskId,
|
|
11
|
-
Priority,
|
|
12
|
-
Team,
|
|
13
|
-
TeamId,
|
|
14
|
-
Project,
|
|
15
|
-
ProjectId,
|
|
16
|
-
WorkflowState,
|
|
17
|
-
WorkflowStateType,
|
|
18
|
-
type TaskStatus,
|
|
19
|
-
} from "../../../domain/Task.js";
|
|
20
|
-
|
|
21
|
-
// Map Linear state type to our WorkflowStateType
|
|
22
|
-
const mapStateType = (stateType: string): WorkflowStateType => {
|
|
23
|
-
switch (stateType) {
|
|
24
|
-
case "backlog":
|
|
25
|
-
return "backlog";
|
|
26
|
-
case "unstarted":
|
|
27
|
-
return "unstarted";
|
|
28
|
-
case "started":
|
|
29
|
-
return "started";
|
|
30
|
-
case "completed":
|
|
31
|
-
return "completed";
|
|
32
|
-
case "canceled":
|
|
33
|
-
return "canceled";
|
|
34
|
-
default:
|
|
35
|
-
return "unstarted"; // Safe default
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
// Map Linear WorkflowState to our WorkflowState
|
|
40
|
-
const mapWorkflowState = (state: LinearWorkflowState | undefined): WorkflowState => {
|
|
41
|
-
return new WorkflowState({
|
|
42
|
-
id: state?.id ?? "",
|
|
43
|
-
name: state?.name ?? "Unknown",
|
|
44
|
-
type: mapStateType(state?.type ?? "unstarted"),
|
|
45
|
-
});
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Map our TaskStatus to Linear state type for filtering/updating
|
|
49
|
-
export const statusToLinearStateType = (status: TaskStatus): WorkflowStateType => {
|
|
50
|
-
switch (status) {
|
|
51
|
-
case "backlog":
|
|
52
|
-
return "backlog";
|
|
53
|
-
case "todo":
|
|
54
|
-
return "unstarted";
|
|
55
|
-
case "in_progress":
|
|
56
|
-
case "in_review":
|
|
57
|
-
return "started";
|
|
58
|
-
case "done":
|
|
59
|
-
return "completed";
|
|
60
|
-
case "cancelled":
|
|
61
|
-
return "canceled";
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// Map Linear priority (0-4, where 0 = no priority, 1 = urgent, 4 = low)
|
|
66
|
-
const mapPriority = (priority: number): Priority => {
|
|
67
|
-
switch (priority) {
|
|
68
|
-
case 0:
|
|
69
|
-
return "none";
|
|
70
|
-
case 1:
|
|
71
|
-
return "urgent";
|
|
72
|
-
case 2:
|
|
73
|
-
return "high";
|
|
74
|
-
case 3:
|
|
75
|
-
return "medium";
|
|
76
|
-
case 4:
|
|
77
|
-
return "low";
|
|
78
|
-
default:
|
|
79
|
-
return "none";
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
// Map our Priority to Linear priority number
|
|
84
|
-
export const priorityToLinear = (priority: Priority): number => {
|
|
85
|
-
switch (priority) {
|
|
86
|
-
case "urgent":
|
|
87
|
-
return 1;
|
|
88
|
-
case "high":
|
|
89
|
-
return 2;
|
|
90
|
-
case "medium":
|
|
91
|
-
return 3;
|
|
92
|
-
case "low":
|
|
93
|
-
return 4;
|
|
94
|
-
case "none":
|
|
95
|
-
return 0;
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
export const mapIssueToTask = async (issue: Issue): Promise<Task> => {
|
|
100
|
-
// Fetch related data
|
|
101
|
-
const state = await issue.state;
|
|
102
|
-
const labels = await issue.labels();
|
|
103
|
-
const team = await issue.team;
|
|
104
|
-
|
|
105
|
-
// Get blocking relations
|
|
106
|
-
// Note: Linear SDK doesn't directly expose relations, we'll handle this in the repository
|
|
107
|
-
const blockedBy: TaskId[] = [];
|
|
108
|
-
const blocks: TaskId[] = [];
|
|
109
|
-
|
|
110
|
-
return new Task({
|
|
111
|
-
id: issue.id as TaskId,
|
|
112
|
-
identifier: issue.identifier,
|
|
113
|
-
title: issue.title,
|
|
114
|
-
description: issue.description ? Option.some(issue.description) : Option.none(),
|
|
115
|
-
state: mapWorkflowState(state),
|
|
116
|
-
priority: mapPriority(issue.priority),
|
|
117
|
-
type: Option.none(), // Linear doesn't have issue types in the same way
|
|
118
|
-
teamId: (team?.id ?? "") as TeamId,
|
|
119
|
-
projectId: Option.none(), // Will be populated if needed
|
|
120
|
-
branchName: issue.branchName ? Option.some(issue.branchName) : Option.none(),
|
|
121
|
-
url: issue.url,
|
|
122
|
-
labels: labels?.nodes?.map((l) => l.name) ?? [],
|
|
123
|
-
blockedBy,
|
|
124
|
-
blocks,
|
|
125
|
-
createdAt: issue.createdAt,
|
|
126
|
-
updatedAt: issue.updatedAt,
|
|
127
|
-
});
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
export const mapTeam = (team: LinearTeam): Team =>
|
|
131
|
-
new Team({
|
|
132
|
-
id: team.id as TeamId,
|
|
133
|
-
name: team.name,
|
|
134
|
-
key: team.key,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
export const mapProject = (project: LinearProject, teamId: string): Project =>
|
|
138
|
-
new Project({
|
|
139
|
-
id: project.id as ProjectId,
|
|
140
|
-
name: project.name,
|
|
141
|
-
teamId: teamId as TeamId,
|
|
142
|
-
});
|