Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/commands/initiatives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Command } from "commander";
import { createLinearService } from "../utils/linear-service.js";
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
import type {
InitiativeListOptions,
InitiativeReadOptions,
InitiativeUpdateOptions,
} from "../utils/linear-types.js";

export function setupInitiativesCommands(program: Command): void {
const initiatives = program
.command("initiatives")
.description("Initiative operations");

initiatives.action(() => initiatives.help());

initiatives
.command("list")
.description("List initiatives")
.option("--status <status>", "filter by status (Planned/Active/Completed)")
.option("--owner <ownerId>", "filter by owner ID")
.option("-l, --limit <number>", "limit results", "50")
.action(
handleAsyncCommand(
async (options: InitiativeListOptions, command: Command) => {
const linearService = await createLinearService(
command.parent!.parent!.opts(),
);

const initiatives = await linearService.getInitiatives(
options.status,
options.owner,
parseInt(options.limit || "50"),
);

outputSuccess(initiatives);
},
),
);

initiatives
.command("read <initiativeIdOrName>")
.description(
"Get initiative details including projects and sub-initiatives. Accepts UUID or initiative name.",
)
.option(
"--projects-first <n>",
"how many projects to fetch (default 50)",
"50",
)
.action(
handleAsyncCommand(
async (
initiativeIdOrName: string,
options: InitiativeReadOptions,
command: Command,
) => {
const linearService = await createLinearService(
command.parent!.parent!.opts(),
);

// Resolve initiative ID (handles both UUID and name-based lookup)
const initiativeId = await linearService.resolveInitiativeId(
initiativeIdOrName,
);

// Fetch initiative with projects and sub-initiatives
const initiative = await linearService.getInitiativeById(
initiativeId,
parseInt(options.projectsFirst || "50"),
);

outputSuccess(initiative);
},
),
);

initiatives
.command("update <initiativeIdOrName>")
.description("Update an initiative. Accepts UUID or initiative name.")
.option("-n, --name <name>", "new initiative name")
.option("-d, --description <desc>", "new short description")
.option("--content <content>", "new body content (markdown)")
.option("--status <status>", "new status (Planned/Active/Completed)")
.option("--owner <ownerId>", "new owner user ID")
.option("--target-date <date>", "new target date (YYYY-MM-DD)")
.action(
handleAsyncCommand(
async (
initiativeIdOrName: string,
options: InitiativeUpdateOptions,
command: Command,
) => {
const linearService = await createLinearService(
command.parent!.parent!.opts(),
);

// Resolve initiative ID
const initiativeId = await linearService.resolveInitiativeId(
initiativeIdOrName,
);

// Build update object with only provided fields
const updates: Record<string, any> = {};
if (options.name !== undefined) updates.name = options.name;
if (options.description !== undefined)
updates.description = options.description;
if (options.content !== undefined) updates.content = options.content;
if (options.status !== undefined) updates.status = options.status;
if (options.owner !== undefined) updates.ownerId = options.owner;
if (options.targetDate !== undefined)
updates.targetDate = options.targetDate;

// Require at least one field to update
if (Object.keys(updates).length === 0) {
throw new Error(
"At least one update option is required (--name, --description, --content, --status, --owner, or --target-date)",
);
}

const updated = await linearService.updateInitiative(
initiativeId,
updates,
);

outputSuccess(updated);
},
),
);
}
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { setupLabelsCommands } from "./commands/labels.js";
import { setupProjectsCommands } from "./commands/projects.js";
import { setupCyclesCommands } from "./commands/cycles.js";
import { setupProjectMilestonesCommands } from "./commands/project-milestones.js";
import { setupInitiativesCommands } from "./commands/initiatives.js";
import { setupTeamsCommands } from "./commands/teams.js";
import { setupUsersCommands } from "./commands/users.js";
import { setupDocumentsCommands } from "./commands/documents.js";
Expand All @@ -47,6 +48,7 @@ setupLabelsCommands(program);
setupProjectsCommands(program);
setupCyclesCommands(program);
setupProjectMilestonesCommands(program);
setupInitiativesCommands(program);
setupEmbedsCommands(program);
setupTeamsCommands(program);
setupUsersCommands(program);
Expand Down
217 changes: 217 additions & 0 deletions src/utils/linear-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CommandOptions, getApiToken } from "./auth.js";
import {
CreateCommentArgs,
LinearComment,
LinearInitiative,
LinearIssue,
LinearLabel,
LinearProject,
Expand Down Expand Up @@ -687,6 +688,222 @@ export class LinearService {

return projectsConnection.nodes[0].id;
}

/**
* Get all initiatives
*
* @param statusFilter - Optional status filter (Planned/Active/Completed)
* @param ownerFilter - Optional owner ID filter
* @param limit - Maximum initiatives to fetch (default 50)
* @returns Array of initiatives with owner information
*/
async getInitiatives(
statusFilter?: string,
ownerFilter?: string,
limit: number = 50,
): Promise<LinearInitiative[]> {
const filter: any = {};

if (statusFilter) {
filter.status = { eq: statusFilter };
}

if (ownerFilter) {
filter.owner = { id: { eq: ownerFilter } };
}

const initiativesConnection = await this.client.initiatives({
filter: Object.keys(filter).length > 0 ? filter : undefined,
first: limit,
});

// Fetch owner relationship in parallel for all initiatives
const initiativesWithData = await Promise.all(
initiativesConnection.nodes.map(async (initiative) => {
const owner = await initiative.owner;
return {
id: initiative.id,
name: initiative.name,
description: initiative.description || undefined,
content: initiative.content || undefined,
status: initiative.status as "Planned" | "Active" | "Completed",
health: initiative.health as
| "onTrack"
| "atRisk"
| "offTrack"
| undefined,
targetDate: initiative.targetDate
? new Date(initiative.targetDate).toISOString()
: undefined,
owner: owner
? {
id: owner.id,
name: owner.name,
}
: undefined,
createdAt: initiative.createdAt
? new Date(initiative.createdAt).toISOString()
: new Date().toISOString(),
updatedAt: initiative.updatedAt
? new Date(initiative.updatedAt).toISOString()
: new Date().toISOString(),
};
}),
);

return initiativesWithData;
}

/**
* Get single initiative by ID with projects and sub-initiatives
*
* @param initiativeId - Initiative UUID
* @param projectsLimit - Maximum projects to fetch (default 50)
* @returns Initiative with projects and sub-initiatives
*/
async getInitiativeById(
initiativeId: string,
projectsLimit: number = 50,
): Promise<LinearInitiative> {
const initiative = await this.client.initiative(initiativeId);

const [
owner,
projectsConnection,
parentInitiative,
subInitiativesConnection,
] = await Promise.all([
initiative.owner,
initiative.projects({ first: projectsLimit }),
initiative.parentInitiative,
initiative.subInitiatives({ first: 50 }),
]);

// Map projects with basic info
const projects = projectsConnection.nodes.map((project) => ({
id: project.id,
name: project.name,
state: project.state,
progress: project.progress,
}));

// Map sub-initiatives
const subInitiatives = subInitiativesConnection.nodes.map((sub) => ({
id: sub.id,
name: sub.name,
status: sub.status as "Planned" | "Active" | "Completed",
}));

return {
id: initiative.id,
name: initiative.name,
description: initiative.description || undefined,
content: initiative.content || undefined,
status: initiative.status as "Planned" | "Active" | "Completed",
health: initiative.health as
| "onTrack"
| "atRisk"
| "offTrack"
| undefined,
targetDate: initiative.targetDate
? new Date(initiative.targetDate).toISOString()
: undefined,
owner: owner
? {
id: owner.id,
name: owner.name,
}
: undefined,
createdAt: initiative.createdAt
? new Date(initiative.createdAt).toISOString()
: new Date().toISOString(),
updatedAt: initiative.updatedAt
? new Date(initiative.updatedAt).toISOString()
: new Date().toISOString(),
projects: projects.length > 0 ? projects : undefined,
parentInitiative: parentInitiative
? {
id: parentInitiative.id,
name: parentInitiative.name,
}
: undefined,
subInitiatives: subInitiatives.length > 0 ? subInitiatives : undefined,
};
}

/**
* Resolve initiative by name or ID
*
* @param initiativeNameOrId - Initiative name or UUID
* @returns Initiative UUID
* @throws Error if initiative not found or multiple matches
*/
async resolveInitiativeId(initiativeNameOrId: string): Promise<string> {
// Return UUID as-is
if (isUuid(initiativeNameOrId)) {
return initiativeNameOrId;
}

// Search by name (case-insensitive)
const initiativesConnection = await this.client.initiatives({
filter: { name: { eqIgnoreCase: initiativeNameOrId } },
first: 10,
});

const nodes = initiativesConnection.nodes;

if (nodes.length === 0) {
throw notFoundError("Initiative", initiativeNameOrId);
}

if (nodes.length === 1) {
return nodes[0].id;
}

// Multiple matches - prefer Active, then Planned
let chosen = nodes.find((n) => n.status === "Active");
if (!chosen) chosen = nodes.find((n) => n.status === "Planned");
if (!chosen) chosen = nodes[0];

return chosen.id;
}

/**
* Update an initiative
*
* @param initiativeId - Initiative UUID
* @param updates - Fields to update
* @returns Updated initiative
*/
async updateInitiative(
initiativeId: string,
updates: {
name?: string;
description?: string;
content?: string;
status?: "Planned" | "Active" | "Completed";
ownerId?: string;
targetDate?: string;
},
): Promise<LinearInitiative> {
// Build update input with only provided fields
const input: Record<string, any> = {};
if (updates.name !== undefined) input.name = updates.name;
if (updates.description !== undefined) input.description = updates.description;
if (updates.content !== undefined) input.content = updates.content;
if (updates.status !== undefined) input.status = updates.status;
if (updates.ownerId !== undefined) input.ownerId = updates.ownerId;
if (updates.targetDate !== undefined) input.targetDate = updates.targetDate;

const payload = await this.client.updateInitiative(initiativeId, input);

if (!payload.success) {
throw new Error("Failed to update initiative");
}

// Re-fetch to get complete data
return this.getInitiativeById(initiativeId);
}
}

/**
Expand Down
Loading