Skip to content

Commit 3173b0c

Browse files
committed
feat: Add initiatives command with list, read, and update subcommands
- Add initiatives command with list/read/update operations - Add TypeScript interfaces for initiatives (Initiative, InitiativeConnection) - Add initiative service methods to LinearService - Include content field for full body markdown support
1 parent 3b3ccd1 commit 3173b0c

File tree

4 files changed

+399
-0
lines changed

4 files changed

+399
-0
lines changed

src/commands/initiatives.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Command } from "commander";
2+
import { createLinearService } from "../utils/linear-service.js";
3+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
4+
import type {
5+
InitiativeListOptions,
6+
InitiativeReadOptions,
7+
InitiativeUpdateOptions,
8+
} from "../utils/linear-types.js";
9+
10+
export function setupInitiativesCommands(program: Command): void {
11+
const initiatives = program
12+
.command("initiatives")
13+
.description("Initiative operations");
14+
15+
initiatives.action(() => initiatives.help());
16+
17+
initiatives
18+
.command("list")
19+
.description("List initiatives")
20+
.option("--status <status>", "filter by status (Planned/Active/Completed)")
21+
.option("--owner <ownerId>", "filter by owner ID")
22+
.option("-l, --limit <number>", "limit results", "50")
23+
.action(
24+
handleAsyncCommand(
25+
async (options: InitiativeListOptions, command: Command) => {
26+
const linearService = await createLinearService(
27+
command.parent!.parent!.opts(),
28+
);
29+
30+
const initiatives = await linearService.getInitiatives(
31+
options.status,
32+
options.owner,
33+
parseInt(options.limit || "50"),
34+
);
35+
36+
outputSuccess(initiatives);
37+
},
38+
),
39+
);
40+
41+
initiatives
42+
.command("read <initiativeIdOrName>")
43+
.description(
44+
"Get initiative details including projects and sub-initiatives. Accepts UUID or initiative name.",
45+
)
46+
.option(
47+
"--projects-first <n>",
48+
"how many projects to fetch (default 50)",
49+
"50",
50+
)
51+
.action(
52+
handleAsyncCommand(
53+
async (
54+
initiativeIdOrName: string,
55+
options: InitiativeReadOptions,
56+
command: Command,
57+
) => {
58+
const linearService = await createLinearService(
59+
command.parent!.parent!.opts(),
60+
);
61+
62+
// Resolve initiative ID (handles both UUID and name-based lookup)
63+
const initiativeId = await linearService.resolveInitiativeId(
64+
initiativeIdOrName,
65+
);
66+
67+
// Fetch initiative with projects and sub-initiatives
68+
const initiative = await linearService.getInitiativeById(
69+
initiativeId,
70+
parseInt(options.projectsFirst || "50"),
71+
);
72+
73+
outputSuccess(initiative);
74+
},
75+
),
76+
);
77+
78+
initiatives
79+
.command("update <initiativeIdOrName>")
80+
.description("Update an initiative. Accepts UUID or initiative name.")
81+
.option("-n, --name <name>", "new initiative name")
82+
.option("-d, --description <desc>", "new short description")
83+
.option("--content <content>", "new body content (markdown)")
84+
.option("--status <status>", "new status (Planned/Active/Completed)")
85+
.option("--owner <ownerId>", "new owner user ID")
86+
.option("--target-date <date>", "new target date (YYYY-MM-DD)")
87+
.action(
88+
handleAsyncCommand(
89+
async (
90+
initiativeIdOrName: string,
91+
options: InitiativeUpdateOptions,
92+
command: Command,
93+
) => {
94+
const linearService = await createLinearService(
95+
command.parent!.parent!.opts(),
96+
);
97+
98+
// Resolve initiative ID
99+
const initiativeId = await linearService.resolveInitiativeId(
100+
initiativeIdOrName,
101+
);
102+
103+
// Build update object with only provided fields
104+
const updates: Record<string, any> = {};
105+
if (options.name !== undefined) updates.name = options.name;
106+
if (options.description !== undefined)
107+
updates.description = options.description;
108+
if (options.content !== undefined) updates.content = options.content;
109+
if (options.status !== undefined) updates.status = options.status;
110+
if (options.owner !== undefined) updates.ownerId = options.owner;
111+
if (options.targetDate !== undefined)
112+
updates.targetDate = options.targetDate;
113+
114+
// Require at least one field to update
115+
if (Object.keys(updates).length === 0) {
116+
throw new Error(
117+
"At least one update option is required (--name, --description, --content, --status, --owner, or --target-date)",
118+
);
119+
}
120+
121+
const updated = await linearService.updateInitiative(
122+
initiativeId,
123+
updates,
124+
);
125+
126+
outputSuccess(updated);
127+
},
128+
),
129+
);
130+
}

