@ship-cli/core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tsbuildinfo/src.tsbuildinfo +1 -0
- package/.tsbuildinfo/test.tsbuildinfo +1 -0
- package/LICENSE +21 -0
- package/dist/bin.js +49230 -0
- package/package.json +50 -0
- package/src/adapters/driven/auth/AuthServiceLive.ts +125 -0
- package/src/adapters/driven/config/ConfigRepositoryLive.ts +366 -0
- package/src/adapters/driven/linear/IssueRepositoryLive.ts +494 -0
- package/src/adapters/driven/linear/LinearClient.ts +33 -0
- package/src/adapters/driven/linear/Mapper.ts +142 -0
- package/src/adapters/driven/linear/ProjectRepositoryLive.ts +98 -0
- package/src/adapters/driven/linear/TeamRepositoryLive.ts +100 -0
- package/src/adapters/driving/cli/commands/block.ts +63 -0
- package/src/adapters/driving/cli/commands/blocked.ts +61 -0
- package/src/adapters/driving/cli/commands/create.ts +83 -0
- package/src/adapters/driving/cli/commands/done.ts +82 -0
- package/src/adapters/driving/cli/commands/init.ts +194 -0
- package/src/adapters/driving/cli/commands/list.ts +87 -0
- package/src/adapters/driving/cli/commands/login.ts +46 -0
- package/src/adapters/driving/cli/commands/prime.ts +114 -0
- package/src/adapters/driving/cli/commands/project.ts +155 -0
- package/src/adapters/driving/cli/commands/ready.ts +73 -0
- package/src/adapters/driving/cli/commands/show.ts +94 -0
- package/src/adapters/driving/cli/commands/start.ts +99 -0
- package/src/adapters/driving/cli/commands/team.ts +134 -0
- package/src/adapters/driving/cli/commands/unblock.ts +63 -0
- package/src/adapters/driving/cli/main.ts +70 -0
- package/src/bin.ts +12 -0
- package/src/domain/Config.ts +42 -0
- package/src/domain/Errors.ts +89 -0
- package/src/domain/Task.ts +124 -0
- package/src/domain/index.ts +3 -0
- package/src/infrastructure/Layers.ts +45 -0
- package/src/ports/AuthService.ts +19 -0
- package/src/ports/ConfigRepository.ts +20 -0
- package/src/ports/IssueRepository.ts +69 -0
- package/src/ports/PrService.ts +52 -0
- package/src/ports/ProjectRepository.ts +19 -0
- package/src/ports/TeamRepository.ts +17 -0
- package/src/ports/VcsService.ts +87 -0
- package/src/ports/index.ts +7 -0
- package/test/Dummy.test.ts +7 -0
- package/tsconfig.base.json +45 -0
- package/tsconfig.json +7 -0
- package/tsconfig.src.json +11 -0
- package/tsconfig.test.json +10 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
import * as Schedule from "effect/Schedule";
|
|
4
|
+
import * as Duration from "effect/Duration";
|
|
5
|
+
import { ProjectRepository, type CreateProjectInput } from "../../../ports/ProjectRepository.js";
|
|
6
|
+
import { LinearClientService } from "./LinearClient.js";
|
|
7
|
+
import { Project, type TeamId } from "../../../domain/Task.js";
|
|
8
|
+
import { LinearApiError, TaskError } from "../../../domain/Errors.js";
|
|
9
|
+
import { mapProject } from "./Mapper.js";
|
|
10
|
+
|
|
11
|
+
// Retry policy: exponential backoff with max 3 retries
|
|
12
|
+
const retryPolicy = Schedule.intersect(
|
|
13
|
+
Schedule.exponential(Duration.millis(100)),
|
|
14
|
+
Schedule.recurs(3),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// Timeout for API calls: 30 seconds
|
|
18
|
+
const API_TIMEOUT = Duration.seconds(30);
|
|
19
|
+
|
|
20
|
+
const withRetryAndTimeout = <A, E>(
|
|
21
|
+
effect: Effect.Effect<A, E>,
|
|
22
|
+
operation: string,
|
|
23
|
+
): Effect.Effect<A, E | LinearApiError> =>
|
|
24
|
+
effect.pipe(
|
|
25
|
+
Effect.timeoutFail({
|
|
26
|
+
duration: API_TIMEOUT,
|
|
27
|
+
onTimeout: () => new LinearApiError({ message: `${operation} timed out after 30 seconds` }),
|
|
28
|
+
}),
|
|
29
|
+
Effect.retry(retryPolicy),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const make = Effect.gen(function* () {
|
|
33
|
+
const linearClient = yield* LinearClientService;
|
|
34
|
+
|
|
35
|
+
const getProjects = (teamId: TeamId): Effect.Effect<ReadonlyArray<Project>, LinearApiError> =>
|
|
36
|
+
withRetryAndTimeout(
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const client = yield* linearClient.client();
|
|
39
|
+
const team = yield* Effect.tryPromise({
|
|
40
|
+
try: () => client.team(teamId),
|
|
41
|
+
catch: (e) => new LinearApiError({ message: `Failed to fetch team: ${e}`, cause: e }),
|
|
42
|
+
});
|
|
43
|
+
const projects = yield* Effect.tryPromise({
|
|
44
|
+
try: () => team.projects(),
|
|
45
|
+
catch: (e) => new LinearApiError({ message: `Failed to fetch projects: ${e}`, cause: e }),
|
|
46
|
+
});
|
|
47
|
+
return projects.nodes.map((p) => mapProject(p, teamId));
|
|
48
|
+
}),
|
|
49
|
+
"Fetching projects",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const createProject = (
|
|
53
|
+
teamId: TeamId,
|
|
54
|
+
input: CreateProjectInput,
|
|
55
|
+
): Effect.Effect<Project, TaskError | LinearApiError> =>
|
|
56
|
+
withRetryAndTimeout(
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const client = yield* linearClient.client();
|
|
59
|
+
|
|
60
|
+
const createInput: { name: string; description?: string; teamIds: string[] } = {
|
|
61
|
+
name: input.name,
|
|
62
|
+
teamIds: [teamId],
|
|
63
|
+
};
|
|
64
|
+
if (input.description) {
|
|
65
|
+
createInput.description = input.description;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = yield* Effect.tryPromise({
|
|
69
|
+
try: () => client.createProject(createInput),
|
|
70
|
+
catch: (e) => new LinearApiError({ message: `Failed to create project: ${e}`, cause: e }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!result.success) {
|
|
74
|
+
return yield* Effect.fail(new TaskError({ message: "Failed to create project" }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const project = yield* Effect.tryPromise({
|
|
78
|
+
try: async () => {
|
|
79
|
+
const p = await result.project;
|
|
80
|
+
if (!p) throw new Error("Project not returned");
|
|
81
|
+
return p;
|
|
82
|
+
},
|
|
83
|
+
catch: (e) =>
|
|
84
|
+
new LinearApiError({ message: `Failed to get created project: ${e}`, cause: e }),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return mapProject(project, teamId);
|
|
88
|
+
}),
|
|
89
|
+
"Creating project",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
getProjects,
|
|
94
|
+
createProject,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export const ProjectRepositoryLive = Layer.effect(ProjectRepository, make);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
import * as Schedule from "effect/Schedule";
|
|
4
|
+
import * as Duration from "effect/Duration";
|
|
5
|
+
import { TeamRepository, type CreateTeamInput } from "../../../ports/TeamRepository.js";
|
|
6
|
+
import { LinearClientService } from "./LinearClient.js";
|
|
7
|
+
import { Team, type TeamId } from "../../../domain/Task.js";
|
|
8
|
+
import { LinearApiError, TaskError } from "../../../domain/Errors.js";
|
|
9
|
+
import { mapTeam } from "./Mapper.js";
|
|
10
|
+
|
|
11
|
+
// Retry policy: exponential backoff with max 3 retries
|
|
12
|
+
const retryPolicy = Schedule.intersect(
|
|
13
|
+
Schedule.exponential(Duration.millis(100)),
|
|
14
|
+
Schedule.recurs(3),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// Timeout for API calls: 30 seconds
|
|
18
|
+
const API_TIMEOUT = Duration.seconds(30);
|
|
19
|
+
|
|
20
|
+
const withRetryAndTimeout = <A, E>(
|
|
21
|
+
effect: Effect.Effect<A, E>,
|
|
22
|
+
operation: string,
|
|
23
|
+
): Effect.Effect<A, E | LinearApiError> =>
|
|
24
|
+
effect.pipe(
|
|
25
|
+
Effect.timeoutFail({
|
|
26
|
+
duration: API_TIMEOUT,
|
|
27
|
+
onTimeout: () => new LinearApiError({ message: `${operation} timed out after 30 seconds` }),
|
|
28
|
+
}),
|
|
29
|
+
Effect.retry(retryPolicy),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const make = Effect.gen(function* () {
|
|
33
|
+
const linearClient = yield* LinearClientService;
|
|
34
|
+
|
|
35
|
+
const getTeams = (): Effect.Effect<ReadonlyArray<Team>, LinearApiError> =>
|
|
36
|
+
withRetryAndTimeout(
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const client = yield* linearClient.client();
|
|
39
|
+
const teams = yield* Effect.tryPromise({
|
|
40
|
+
try: () => client.teams(),
|
|
41
|
+
catch: (e) => new LinearApiError({ message: `Failed to fetch teams: ${e}`, cause: e }),
|
|
42
|
+
});
|
|
43
|
+
return teams.nodes.map(mapTeam);
|
|
44
|
+
}),
|
|
45
|
+
"Fetching teams",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const getTeam = (id: TeamId): Effect.Effect<Team, LinearApiError> =>
|
|
49
|
+
withRetryAndTimeout(
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const client = yield* linearClient.client();
|
|
52
|
+
const team = yield* Effect.tryPromise({
|
|
53
|
+
try: () => client.team(id),
|
|
54
|
+
catch: (e) => new LinearApiError({ message: `Failed to fetch team: ${e}`, cause: e }),
|
|
55
|
+
});
|
|
56
|
+
return mapTeam(team);
|
|
57
|
+
}),
|
|
58
|
+
"Fetching team",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const createTeam = (input: CreateTeamInput): Effect.Effect<Team, TaskError | LinearApiError> =>
|
|
62
|
+
withRetryAndTimeout(
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const client = yield* linearClient.client();
|
|
65
|
+
|
|
66
|
+
const result = yield* Effect.tryPromise({
|
|
67
|
+
try: () =>
|
|
68
|
+
client.createTeam({
|
|
69
|
+
name: input.name,
|
|
70
|
+
key: input.key,
|
|
71
|
+
}),
|
|
72
|
+
catch: (e) => new LinearApiError({ message: `Failed to create team: ${e}`, cause: e }),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!result.success) {
|
|
76
|
+
return yield* Effect.fail(new TaskError({ message: "Failed to create team" }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const team = yield* Effect.tryPromise({
|
|
80
|
+
try: async () => {
|
|
81
|
+
const t = await result.team;
|
|
82
|
+
if (!t) throw new Error("Team not returned");
|
|
83
|
+
return t;
|
|
84
|
+
},
|
|
85
|
+
catch: (e) => new LinearApiError({ message: `Failed to get created team: ${e}`, cause: e }),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return mapTeam(team);
|
|
89
|
+
}),
|
|
90
|
+
"Creating team",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
getTeams,
|
|
95
|
+
getTeam,
|
|
96
|
+
createTeam,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const TeamRepositoryLive = Layer.effect(TeamRepository, make);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as Command from "@effect/cli/Command";
|
|
2
|
+
import * as Args from "@effect/cli/Args";
|
|
3
|
+
import * as Options from "@effect/cli/Options";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Console from "effect/Console";
|
|
6
|
+
import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
|
|
7
|
+
import { IssueRepository } from "../../../../ports/IssueRepository.js";
|
|
8
|
+
import type { TaskId } from "../../../../domain/Task.js";
|
|
9
|
+
|
|
10
|
+
const blockerArg = Args.text({ name: "blocker" }).pipe(
|
|
11
|
+
Args.withDescription("Task that is blocking (e.g., ENG-123)"),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const blockedArg = Args.text({ name: "blocked" }).pipe(
|
|
15
|
+
Args.withDescription("Task that is blocked (e.g., ENG-456)"),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
19
|
+
Options.withDescription("Output as JSON"),
|
|
20
|
+
Options.withDefault(false),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const blockCommand = Command.make(
|
|
24
|
+
"block",
|
|
25
|
+
{ blocker: blockerArg, blocked: blockedArg, json: jsonOption },
|
|
26
|
+
({ blocker, blocked, json }) =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const config = yield* ConfigRepository;
|
|
29
|
+
const issueRepo = yield* IssueRepository;
|
|
30
|
+
|
|
31
|
+
yield* config.load(); // Ensure initialized
|
|
32
|
+
|
|
33
|
+
// Get both tasks to get their IDs
|
|
34
|
+
const blockerTask = yield* issueRepo
|
|
35
|
+
.getTaskByIdentifier(blocker)
|
|
36
|
+
.pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(blocker as TaskId)));
|
|
37
|
+
|
|
38
|
+
const blockedTask = yield* issueRepo
|
|
39
|
+
.getTaskByIdentifier(blocked)
|
|
40
|
+
.pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(blocked as TaskId)));
|
|
41
|
+
|
|
42
|
+
// Add the blocking relationship
|
|
43
|
+
yield* issueRepo.addBlocker(blockedTask.id, blockerTask.id);
|
|
44
|
+
|
|
45
|
+
if (json) {
|
|
46
|
+
yield* Console.log(
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
status: "blocked",
|
|
49
|
+
blocker: {
|
|
50
|
+
id: blockerTask.id,
|
|
51
|
+
identifier: blockerTask.identifier,
|
|
52
|
+
},
|
|
53
|
+
blocked: {
|
|
54
|
+
id: blockedTask.id,
|
|
55
|
+
identifier: blockedTask.identifier,
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
yield* Console.log(`${blockerTask.identifier} now blocks ${blockedTask.identifier}`);
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as Command from "@effect/cli/Command";
|
|
2
|
+
import * as Options from "@effect/cli/Options";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Console from "effect/Console";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
|
|
7
|
+
import { IssueRepository } from "../../../../ports/IssueRepository.js";
|
|
8
|
+
import type { Task } from "../../../../domain/Task.js";
|
|
9
|
+
|
|
10
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
11
|
+
Options.withDescription("Output as JSON"),
|
|
12
|
+
Options.withDefault(false),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const formatBlockedTask = (task: Task): string[] => {
|
|
16
|
+
const lines: string[] = [];
|
|
17
|
+
lines.push(`${task.identifier}: ${task.title}`);
|
|
18
|
+
lines.push(` Status: ${task.state.name}`);
|
|
19
|
+
lines.push(` Priority: ${task.priority}`);
|
|
20
|
+
if (task.blockedBy.length > 0) {
|
|
21
|
+
lines.push(` Blocked by: ${task.blockedBy.join(", ")}`);
|
|
22
|
+
}
|
|
23
|
+
return lines;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const blockedCommand = Command.make("blocked", { json: jsonOption }, ({ json }) =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const config = yield* ConfigRepository;
|
|
29
|
+
const issueRepo = yield* IssueRepository;
|
|
30
|
+
|
|
31
|
+
const cfg = yield* config.load();
|
|
32
|
+
const projectId = Option.getOrUndefined(cfg.linear.projectId);
|
|
33
|
+
const blockedTasks = yield* issueRepo.getBlockedTasks(cfg.linear.teamId, projectId);
|
|
34
|
+
|
|
35
|
+
if (json) {
|
|
36
|
+
const output = blockedTasks.map((t) => ({
|
|
37
|
+
id: t.id,
|
|
38
|
+
identifier: t.identifier,
|
|
39
|
+
title: t.title,
|
|
40
|
+
priority: t.priority,
|
|
41
|
+
state: t.state.name,
|
|
42
|
+
stateType: t.state.type,
|
|
43
|
+
blockedBy: t.blockedBy,
|
|
44
|
+
url: t.url,
|
|
45
|
+
}));
|
|
46
|
+
yield* Console.log(JSON.stringify(output, null, 2));
|
|
47
|
+
} else {
|
|
48
|
+
if (blockedTasks.length === 0) {
|
|
49
|
+
yield* Console.log("No blocked tasks found.");
|
|
50
|
+
} else {
|
|
51
|
+
yield* Console.log(`Found ${blockedTasks.length} blocked task(s):\n`);
|
|
52
|
+
for (const task of blockedTasks) {
|
|
53
|
+
for (const line of formatBlockedTask(task)) {
|
|
54
|
+
yield* Console.log(line);
|
|
55
|
+
}
|
|
56
|
+
yield* Console.log("");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as Command from "@effect/cli/Command";
|
|
2
|
+
import * as Args from "@effect/cli/Args";
|
|
3
|
+
import * as Options from "@effect/cli/Options";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Console from "effect/Console";
|
|
6
|
+
import * as Option from "effect/Option";
|
|
7
|
+
import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
|
|
8
|
+
import { IssueRepository } from "../../../../ports/IssueRepository.js";
|
|
9
|
+
import { CreateTaskInput, Priority, TaskType } from "../../../../domain/Task.js";
|
|
10
|
+
|
|
11
|
+
const titleArg = Args.text({ name: "title" }).pipe(Args.withDescription("Task title"));
|
|
12
|
+
|
|
13
|
+
const descriptionOption = Options.text("description").pipe(
|
|
14
|
+
Options.withAlias("d"),
|
|
15
|
+
Options.withDescription("Task description"),
|
|
16
|
+
Options.optional,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const priorityOption = Options.choice("priority", ["urgent", "high", "medium", "low", "none"]).pipe(
|
|
20
|
+
Options.withAlias("p"),
|
|
21
|
+
Options.withDescription("Task priority"),
|
|
22
|
+
Options.withDefault("medium" as const),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const typeOption = Options.choice("type", ["bug", "feature", "task", "epic", "chore"]).pipe(
|
|
26
|
+
Options.withAlias("t"),
|
|
27
|
+
Options.withDescription("Task type"),
|
|
28
|
+
Options.withDefault("task" as const),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
32
|
+
Options.withDescription("Output as JSON"),
|
|
33
|
+
Options.withDefault(false),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export const createCommand = Command.make(
|
|
37
|
+
"create",
|
|
38
|
+
{
|
|
39
|
+
title: titleArg,
|
|
40
|
+
description: descriptionOption,
|
|
41
|
+
priority: priorityOption,
|
|
42
|
+
type: typeOption,
|
|
43
|
+
json: jsonOption,
|
|
44
|
+
},
|
|
45
|
+
({ title, description, priority, type, json }) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const config = yield* ConfigRepository;
|
|
48
|
+
const issueRepo = yield* IssueRepository;
|
|
49
|
+
|
|
50
|
+
const cfg = yield* config.load();
|
|
51
|
+
|
|
52
|
+
const input = new CreateTaskInput({
|
|
53
|
+
title,
|
|
54
|
+
description: Option.isSome(description) ? Option.some(description.value) : Option.none(),
|
|
55
|
+
priority: priority as Priority,
|
|
56
|
+
type: type as TaskType,
|
|
57
|
+
projectId: cfg.linear.projectId,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const task = yield* issueRepo.createTask(cfg.linear.teamId, input);
|
|
61
|
+
|
|
62
|
+
if (json) {
|
|
63
|
+
yield* Console.log(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
status: "created",
|
|
66
|
+
task: {
|
|
67
|
+
id: task.id,
|
|
68
|
+
identifier: task.identifier,
|
|
69
|
+
title: task.title,
|
|
70
|
+
priority: task.priority,
|
|
71
|
+
state: task.state.name,
|
|
72
|
+
url: task.url,
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
yield* Console.log(`Created: ${task.identifier} - ${task.title}`);
|
|
78
|
+
yield* Console.log(`Priority: ${task.priority}`);
|
|
79
|
+
yield* Console.log(`URL: ${task.url}`);
|
|
80
|
+
yield* Console.log(`\nRun 'ship start ${task.identifier}' to begin work.`);
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as Command from "@effect/cli/Command";
|
|
2
|
+
import * as Args from "@effect/cli/Args";
|
|
3
|
+
import * as Options from "@effect/cli/Options";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Console from "effect/Console";
|
|
6
|
+
import * as Option from "effect/Option";
|
|
7
|
+
import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
|
|
8
|
+
import { IssueRepository } from "../../../../ports/IssueRepository.js";
|
|
9
|
+
import { UpdateTaskInput, type TaskId } from "../../../../domain/Task.js";
|
|
10
|
+
|
|
11
|
+
const taskIdArg = Args.text({ name: "task-id" }).pipe(
|
|
12
|
+
Args.withDescription("Task identifier (e.g., ENG-123)"),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const reasonOption = Options.text("reason").pipe(
|
|
16
|
+
Options.withAlias("r"),
|
|
17
|
+
Options.withDescription("Reason or summary of completion"),
|
|
18
|
+
Options.optional,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
22
|
+
Options.withDescription("Output as JSON"),
|
|
23
|
+
Options.withDefault(false),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const doneCommand = Command.make(
|
|
27
|
+
"done",
|
|
28
|
+
{ taskId: taskIdArg, reason: reasonOption, json: jsonOption },
|
|
29
|
+
({ taskId, reason, json }) =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const config = yield* ConfigRepository;
|
|
32
|
+
const issueRepo = yield* IssueRepository;
|
|
33
|
+
|
|
34
|
+
yield* config.load(); // Ensure initialized
|
|
35
|
+
|
|
36
|
+
// Get the task
|
|
37
|
+
const task = yield* issueRepo
|
|
38
|
+
.getTaskByIdentifier(taskId)
|
|
39
|
+
.pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(taskId as TaskId)));
|
|
40
|
+
|
|
41
|
+
// Check if already done (Linear's "completed" or "canceled" state type)
|
|
42
|
+
if (task.isDone) {
|
|
43
|
+
if (json) {
|
|
44
|
+
yield* Console.log(JSON.stringify({ status: "already_done", task: taskId }));
|
|
45
|
+
} else {
|
|
46
|
+
yield* Console.log(`Task ${task.identifier} is already done (${task.state.name}).`);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Update status to done
|
|
52
|
+
const updateInput = new UpdateTaskInput({
|
|
53
|
+
title: Option.none(),
|
|
54
|
+
description: Option.none(),
|
|
55
|
+
status: Option.some("done"),
|
|
56
|
+
priority: Option.none(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const updatedTask = yield* issueRepo.updateTask(task.id, updateInput);
|
|
60
|
+
|
|
61
|
+
if (json) {
|
|
62
|
+
yield* Console.log(
|
|
63
|
+
JSON.stringify({
|
|
64
|
+
status: "completed",
|
|
65
|
+
task: {
|
|
66
|
+
id: updatedTask.id,
|
|
67
|
+
identifier: updatedTask.identifier,
|
|
68
|
+
title: updatedTask.title,
|
|
69
|
+
state: updatedTask.state.name,
|
|
70
|
+
},
|
|
71
|
+
reason: Option.getOrNull(reason),
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
} else {
|
|
75
|
+
yield* Console.log(`Completed: ${updatedTask.identifier} - ${updatedTask.title}`);
|
|
76
|
+
if (Option.isSome(reason)) {
|
|
77
|
+
yield* Console.log(`Reason: ${reason.value}`);
|
|
78
|
+
}
|
|
79
|
+
yield* Console.log(`\nRun 'ship ready' to see the next available task.`);
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import * as Command from "@effect/cli/Command";
|
|
2
|
+
import * as Options from "@effect/cli/Options";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Option from "effect/Option";
|
|
5
|
+
import * as clack from "@clack/prompts";
|
|
6
|
+
import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
|
|
7
|
+
import { AuthService } from "../../../../ports/AuthService.js";
|
|
8
|
+
import { TeamRepository } from "../../../../ports/TeamRepository.js";
|
|
9
|
+
import { ProjectRepository } from "../../../../ports/ProjectRepository.js";
|
|
10
|
+
import { LinearConfig } from "../../../../domain/Config.js";
|
|
11
|
+
import type { Team, Project } from "../../../../domain/Task.js";
|
|
12
|
+
|
|
13
|
+
const teamOption = Options.text("team").pipe(
|
|
14
|
+
Options.withAlias("t"),
|
|
15
|
+
Options.withDescription("Team ID or key to use"),
|
|
16
|
+
Options.optional,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const projectOption = Options.text("project").pipe(
|
|
20
|
+
Options.withAlias("p"),
|
|
21
|
+
Options.withDescription("Project ID or name to use"),
|
|
22
|
+
Options.optional,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export const initCommand = Command.make(
|
|
26
|
+
"init",
|
|
27
|
+
{ team: teamOption, project: projectOption },
|
|
28
|
+
({ team, project }) =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const config = yield* ConfigRepository;
|
|
31
|
+
const auth = yield* AuthService;
|
|
32
|
+
const teamRepo = yield* TeamRepository;
|
|
33
|
+
const projectRepo = yield* ProjectRepository;
|
|
34
|
+
|
|
35
|
+
clack.intro("ship init");
|
|
36
|
+
|
|
37
|
+
// Check if already initialized
|
|
38
|
+
const exists = yield* config.exists();
|
|
39
|
+
if (exists) {
|
|
40
|
+
const partial = yield* config.loadPartial();
|
|
41
|
+
if (Option.isSome(partial.auth) && Option.isSome(partial.linear)) {
|
|
42
|
+
clack.note(
|
|
43
|
+
`Team: ${partial.linear.value.teamKey}${Option.isSome(partial.linear.value.projectId) ? `\nProject: ${partial.linear.value.projectId.value}` : ""}`,
|
|
44
|
+
"Already initialized",
|
|
45
|
+
);
|
|
46
|
+
clack.outro("Run 'ship ready' to see available tasks.");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Step 1: Authenticate if needed
|
|
52
|
+
const isAuth = yield* auth.isAuthenticated();
|
|
53
|
+
if (!isAuth) {
|
|
54
|
+
clack.note(
|
|
55
|
+
"Create a personal API key at:\nhttps://linear.app/settings/api",
|
|
56
|
+
"Linear Authentication",
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const apiKey = yield* Effect.tryPromise({
|
|
60
|
+
try: () =>
|
|
61
|
+
clack.text({
|
|
62
|
+
message: "Paste your API key",
|
|
63
|
+
placeholder: "lin_api_...",
|
|
64
|
+
validate: (value) => {
|
|
65
|
+
if (!value) return "API key is required";
|
|
66
|
+
if (!value.startsWith("lin_api_")) return "API key should start with lin_api_";
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
catch: () => new Error("Prompt cancelled"),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (clack.isCancel(apiKey)) {
|
|
73
|
+
clack.cancel("Setup cancelled");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const spinner = clack.spinner();
|
|
78
|
+
spinner.start("Validating API key...");
|
|
79
|
+
|
|
80
|
+
yield* auth
|
|
81
|
+
.saveApiKey(apiKey as string)
|
|
82
|
+
.pipe(Effect.tapError(() => Effect.sync(() => spinner.stop("Invalid API key"))));
|
|
83
|
+
|
|
84
|
+
spinner.stop("Authenticated");
|
|
85
|
+
} else {
|
|
86
|
+
clack.log.success("Already authenticated");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Step 2: Get teams
|
|
90
|
+
const teamSpinner = clack.spinner();
|
|
91
|
+
teamSpinner.start("Fetching teams...");
|
|
92
|
+
const teams = yield* teamRepo.getTeams();
|
|
93
|
+
teamSpinner.stop("Teams loaded");
|
|
94
|
+
|
|
95
|
+
if (teams.length === 0) {
|
|
96
|
+
clack.log.error("No teams found. Please create a team in Linear first.");
|
|
97
|
+
clack.outro("Setup incomplete");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Select team
|
|
102
|
+
let selectedTeam: Team;
|
|
103
|
+
if (Option.isSome(team)) {
|
|
104
|
+
const found = teams.find((t) => t.id === team.value || t.key === team.value);
|
|
105
|
+
if (!found) {
|
|
106
|
+
clack.log.error(`Team '${team.value}' not found.`);
|
|
107
|
+
clack.note(teams.map((t) => `${t.key} - ${t.name}`).join("\n"), "Available teams");
|
|
108
|
+
clack.outro("Setup incomplete");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
selectedTeam = found;
|
|
112
|
+
clack.log.success(`Using team: ${selectedTeam.key}`);
|
|
113
|
+
} else if (teams.length === 1) {
|
|
114
|
+
selectedTeam = teams[0]!;
|
|
115
|
+
clack.log.success(`Using team: ${selectedTeam.key} - ${selectedTeam.name}`);
|
|
116
|
+
} else {
|
|
117
|
+
const teamChoice = yield* Effect.tryPromise({
|
|
118
|
+
try: () =>
|
|
119
|
+
clack.select({
|
|
120
|
+
message: "Select a team",
|
|
121
|
+
options: teams.map((t) => ({
|
|
122
|
+
value: t.id,
|
|
123
|
+
label: `${t.key} - ${t.name}`,
|
|
124
|
+
})),
|
|
125
|
+
}),
|
|
126
|
+
catch: () => new Error("Prompt cancelled"),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (clack.isCancel(teamChoice)) {
|
|
130
|
+
clack.cancel("Setup cancelled");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
selectedTeam = teams.find((t) => t.id === teamChoice)!;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Step 3: Get projects (optional)
|
|
138
|
+
const projectSpinner = clack.spinner();
|
|
139
|
+
projectSpinner.start("Fetching projects...");
|
|
140
|
+
const projects = yield* projectRepo.getProjects(selectedTeam.id);
|
|
141
|
+
projectSpinner.stop("Projects loaded");
|
|
142
|
+
|
|
143
|
+
let selectedProject: Project | undefined;
|
|
144
|
+
if (Option.isSome(project)) {
|
|
145
|
+
selectedProject = projects.find((p) => p.id === project.value || p.name === project.value);
|
|
146
|
+
if (!selectedProject) {
|
|
147
|
+
clack.log.warn(
|
|
148
|
+
`Project '${project.value}' not found, continuing without project filter.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
} else if (projects.length > 0) {
|
|
152
|
+
const projectChoice = yield* Effect.tryPromise({
|
|
153
|
+
try: () =>
|
|
154
|
+
clack.select({
|
|
155
|
+
message: "Select a project (optional)",
|
|
156
|
+
options: [
|
|
157
|
+
{ value: null, label: "No project filter" },
|
|
158
|
+
...projects.map((p) => ({
|
|
159
|
+
value: p.id,
|
|
160
|
+
label: p.name,
|
|
161
|
+
})),
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
164
|
+
catch: () => new Error("Prompt cancelled"),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (clack.isCancel(projectChoice)) {
|
|
168
|
+
clack.cancel("Setup cancelled");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (projectChoice) {
|
|
173
|
+
selectedProject = projects.find((p) => p.id === projectChoice);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Step 4: Save config and update .gitignore
|
|
178
|
+
const linearConfig = new LinearConfig({
|
|
179
|
+
teamId: selectedTeam.id,
|
|
180
|
+
teamKey: selectedTeam.key,
|
|
181
|
+
projectId: selectedProject ? Option.some(selectedProject.id) : Option.none(),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
yield* config.saveLinear(linearConfig);
|
|
185
|
+
yield* config.ensureGitignore();
|
|
186
|
+
|
|
187
|
+
clack.note(
|
|
188
|
+
`Team: ${selectedTeam.key} - ${selectedTeam.name}${selectedProject ? `\nProject: ${selectedProject.name}` : ""}`,
|
|
189
|
+
"Workspace initialized",
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
clack.outro("Run 'ship ready' to see available tasks.");
|
|
193
|
+
}),
|
|
194
|
+
);
|