@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,87 @@
|
|
|
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 { TaskFilter, type TaskStatus, type Priority, 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 statusOption = Options.choice("status", [
|
|
16
|
+
"backlog",
|
|
17
|
+
"todo",
|
|
18
|
+
"in_progress",
|
|
19
|
+
"in_review",
|
|
20
|
+
"done",
|
|
21
|
+
"cancelled",
|
|
22
|
+
]).pipe(Options.withAlias("s"), Options.withDescription("Filter by status"), Options.optional);
|
|
23
|
+
|
|
24
|
+
const priorityOption = Options.choice("priority", ["urgent", "high", "medium", "low", "none"]).pipe(
|
|
25
|
+
Options.withAlias("P"),
|
|
26
|
+
Options.withDescription("Filter by priority"),
|
|
27
|
+
Options.optional,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const mineOption = Options.boolean("mine").pipe(
|
|
31
|
+
Options.withAlias("m"),
|
|
32
|
+
Options.withDescription("Show only tasks assigned to me"),
|
|
33
|
+
Options.withDefault(false),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const formatTask = (task: Task): string => {
|
|
37
|
+
const priority = task.priority === "urgent" ? "[!]" : task.priority === "high" ? "[^]" : " ";
|
|
38
|
+
const stateName = task.state.name.padEnd(11);
|
|
39
|
+
return `${priority} ${task.identifier.padEnd(10)} ${stateName} ${task.title}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const listCommand = Command.make(
|
|
43
|
+
"list",
|
|
44
|
+
{ json: jsonOption, status: statusOption, priority: priorityOption, mine: mineOption },
|
|
45
|
+
({ json, status, priority, mine }) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const config = yield* ConfigRepository;
|
|
48
|
+
const issueRepo = yield* IssueRepository;
|
|
49
|
+
|
|
50
|
+
const cfg = yield* config.load();
|
|
51
|
+
|
|
52
|
+
const filter = new TaskFilter({
|
|
53
|
+
status: Option.isSome(status) ? Option.some(status.value as TaskStatus) : Option.none(),
|
|
54
|
+
priority: Option.isSome(priority) ? Option.some(priority.value as Priority) : Option.none(),
|
|
55
|
+
projectId: cfg.linear.projectId,
|
|
56
|
+
assignedToMe: mine,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const taskList = yield* issueRepo.listTasks(cfg.linear.teamId, filter);
|
|
60
|
+
|
|
61
|
+
if (json) {
|
|
62
|
+
const output = taskList.map((t) => ({
|
|
63
|
+
id: t.id,
|
|
64
|
+
identifier: t.identifier,
|
|
65
|
+
title: t.title,
|
|
66
|
+
priority: t.priority,
|
|
67
|
+
state: t.state.name,
|
|
68
|
+
stateType: t.state.type,
|
|
69
|
+
labels: t.labels,
|
|
70
|
+
url: t.url,
|
|
71
|
+
branchName: Option.getOrNull(t.branchName),
|
|
72
|
+
}));
|
|
73
|
+
yield* Console.log(JSON.stringify(output, null, 2));
|
|
74
|
+
} else {
|
|
75
|
+
if (taskList.length === 0) {
|
|
76
|
+
yield* Console.log("No tasks found matching the filter.");
|
|
77
|
+
} else {
|
|
78
|
+
yield* Console.log(`Found ${taskList.length} task(s):\n`);
|
|
79
|
+
yield* Console.log("PRI IDENTIFIER STATUS TITLE");
|
|
80
|
+
yield* Console.log("─".repeat(60));
|
|
81
|
+
for (const task of taskList) {
|
|
82
|
+
yield* Console.log(formatTask(task));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as Command from "@effect/cli/Command";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as clack from "@clack/prompts";
|
|
4
|
+
import { AuthService } from "../../../../ports/AuthService.js";
|
|
5
|
+
|
|
6
|
+
export const loginCommand = Command.make("login", {}, () =>
|
|
7
|
+
Effect.gen(function* () {
|
|
8
|
+
const auth = yield* AuthService;
|
|
9
|
+
|
|
10
|
+
clack.intro("ship login");
|
|
11
|
+
|
|
12
|
+
clack.note(
|
|
13
|
+
"Create a personal API key at:\nhttps://linear.app/settings/api",
|
|
14
|
+
"Linear Authentication",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const apiKey = yield* Effect.tryPromise({
|
|
18
|
+
try: () =>
|
|
19
|
+
clack.text({
|
|
20
|
+
message: "Paste your API key",
|
|
21
|
+
placeholder: "lin_api_...",
|
|
22
|
+
validate: (value) => {
|
|
23
|
+
if (!value) return "API key is required";
|
|
24
|
+
if (!value.startsWith("lin_api_")) return "API key should start with lin_api_";
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
catch: () => new Error("Prompt cancelled"),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (clack.isCancel(apiKey)) {
|
|
31
|
+
clack.cancel("Login cancelled");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const spinner = clack.spinner();
|
|
36
|
+
spinner.start("Validating API key...");
|
|
37
|
+
|
|
38
|
+
yield* auth
|
|
39
|
+
.saveApiKey(apiKey as string)
|
|
40
|
+
.pipe(Effect.tapError(() => Effect.sync(() => spinner.stop("Invalid API key"))));
|
|
41
|
+
|
|
42
|
+
spinner.stop("API key validated");
|
|
43
|
+
|
|
44
|
+
clack.outro("Run 'ship init' to select your team and project.");
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as Command from "@effect/cli/Command";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Console from "effect/Console";
|
|
4
|
+
import * as Option from "effect/Option";
|
|
5
|
+
import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
|
|
6
|
+
import { IssueRepository } from "../../../../ports/IssueRepository.js";
|
|
7
|
+
import { TaskFilter, type Task } from "../../../../domain/Task.js";
|
|
8
|
+
|
|
9
|
+
const AGENT_GUIDANCE = `## Agent Restrictions
|
|
10
|
+
|
|
11
|
+
1. **Never create issues without user confirmation**
|
|
12
|
+
2. **Check blockers before starting work** - blocked tasks should be surfaced
|
|
13
|
+
3. **Small, focused tasks only** - if a task seems too large, suggest splitting
|
|
14
|
+
4. **Always update status** - in_progress when starting, done when complete
|
|
15
|
+
5. **Stack blocking tasks** - work on blockers first, stack changes appropriately
|
|
16
|
+
6. **Use conventional commits** - format: type(TASK-ID): description
|
|
17
|
+
|
|
18
|
+
## CLI Commands
|
|
19
|
+
|
|
20
|
+
- \`ship ready --json\` - List tasks with no blockers
|
|
21
|
+
- \`ship show <id> --json\` - Show task details
|
|
22
|
+
- \`ship start <id>\` - Begin work (sets status to in_progress)
|
|
23
|
+
- \`ship done <id> --reason "msg"\` - Complete task
|
|
24
|
+
- \`ship blocked --json\` - Show blocked tasks
|
|
25
|
+
- \`ship block <blocker> <blocked>\` - Create blocking relationship
|
|
26
|
+
- \`ship unblock <blocker> <blocked>\` - Remove blocking relationship
|
|
27
|
+
- \`ship list --json\` - List all tasks
|
|
28
|
+
- \`ship create "title" -p priority -t type\` - Create new task
|
|
29
|
+
|
|
30
|
+
Always use \`--json\` flag for programmatic output.`;
|
|
31
|
+
|
|
32
|
+
const formatTaskCompact = (task: Task): string => {
|
|
33
|
+
const priority = task.priority === "urgent" ? "!" : task.priority === "high" ? "^" : "";
|
|
34
|
+
return `${priority}${task.identifier}: ${task.title} [${task.state.name}]`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const primeCommand = Command.make("prime", {}, () =>
|
|
38
|
+
Effect.gen(function* () {
|
|
39
|
+
const config = yield* ConfigRepository;
|
|
40
|
+
const issueRepo = yield* IssueRepository;
|
|
41
|
+
|
|
42
|
+
const cfg = yield* config.load();
|
|
43
|
+
const projectId = Option.getOrUndefined(cfg.linear.projectId);
|
|
44
|
+
|
|
45
|
+
// Get ready and blocked tasks
|
|
46
|
+
const [readyTasks, blockedTasks] = yield* Effect.all([
|
|
47
|
+
issueRepo.getReadyTasks(cfg.linear.teamId, projectId),
|
|
48
|
+
issueRepo.getBlockedTasks(cfg.linear.teamId, projectId),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// Get in-progress tasks (filter by "started" state type)
|
|
52
|
+
const filter = new TaskFilter({
|
|
53
|
+
status: Option.some("in_progress"),
|
|
54
|
+
priority: Option.none(),
|
|
55
|
+
projectId: cfg.linear.projectId,
|
|
56
|
+
assignedToMe: false,
|
|
57
|
+
});
|
|
58
|
+
const allTasks = yield* issueRepo.listTasks(cfg.linear.teamId, filter);
|
|
59
|
+
|
|
60
|
+
// Filter to only "started" state type tasks
|
|
61
|
+
const inProgressTasks = allTasks.filter((t: Task) => t.state.type === "started");
|
|
62
|
+
|
|
63
|
+
// Build context output
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
|
|
66
|
+
lines.push("<ship-context>");
|
|
67
|
+
lines.push(`Team: ${cfg.linear.teamKey}`);
|
|
68
|
+
if (Option.isSome(cfg.linear.projectId)) {
|
|
69
|
+
lines.push(`Project: ${cfg.linear.projectId.value}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
|
|
73
|
+
if (inProgressTasks.length > 0) {
|
|
74
|
+
lines.push("## In Progress");
|
|
75
|
+
for (const task of inProgressTasks) {
|
|
76
|
+
lines.push(`- ${formatTaskCompact(task)}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (readyTasks.length > 0) {
|
|
82
|
+
lines.push("## Ready to Work");
|
|
83
|
+
for (const task of readyTasks.slice(0, 10)) {
|
|
84
|
+
lines.push(`- ${formatTaskCompact(task)}`);
|
|
85
|
+
}
|
|
86
|
+
if (readyTasks.length > 10) {
|
|
87
|
+
lines.push(` ... and ${readyTasks.length - 10} more`);
|
|
88
|
+
}
|
|
89
|
+
lines.push("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (blockedTasks.length > 0) {
|
|
93
|
+
lines.push("## Blocked");
|
|
94
|
+
for (const task of blockedTasks.slice(0, 5)) {
|
|
95
|
+
lines.push(`- ${formatTaskCompact(task)}`);
|
|
96
|
+
if (task.blockedBy.length > 0) {
|
|
97
|
+
lines.push(` Blocked by: ${task.blockedBy.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (blockedTasks.length > 5) {
|
|
101
|
+
lines.push(` ... and ${blockedTasks.length - 5} more`);
|
|
102
|
+
}
|
|
103
|
+
lines.push("");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
lines.push("</ship-context>");
|
|
107
|
+
lines.push("");
|
|
108
|
+
lines.push("<ship-guidance>");
|
|
109
|
+
lines.push(AGENT_GUIDANCE);
|
|
110
|
+
lines.push("</ship-guidance>");
|
|
111
|
+
|
|
112
|
+
yield* Console.log(lines.join("\n"));
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as Command from "@effect/cli/Command";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Option from "effect/Option";
|
|
4
|
+
import * as clack from "@clack/prompts";
|
|
5
|
+
import { ConfigRepository } from "../../../../ports/ConfigRepository.js";
|
|
6
|
+
import { AuthService } from "../../../../ports/AuthService.js";
|
|
7
|
+
import { ProjectRepository } from "../../../../ports/ProjectRepository.js";
|
|
8
|
+
import { LinearConfig } from "../../../../domain/Config.js";
|
|
9
|
+
import type { Project, ProjectId } from "../../../../domain/Task.js";
|
|
10
|
+
|
|
11
|
+
const CREATE_NEW = "__create_new__" as const;
|
|
12
|
+
const NO_PROJECT = null;
|
|
13
|
+
|
|
14
|
+
export const projectCommand = Command.make("project", {}, () =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const config = yield* ConfigRepository;
|
|
17
|
+
const auth = yield* AuthService;
|
|
18
|
+
const projectRepo = yield* ProjectRepository;
|
|
19
|
+
|
|
20
|
+
clack.intro("ship project");
|
|
21
|
+
|
|
22
|
+
// Check authentication
|
|
23
|
+
const isAuth = yield* auth.isAuthenticated();
|
|
24
|
+
if (!isAuth) {
|
|
25
|
+
clack.log.error("Not authenticated. Run 'ship login' first.");
|
|
26
|
+
clack.outro("Setup required");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check team is configured
|
|
31
|
+
const partial = yield* config.loadPartial();
|
|
32
|
+
if (Option.isNone(partial.linear)) {
|
|
33
|
+
clack.log.error("No team configured. Run 'ship init' first.");
|
|
34
|
+
clack.outro("Setup required");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const currentConfig = partial.linear.value;
|
|
39
|
+
clack.log.info(`Team: ${currentConfig.teamKey}`);
|
|
40
|
+
|
|
41
|
+
if (Option.isSome(currentConfig.projectId)) {
|
|
42
|
+
clack.log.info(`Current project: ${currentConfig.projectId.value}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fetch projects
|
|
46
|
+
const spinner = clack.spinner();
|
|
47
|
+
spinner.start("Fetching projects...");
|
|
48
|
+
const projects = yield* projectRepo.getProjects(currentConfig.teamId);
|
|
49
|
+
spinner.stop("Projects loaded");
|
|
50
|
+
|
|
51
|
+
// Select project or create new
|
|
52
|
+
const currentProjectId = Option.isSome(currentConfig.projectId)
|
|
53
|
+
? currentConfig.projectId.value
|
|
54
|
+
: null;
|
|
55
|
+
|
|
56
|
+
const projectOptions: Array<{
|
|
57
|
+
value: ProjectId | typeof CREATE_NEW | typeof NO_PROJECT;
|
|
58
|
+
label: string;
|
|
59
|
+
hint?: string;
|
|
60
|
+
}> = [
|
|
61
|
+
{ value: NO_PROJECT, label: "No project filter", hint: "show all team tasks" },
|
|
62
|
+
...projects.map((p) =>
|
|
63
|
+
currentProjectId === p.id
|
|
64
|
+
? { value: p.id, label: p.name, hint: "current" as const }
|
|
65
|
+
: { value: p.id, label: p.name },
|
|
66
|
+
),
|
|
67
|
+
{ value: CREATE_NEW, label: "Create new project..." },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const projectChoice = yield* Effect.tryPromise({
|
|
71
|
+
try: () =>
|
|
72
|
+
clack.select({
|
|
73
|
+
message: "Select a project",
|
|
74
|
+
options: projectOptions,
|
|
75
|
+
}),
|
|
76
|
+
catch: () => new Error("Prompt cancelled"),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (clack.isCancel(projectChoice)) {
|
|
80
|
+
clack.cancel("Cancelled");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let selectedProject: Project | undefined;
|
|
85
|
+
|
|
86
|
+
if (projectChoice === CREATE_NEW) {
|
|
87
|
+
// Create new project
|
|
88
|
+
const projectName = yield* Effect.tryPromise({
|
|
89
|
+
try: () =>
|
|
90
|
+
clack.text({
|
|
91
|
+
message: "Project name",
|
|
92
|
+
placeholder: "My Project",
|
|
93
|
+
validate: (v) => (!v ? "Name is required" : undefined),
|
|
94
|
+
}),
|
|
95
|
+
catch: () => new Error("Prompt cancelled"),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (clack.isCancel(projectName)) {
|
|
99
|
+
clack.cancel("Cancelled");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const projectDesc = yield* Effect.tryPromise({
|
|
104
|
+
try: () =>
|
|
105
|
+
clack.text({
|
|
106
|
+
message: "Description (optional)",
|
|
107
|
+
placeholder: "A brief description of the project",
|
|
108
|
+
}),
|
|
109
|
+
catch: () => new Error("Prompt cancelled"),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (clack.isCancel(projectDesc)) {
|
|
113
|
+
clack.cancel("Cancelled");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const createSpinner = clack.spinner();
|
|
118
|
+
createSpinner.start("Creating project...");
|
|
119
|
+
|
|
120
|
+
const createInput = { name: projectName as string } as { name: string; description?: string };
|
|
121
|
+
if (projectDesc) {
|
|
122
|
+
createInput.description = projectDesc as string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
selectedProject = yield* projectRepo.createProject(currentConfig.teamId, createInput);
|
|
126
|
+
|
|
127
|
+
createSpinner.stop(`Created project: ${selectedProject.name}`);
|
|
128
|
+
} else if (projectChoice !== NO_PROJECT) {
|
|
129
|
+
const found = projects.find((p) => p.id === projectChoice);
|
|
130
|
+
if (!found) {
|
|
131
|
+
clack.log.error("Selected project not found. Please try again.");
|
|
132
|
+
clack.outro("Error");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
selectedProject = found;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Save updated config
|
|
139
|
+
const linearConfig = new LinearConfig({
|
|
140
|
+
teamId: currentConfig.teamId,
|
|
141
|
+
teamKey: currentConfig.teamKey,
|
|
142
|
+
projectId: selectedProject ? Option.some(selectedProject.id) : Option.none(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
yield* config.saveLinear(linearConfig);
|
|
146
|
+
|
|
147
|
+
if (selectedProject) {
|
|
148
|
+
clack.log.success(`Switched to project: ${selectedProject.name}`);
|
|
149
|
+
} else {
|
|
150
|
+
clack.log.success("Cleared project filter");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
clack.outro("Run 'ship ready' to see available tasks.");
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
@@ -0,0 +1,73 @@
|
|
|
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 formatTaskDetailed = (task: Task): string[] => {
|
|
16
|
+
const lines: string[] = [];
|
|
17
|
+
const priority =
|
|
18
|
+
task.priority === "urgent"
|
|
19
|
+
? "URGENT"
|
|
20
|
+
: task.priority === "high"
|
|
21
|
+
? "HIGH"
|
|
22
|
+
: task.priority === "medium"
|
|
23
|
+
? "MEDIUM"
|
|
24
|
+
: task.priority === "low"
|
|
25
|
+
? "LOW"
|
|
26
|
+
: "";
|
|
27
|
+
|
|
28
|
+
lines.push(`${task.identifier}: ${task.title}`);
|
|
29
|
+
if (priority) lines.push(` Priority: ${priority}`);
|
|
30
|
+
if (task.labels.length > 0) lines.push(` Labels: ${task.labels.join(", ")}`);
|
|
31
|
+
if (Option.isSome(task.branchName)) lines.push(` Branch: ${task.branchName.value}`);
|
|
32
|
+
lines.push(` URL: ${task.url}`);
|
|
33
|
+
return lines;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const readyCommand = Command.make("ready", { json: jsonOption }, ({ json }) =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const config = yield* ConfigRepository;
|
|
39
|
+
const issueRepo = yield* IssueRepository;
|
|
40
|
+
|
|
41
|
+
const cfg = yield* config.load();
|
|
42
|
+
const projectId = Option.getOrUndefined(cfg.linear.projectId);
|
|
43
|
+
const readyTasks = yield* issueRepo.getReadyTasks(cfg.linear.teamId, projectId);
|
|
44
|
+
|
|
45
|
+
if (json) {
|
|
46
|
+
const output = readyTasks.map((t) => ({
|
|
47
|
+
id: t.id,
|
|
48
|
+
identifier: t.identifier,
|
|
49
|
+
title: t.title,
|
|
50
|
+
priority: t.priority,
|
|
51
|
+
state: t.state.name,
|
|
52
|
+
stateType: t.state.type,
|
|
53
|
+
labels: t.labels,
|
|
54
|
+
url: t.url,
|
|
55
|
+
branchName: Option.getOrNull(t.branchName),
|
|
56
|
+
}));
|
|
57
|
+
yield* Console.log(JSON.stringify(output, null, 2));
|
|
58
|
+
} else {
|
|
59
|
+
if (readyTasks.length === 0) {
|
|
60
|
+
yield* Console.log("No ready tasks found.");
|
|
61
|
+
yield* Console.log("All tasks may be blocked or completed.");
|
|
62
|
+
} else {
|
|
63
|
+
yield* Console.log(`Found ${readyTasks.length} ready task(s):\n`);
|
|
64
|
+
for (const task of readyTasks) {
|
|
65
|
+
for (const line of formatTaskDetailed(task)) {
|
|
66
|
+
yield* Console.log(line);
|
|
67
|
+
}
|
|
68
|
+
yield* Console.log("");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { IssueRepository } from "../../../../ports/IssueRepository.js";
|
|
8
|
+
import type { Task, TaskId } from "../../../../domain/Task.js";
|
|
9
|
+
|
|
10
|
+
const taskIdArg = Args.text({ name: "task-id" }).pipe(
|
|
11
|
+
Args.withDescription("Task identifier (e.g., ENG-123)"),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
15
|
+
Options.withDescription("Output as JSON"),
|
|
16
|
+
Options.withDefault(false),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const formatTask = (task: Task): string[] => {
|
|
20
|
+
const lines: string[] = [];
|
|
21
|
+
|
|
22
|
+
lines.push(`${task.identifier}: ${task.title}`);
|
|
23
|
+
lines.push("─".repeat(50));
|
|
24
|
+
lines.push(`Status: ${task.state.name}`);
|
|
25
|
+
lines.push(`Priority: ${task.priority}`);
|
|
26
|
+
|
|
27
|
+
if (task.labels.length > 0) {
|
|
28
|
+
lines.push(`Labels: ${task.labels.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (Option.isSome(task.branchName)) {
|
|
32
|
+
lines.push(`Branch: ${task.branchName.value}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
lines.push(`URL: ${task.url}`);
|
|
36
|
+
|
|
37
|
+
if (Option.isSome(task.description)) {
|
|
38
|
+
lines.push("");
|
|
39
|
+
lines.push("Description:");
|
|
40
|
+
lines.push(task.description.value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (task.blockedBy.length > 0) {
|
|
44
|
+
lines.push("");
|
|
45
|
+
lines.push(`Blocked by: ${task.blockedBy.join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (task.blocks.length > 0) {
|
|
49
|
+
lines.push(`Blocks: ${task.blocks.join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return lines;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const showCommand = Command.make(
|
|
56
|
+
"show",
|
|
57
|
+
{ taskId: taskIdArg, json: jsonOption },
|
|
58
|
+
({ taskId, json }) =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
const issueRepo = yield* IssueRepository;
|
|
61
|
+
|
|
62
|
+
// Try to get by identifier first (ENG-123 format)
|
|
63
|
+
const task = yield* issueRepo.getTaskByIdentifier(taskId).pipe(
|
|
64
|
+
Effect.catchTag("TaskNotFoundError", () =>
|
|
65
|
+
// Fallback to ID lookup
|
|
66
|
+
issueRepo.getTask(taskId as TaskId),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (json) {
|
|
71
|
+
const output = {
|
|
72
|
+
id: task.id,
|
|
73
|
+
identifier: task.identifier,
|
|
74
|
+
title: task.title,
|
|
75
|
+
description: Option.getOrNull(task.description),
|
|
76
|
+
priority: task.priority,
|
|
77
|
+
state: task.state.name,
|
|
78
|
+
stateType: task.state.type,
|
|
79
|
+
labels: task.labels,
|
|
80
|
+
url: task.url,
|
|
81
|
+
branchName: Option.getOrNull(task.branchName),
|
|
82
|
+
blockedBy: task.blockedBy,
|
|
83
|
+
blocks: task.blocks,
|
|
84
|
+
createdAt: task.createdAt.toISOString(),
|
|
85
|
+
updatedAt: task.updatedAt.toISOString(),
|
|
86
|
+
};
|
|
87
|
+
yield* Console.log(JSON.stringify(output, null, 2));
|
|
88
|
+
} else {
|
|
89
|
+
for (const line of formatTask(task)) {
|
|
90
|
+
yield* Console.log(line);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
@@ -0,0 +1,99 @@
|
|
|
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 jsonOption = Options.boolean("json").pipe(
|
|
16
|
+
Options.withDescription("Output as JSON"),
|
|
17
|
+
Options.withDefault(false),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export const startCommand = Command.make(
|
|
21
|
+
"start",
|
|
22
|
+
{ taskId: taskIdArg, json: jsonOption },
|
|
23
|
+
({ taskId, json }) =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const config = yield* ConfigRepository;
|
|
26
|
+
const issueRepo = yield* IssueRepository;
|
|
27
|
+
|
|
28
|
+
yield* config.load(); // Ensure initialized
|
|
29
|
+
|
|
30
|
+
// Get the task
|
|
31
|
+
const task = yield* issueRepo
|
|
32
|
+
.getTaskByIdentifier(taskId)
|
|
33
|
+
.pipe(Effect.catchTag("TaskNotFoundError", () => issueRepo.getTask(taskId as TaskId)));
|
|
34
|
+
|
|
35
|
+
// Check if already in progress (Linear's "started" state type)
|
|
36
|
+
if (task.state.type === "started") {
|
|
37
|
+
if (json) {
|
|
38
|
+
yield* Console.log(JSON.stringify({ status: "already_in_progress", task: taskId }));
|
|
39
|
+
} else {
|
|
40
|
+
yield* Console.log(`Task ${task.identifier} is already in progress (${task.state.name}).`);
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check if blocked
|
|
46
|
+
if (task.blockedBy.length > 0) {
|
|
47
|
+
if (json) {
|
|
48
|
+
yield* Console.log(
|
|
49
|
+
JSON.stringify({
|
|
50
|
+
status: "blocked",
|
|
51
|
+
task: taskId,
|
|
52
|
+
blockedBy: task.blockedBy,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
yield* Console.log(
|
|
57
|
+
`Warning: Task ${task.identifier} is blocked by: ${task.blockedBy.join(", ")}`,
|
|
58
|
+
);
|
|
59
|
+
yield* Console.log("Consider working on the blocking tasks first.");
|
|
60
|
+
}
|
|
61
|
+
// Continue anyway - user might want to start despite blockers
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Update status to in_progress
|
|
65
|
+
const updateInput = new UpdateTaskInput({
|
|
66
|
+
title: Option.none(),
|
|
67
|
+
description: Option.none(),
|
|
68
|
+
status: Option.some("in_progress"),
|
|
69
|
+
priority: Option.none(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const updatedTask = yield* issueRepo.updateTask(task.id, updateInput);
|
|
73
|
+
|
|
74
|
+
// Get branch name
|
|
75
|
+
const branchName = yield* issueRepo.getBranchName(task.id);
|
|
76
|
+
|
|
77
|
+
if (json) {
|
|
78
|
+
yield* Console.log(
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
status: "started",
|
|
81
|
+
task: {
|
|
82
|
+
id: updatedTask.id,
|
|
83
|
+
identifier: updatedTask.identifier,
|
|
84
|
+
title: updatedTask.title,
|
|
85
|
+
state: updatedTask.state.name,
|
|
86
|
+
branchName,
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
} else {
|
|
91
|
+
yield* Console.log(`Started: ${updatedTask.identifier} - ${updatedTask.title}`);
|
|
92
|
+
yield* Console.log(`Status: ${updatedTask.state.name}`);
|
|
93
|
+
yield* Console.log(`\nBranch name: ${branchName}`);
|
|
94
|
+
yield* Console.log("\nTo create a jj change with this branch (Phase 2):");
|
|
95
|
+
yield* Console.log(` jj new -m "${updatedTask.identifier}: ${updatedTask.title}"`);
|
|
96
|
+
yield* Console.log(` jj bookmark create ${branchName}`);
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
);
|