src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { setupLabelsCommands } from "./commands/labels.js";
2323
import { setupProjectsCommands } from "./commands/projects.js";
2424
import { setupCyclesCommands } from "./commands/cycles.js";
2525
import { setupProjectMilestonesCommands } from "./commands/project-milestones.js";
26+
import { setupInitiativesCommands } from "./commands/initiatives.js";
2627
import { setupTeamsCommands } from "./commands/teams.js";
2728
import { setupUsersCommands } from "./commands/users.js";
2829
import { setupDocumentsCommands } from "./commands/documents.js";
@@ -47,6 +48,7 @@ setupLabelsCommands(program);
4748
setupProjectsCommands(program);
4849
setupCyclesCommands(program);
4950
setupProjectMilestonesCommands(program);
51+
setupInitiativesCommands(program);
5052
setupEmbedsCommands(program);
5153
setupTeamsCommands(program);
5254
setupUsersCommands(program);

src/utils/linear-service.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { CommandOptions, getApiToken } from "./auth.js";
33
import {
44
CreateCommentArgs,
55
LinearComment,
6+
LinearInitiative,
67
LinearIssue,
78
LinearLabel,
89
LinearProject,
@@ -687,6 +688,222 @@ export class LinearService {
687688

688689
return projectsConnection.nodes[0].id;
689690
}
691+
692+
/**
693+
* Get all initiatives
694+
*
695+
* @param statusFilter - Optional status filter (Planned/Active/Completed)
696+
* @param ownerFilter - Optional owner ID filter
697+
* @param limit - Maximum initiatives to fetch (default 50)
698+
* @returns Array of initiatives with owner information
699+
*/
700+
async getInitiatives(
701+
statusFilter?: string,
702+
ownerFilter?: string,
703+
limit: number = 50,
704+
): Promise<LinearInitiative[]> {
705+
const filter: any = {};
706+
707+
if (statusFilter) {
708+
filter.status = { eq: statusFilter };
709+
}
710+
711+
if (ownerFilter) {
712+
filter.owner = { id: { eq: ownerFilter } };
713+
}
714+
715+
const initiativesConnection = await this.client.initiatives({
716+
filter: Object.keys(filter).length > 0 ? filter : undefined,
717+
first: limit,
718+
});
719+
720+
// Fetch owner relationship in parallel for all initiatives
721+
const initiativesWithData = await Promise.all(
722+
initiativesConnection.nodes.map(async (initiative) => {
723+
const owner = await initiative.owner;
724+
return {
725+
id: initiative.id,
726+
name: initiative.name,
727+
description: initiative.description || undefined,
728+
content: initiative.content || undefined,
729+
status: initiative.status as "Planned" | "Active" | "Completed",
730+
health: initiative.health as
731+
| "onTrack"
732+
| "atRisk"
733+
| "offTrack"
734+
| undefined,
735+
targetDate: initiative.targetDate
736+
? new Date(initiative.targetDate).toISOString()
737+
: undefined,
738+
owner: owner
739+
? {
740+
id: owner.id,
741+
name: owner.name,
742+
}
743+
: undefined,
744+
createdAt: initiative.createdAt
745+
? new Date(initiative.createdAt).toISOString()
746+
: new Date().toISOString(),
747+
updatedAt: initiative.updatedAt
748+
? new Date(initiative.updatedAt).toISOString()
749+
: new Date().toISOString(),
750+
};
751+
}),
752+
);
753+
754+
return initiativesWithData;
755+
}
756+
757+
/**
758+
* Get single initiative by ID with projects and sub-initiatives
759+
*
760+
* @param initiativeId - Initiative UUID
761+
* @param projectsLimit - Maximum projects to fetch (default 50)
762+
* @returns Initiative with projects and sub-initiatives
763+
*/
764+
async getInitiativeById(
765+
initiativeId: string,
766+
projectsLimit: number = 50,
767+
): Promise<LinearInitiative> {
768+
const initiative = await this.client.initiative(initiativeId);
769+
770+
const [
771+
owner,
772+
projectsConnection,
773+
parentInitiative,
774+
subInitiativesConnection,
775+
] = await Promise.all([
776+
initiative.owner,
777+
initiative.projects({ first: projectsLimit }),
778+
initiative.parentInitiative,
779+
initiative.subInitiatives({ first: 50 }),
780+
]);
781+
782+
// Map projects with basic info
783+
const projects = projectsConnection.nodes.map((project) => ({
784+
id: project.id,
785+
name: project.name,
786+
state: project.state,
787+
progress: project.progress,
788+
}));
789+
790+
// Map sub-initiatives
791+
const subInitiatives = subInitiativesConnection.nodes.map((sub) => ({
792+
id: sub.id,
793+
name: sub.name,
794+
status: sub.status as "Planned" | "Active" | "Completed",
795+
}));
796+
797+
return {
798+
id: initiative.id,
799+
name: initiative.name,
800+
description: initiative.description || undefined,
801+
content: initiative.content || undefined,
802+
status: initiative.status as "Planned" | "Active" | "Completed",
803+
health: initiative.health as
804+
| "onTrack"
805+
| "atRisk"
806+
| "offTrack"
807+
| undefined,
808+
targetDate: initiative.targetDate
809+
? new Date(initiative.targetDate).toISOString()
810+
: undefined,
811+
owner: owner
812+
? {
813+
id: owner.id,
814+
name: owner.name,
815+
}
816+
: undefined,
817+
createdAt: initiative.createdAt
818+
? new Date(initiative.createdAt).toISOString()
819+
: new Date().toISOString(),
820+
updatedAt: initiative.updatedAt
821+
? new Date(initiative.updatedAt).toISOString()
822+
: new Date().toISOString(),
823+
projects: projects.length > 0 ? projects : undefined,
824+
parentInitiative: parentInitiative
825+
? {
826+
id: parentInitiative.id,
827+
name: parentInitiative.name,
828+
}
829+
: undefined,
830+
subInitiatives: subInitiatives.length > 0 ? subInitiatives : undefined,
831+
};
832+
}
833+
834+
/**
835+
* Resolve initiative by name or ID
836+
*
837+
* @param initiativeNameOrId - Initiative name or UUID
838+
* @returns Initiative UUID
839+
* @throws Error if initiative not found or multiple matches
840+
*/
841+
async resolveInitiativeId(initiativeNameOrId: string): Promise<string> {
842+
// Return UUID as-is
843+
if (isUuid(initiativeNameOrId)) {
844+
return initiativeNameOrId;
845+
}
846+
847+
// Search by name (case-insensitive)
848+
const initiativesConnection = await this.client.initiatives({
849+
filter: { name: { eqIgnoreCase: initiativeNameOrId } },
850+
first: 10,
851+
});
852+
853+
const nodes = initiativesConnection.nodes;
854+
855+
if (nodes.length === 0) {
856+
throw notFoundError("Initiative", initiativeNameOrId);
857+
}
858+
859+
if (nodes.length === 1) {
860+
return nodes[0].id;
861+
}
862+
863+
// Multiple matches - prefer Active, then Planned
864+
let chosen = nodes.find((n) => n.status === "Active");
865+
if (!chosen) chosen = nodes.find((n) => n.status === "Planned");
866+
if (!chosen) chosen = nodes[0];
867+
868+
return chosen.id;
869+
}
870+
871+
/**
872+
* Update an initiative
873+
*
874+
* @param initiativeId - Initiative UUID
875+
* @param updates - Fields to update
876+
* @returns Updated initiative
877+
*/
878+
async updateInitiative(
879+
initiativeId: string,
880+
updates: {
881+
name?: string;
882+
description?: string;
883+
content?: string;
884+
status?: "Planned" | "Active" | "Completed";
885+
ownerId?: string;
886+
targetDate?: string;
887+
},
888+
): Promise<LinearInitiative> {
889+
// Build update input with only provided fields
890+
const input: Record<string, any> = {};
891+
if (updates.name !== undefined) input.name = updates.name;
892+
if (updates.description !== undefined) input.description = updates.description;
893+
if (updates.content !== undefined) input.content = updates.content;
894+
if (updates.status !== undefined) input.status = updates.status;
895+
if (updates.ownerId !== undefined) input.ownerId = updates.ownerId;
896+
if (updates.targetDate !== undefined) input.targetDate = updates.targetDate;
897+
898+
const payload = await this.client.updateInitiative(initiativeId, input);
899+
900+
if (!payload.success) {
901+
throw new Error("Failed to update initiative");
902+
}
903+
904+
// Re-fetch to get complete data
905+
return this.getInitiativeById(initiativeId);
906+
}
690907
}
691908

692909
/**

0 commit comments

Comments
 (0)