From 3e512308eb9bccb5c36b5c0418d51d876eb3af24 Mon Sep 17 00:00:00 2001 From: Sam Gbafa Date: Sun, 18 Jan 2026 02:17:34 -0800 Subject: [PATCH 1/9] Add initiative, label, and bulk operation support (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * TC-522: Add team delete and initiative update/unarchive/delete commands - team delete: with --move-issues option to migrate issues before deletion - initiative update: update name, description, status, owner, target date, color, icon - initiative unarchive: restore archived initiatives with confirmation - initiative delete: permanent deletion with typed name confirmation for safety All commands include proper safety confirmations for destructive operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * TC-521: Add bulk operation support across commands - Add shared bulk utilities (src/utils/bulk.ts): - collectBulkIds: supports --bulk, --bulk-file, --bulk-stdin - executeBulkOperations: concurrent execution with progress - printBulkSummary: formatted output with success/failure details - Add initiative-archive command with bulk support: - Single and bulk archive operations - Progress reporting during bulk operations - Update initiative-delete with bulk support: - Add --bulk, --bulk-file, --bulk-stdin flags - Refactor into handleSingleDelete/handleBulkDelete - Update issue-delete with bulk support: - Add --bulk, --bulk-file, --bulk-stdin flags - Refactor into handleSingleDelete/handleBulkDelete Example usage: linear init archive --bulk id1 id2 id3 linear issue delete --bulk TC-100 TC-101 TC-102 linear init archive --bulk-file initiatives-to-archive.txt linear issue list --state canceled --output-ids | linear issue delete --bulk-stdin 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * TC-520: Add project create command - Add project-create.ts with: - GraphQL mutations for CreateProject and AddProjectToInitiative - Interactive mode with prompts for name, team, status, lead, dates - CLI flags: --name, --team, --description, --lead, --status, --start-date, --target-date, --initiative - Team lookup/validation via getTeamIdByKey - Status lookup from actual project statuses in org - Optional initiative linking at creation time - Register create command in project.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * TC-519: Add label list, create, and delete commands - Add label command with list, create, delete subcommands - label list: workspace/team filtering, JSON output - label create: name, color, description, team; interactive mode - label delete: by name or ID with team disambiguation - Register label command in main.ts with alias "l" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * TC-518: Add initiative add-project and remove-project commands Adds two new subcommands for linking projects to initiatives: - `linear init add-project `: Link a project to an initiative - `linear init remove-project `: Unlink a project from an initiative Features: - Resolve initiatives/projects by UUID, slug, or name (case-insensitive) - Optional --sort-order for add-project to set position - Confirmation prompt for remove-project (skip with -y/--force) - Proper error handling for already-linked or not-linked cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * TC-517: Add initiative create command - New initiative-create.ts with name, description, status, owner, target-date, color, and icon options - Supports both interactive mode (prompts) and flag-based creation - Status options: planned, active, paused, completed, canceled - Owner lookup via username, email, or @me - Registered create subcommand in initiative.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * TC-516: Implement initiative list and view commands - Add initiative-list.ts: - Lists initiatives with filtering by status, owner, archived - Default: active only, use --all-statuses for all - Table output with SLUG, NAME, STATUS, HEALTH, OWNER, PROJ, TARGET - Color-coded status display - Supports --json, --web, --app options - Add initiative-view.ts: - View initiative details by UUID, slug, or name - Shows info, description, and linked projects grouped by status - Supports --json, --web, --app options - Update initiative.ts to register list (alias: ls) and view commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * build fixes * Fix GraphQL schema issues for initiative commands - Rename duplicate GetInitiativeDetails to GetInitiativeForUpdate - Fix initiativeToProjects query (API doesn't support filter, use client-side filtering) - Fix initiativeUnarchive return type (use 'entity' not 'initiative') - Fix initiative status enum values (Planned, Active, Completed - capitalized) - Fix initiative list archivedAt filter (use includeArchived query param instead) Co-Authored-By: Claude Opus 4.5 * Fix label commands - Add label ID column to label list output - Fix label delete error handling for invalid input - Fix initiative unarchive to query with includeArchived Co-Authored-By: Claude Opus 4.5 * update skill --------- Co-authored-by: Claude --- skills/linear-cli/SKILL.md | 97 ++++- .../initiative/initiative-add-project.ts | 240 +++++++++++ src/commands/initiative/initiative-archive.ts | 349 +++++++++++++++ src/commands/initiative/initiative-create.ts | 262 +++++++++++ src/commands/initiative/initiative-delete.ts | 356 +++++++++++++++ src/commands/initiative/initiative-list.ts | 306 +++++++++++++ .../initiative/initiative-remove-project.ts | 280 ++++++++++++ .../initiative/initiative-unarchive.ts | 169 ++++++++ src/commands/initiative/initiative-update.ts | 279 ++++++++++++ src/commands/initiative/initiative-view.ts | 331 ++++++++++++++ src/commands/initiative/initiative.ts | 27 ++ src/commands/issue/issue-delete.ts | 274 ++++++++++-- src/commands/label/label-create.ts | 217 ++++++++++ src/commands/label/label-delete.ts | 190 ++++++++ src/commands/label/label-list.ts | 196 +++++++++ src/commands/label/label.ts | 13 + src/commands/project/project-create.ts | 406 ++++++++++++++++++ src/commands/project/project.ts | 2 + src/commands/team/team-delete.ts | 215 ++++++++++ src/commands/team/team.ts | 2 + src/main.ts | 6 + src/utils/bulk.ts | 261 +++++++++++ 22 files changed, 4425 insertions(+), 53 deletions(-) create mode 100644 src/commands/initiative/initiative-add-project.ts create mode 100644 src/commands/initiative/initiative-archive.ts create mode 100644 src/commands/initiative/initiative-create.ts create mode 100644 src/commands/initiative/initiative-delete.ts create mode 100644 src/commands/initiative/initiative-list.ts create mode 100644 src/commands/initiative/initiative-remove-project.ts create mode 100644 src/commands/initiative/initiative-unarchive.ts create mode 100644 src/commands/initiative/initiative-update.ts create mode 100644 src/commands/initiative/initiative-view.ts create mode 100644 src/commands/initiative/initiative.ts create mode 100644 src/commands/label/label-create.ts create mode 100644 src/commands/label/label-delete.ts create mode 100644 src/commands/label/label-list.ts create mode 100644 src/commands/label/label.ts create mode 100644 src/commands/project/project-create.ts create mode 100644 src/commands/team/team-delete.ts create mode 100644 src/utils/bulk.ts diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index 27ae8c5..0301ff8 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -22,12 +22,97 @@ https://github.com/schpet/linear-cli?tab=readme-ov-file#install ## Available Commands ``` -linear issue # Manage issues (list, view, create, start, update, delete, comment) -linear team # Manage teams (list, members, create, autolinks) -linear project # Manage projects (list, view) -linear config # Configure the CLI for the current repo -linear auth # Manage authentication (token, whoami) -linear schema # Print the GraphQL schema (SDL or JSON) +linear issue # Manage issues (list, view, create, start, update, delete, comment) +linear team # Manage teams (list, members, create, delete, autolinks) +linear project # Manage projects (list, view, create) +linear initiative # Manage initiatives (list, view, create, archive, unarchive, update, delete, add-project, remove-project) +linear label # Manage labels (list, create, delete) +linear milestone # Manage project milestones +linear config # Configure the CLI for the current repo +linear auth # Manage authentication (token, whoami) +linear schema # Print the GraphQL schema (SDL or JSON) +``` + +## Initiative Management + +```bash +# List initiatives (default: active only) +linear initiative list +linear initiative list --all-statuses +linear initiative list --status planned + +# View initiative details +linear initiative view + +# Create initiative +linear initiative create --name "Q1 Goals" --status active +linear initiative create -i # Interactive mode + +# Archive/unarchive +linear initiative archive +linear initiative unarchive + +# Link projects to initiatives +linear initiative add-project +linear initiative remove-project +``` + +## Label Management + +```bash +# List labels (shows ID, name, color, team) +linear label list +linear label list --team DEV +linear label list --workspace # Workspace-level only + +# Create label +linear label create --name "Bug" --color "#EB5757" +linear label create --name "Feature" --team DEV + +# Delete label (by ID or name) +linear label delete +linear label delete "Bug" --team DEV +``` + +## Project Management + +```bash +# List projects +linear project list + +# View project +linear project view + +# Create project +linear project create --name "New Feature" --team DEV +linear project create --name "Q1 Work" --team DEV --initiative "Q1 Goals" +linear project create -i # Interactive mode +``` + +## Bulk Operations + +```bash +# Delete multiple issues +linear issue delete --bulk DEV-123 DEV-124 DEV-125 + +# Delete from file (one ID per line) +linear issue delete --bulk-file issues.txt + +# Delete from stdin +echo -e "DEV-123\nDEV-124" | linear issue delete --bulk-stdin + +# Archive multiple initiatives +linear initiative archive --bulk +``` + +## Adding Labels to Issues + +```bash +# Add label to issue +linear issue update DEV-123 --label "Bug" + +# Add multiple labels +linear issue update DEV-123 --label "Bug" --label "High Priority" ``` ## Discovering Options diff --git a/src/commands/initiative/initiative-add-project.ts b/src/commands/initiative/initiative-add-project.ts new file mode 100644 index 0000000..18d7873 --- /dev/null +++ b/src/commands/initiative/initiative-add-project.ts @@ -0,0 +1,240 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" + +const AddProjectToInitiative = gql(` + mutation AddProjectToInitiative($input: InitiativeToProjectCreateInput!) { + initiativeToProjectCreate(input: $input) { + success + initiativeToProject { + id + } + } + } +`) + +async function resolveInitiativeId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise<{ id: string; name: string } | undefined> { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + // Get the name for display + const nameQuery = gql(` + query GetInitiativeNameById($id: String!) { + initiative(id: $id) { + id + name + } + } + `) + try { + const result = await client.request(nameQuery, { id: idOrSlugOrName }) + if (result.initiative) { + return { id: result.initiative.id, name: result.initiative.name } + } + } catch { + // Continue + } + return { id: idOrSlugOrName, name: idOrSlugOrName } + } + + // Try as slug + const slugQuery = gql(` + query GetInitiativeBySlugForAddProject($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }) { + nodes { + id + slugId + name + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + const init = result.initiatives.nodes[0] + return { id: init.id, name: init.name } + } + } catch { + // Continue to name lookup + } + + // Try as name + const nameQuery = gql(` + query GetInitiativeByNameForAddProject($name: String!) { + initiatives(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + const init = result.initiatives.nodes[0] + return { id: init.id, name: init.name } + } + } catch { + // Not found + } + + return undefined +} + +async function resolveProjectId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise<{ id: string; name: string } | undefined> { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + // Get the name for display + const nameQuery = gql(` + query GetProjectNameById($id: String!) { + project(id: $id) { + id + name + } + } + `) + try { + const result = await client.request(nameQuery, { id: idOrSlugOrName }) + if (result.project) { + return { id: result.project.id, name: result.project.name } + } + } catch { + // Continue + } + return { id: idOrSlugOrName, name: idOrSlugOrName } + } + + // Try as slug + const slugQuery = gql(` + query GetProjectBySlugForAddProject($slugId: String!) { + projects(filter: { slugId: { eq: $slugId } }) { + nodes { + id + slugId + name + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.projects?.nodes?.length > 0) { + const proj = result.projects.nodes[0] + return { id: proj.id, name: proj.name } + } + } catch { + // Continue to name lookup + } + + // Try as name + const nameQuery = gql(` + query GetProjectByNameForAddProject($name: String!) { + projects(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.projects?.nodes?.length > 0) { + const proj = result.projects.nodes[0] + return { id: proj.id, name: proj.name } + } + } catch { + // Not found + } + + return undefined +} + +export const addProjectCommand = new Command() + .name("add-project") + .description("Link a project to an initiative") + .arguments(" ") + .option("--sort-order ", "Sort order within initiative") + .option("--no-color", "Disable colored output") + .action( + async ( + { sortOrder, color: colorEnabled }, + initiativeArg, + projectArg, + ) => { + const client = getGraphQLClient() + + // Resolve initiative + const initiative = await resolveInitiativeId(client, initiativeArg) + if (!initiative) { + console.error(`Initiative not found: ${initiativeArg}`) + Deno.exit(1) + } + + // Resolve project + const project = await resolveProjectId(client, projectArg) + if (!project) { + console.error(`Project not found: ${projectArg}`) + Deno.exit(1) + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = colorEnabled && Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + // Build input + const input: Record = { + initiativeId: initiative.id, + projectId: project.id, + } + + if (sortOrder !== undefined) { + input.sortOrder = sortOrder + } + + try { + const result = await client.request(AddProjectToInitiative, { input }) + + spinner?.stop() + + if (!result.initiativeToProjectCreate.success) { + console.error("Failed to add project to initiative") + Deno.exit(1) + } + + console.log(`✓ Added "${project.name}" to initiative "${initiative.name}"`) + } catch (error) { + spinner?.stop() + // Check if the error is because the link already exists + const errorMessage = String(error) + if (errorMessage.includes("already exists") || errorMessage.includes("duplicate")) { + console.log(`Project "${project.name}" is already linked to initiative "${initiative.name}"`) + } else { + console.error("Failed to add project to initiative:", error) + Deno.exit(1) + } + } + }, + ) diff --git a/src/commands/initiative/initiative-archive.ts b/src/commands/initiative/initiative-archive.ts new file mode 100644 index 0000000..8371f8f --- /dev/null +++ b/src/commands/initiative/initiative-archive.ts @@ -0,0 +1,349 @@ +import { Command } from "@cliffy/command" +import { Confirm } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { + type BulkOperationResult, + collectBulkIds, + executeBulkOperations, + isBulkMode, + printBulkSummary, +} from "../../utils/bulk.ts" + +interface InitiativeArchiveResult extends BulkOperationResult { + name: string +} + +export const archiveCommand = new Command() + .name("archive") + .description("Archive a Linear initiative") + .arguments("[initiativeId:string]") + .option("-y, --force", "Skip confirmation prompt") + .option("--no-color", "Disable colored output") + .option( + "--bulk ", + "Archive multiple initiatives by ID, slug, or name", + ) + .option( + "--bulk-file ", + "Read initiative IDs from a file (one per line)", + ) + .option("--bulk-stdin", "Read initiative IDs from stdin") + .action( + async ( + { force, color: colorEnabled, bulk, bulkFile, bulkStdin }, + initiativeId, + ) => { + const client = getGraphQLClient() + + // Check if bulk mode + if (isBulkMode({ bulk, bulkFile, bulkStdin })) { + await handleBulkArchive(client, { + bulk, + bulkFile, + bulkStdin, + force, + colorEnabled, + }) + return + } + + // Single mode requires initiativeId + if (!initiativeId) { + console.error( + "Initiative ID required. Use --bulk for multiple initiatives.", + ) + Deno.exit(1) + } + + await handleSingleArchive(client, initiativeId, { force, colorEnabled }) + }, + ) + +async function handleSingleArchive( + // deno-lint-ignore no-explicit-any + client: any, + initiativeId: string, + options: { force?: boolean; colorEnabled?: boolean }, +): Promise { + const { force, colorEnabled } = options + + // Resolve initiative ID + const resolvedId = await resolveInitiativeId(client, initiativeId) + if (!resolvedId) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + // Get initiative details for confirmation message + const detailsQuery = gql(` + query GetInitiativeForArchive($id: String!) { + initiative(id: $id) { + id + slugId + name + archivedAt + } + } + `) + + let initiativeDetails + try { + initiativeDetails = await client.request(detailsQuery, { id: resolvedId }) + } catch (error) { + console.error("Failed to fetch initiative details:", error) + Deno.exit(1) + } + + if (!initiativeDetails?.initiative) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + const initiative = initiativeDetails.initiative + + // Check if already archived + if (initiative.archivedAt) { + console.log(`Initiative "${initiative.name}" is already archived.`) + return + } + + // Confirm archival + if (!force) { + const confirmed = await Confirm.prompt({ + message: `Archive initiative "${initiative.name}"?`, + default: true, + }) + + if (!confirmed) { + console.log("Archive cancelled.") + return + } + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = colorEnabled && Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + // Archive the initiative + const archiveMutation = gql(` + mutation ArchiveInitiative($id: String!) { + initiativeArchive(id: $id) { + success + } + } + `) + + try { + const result = await client.request(archiveMutation, { id: resolvedId }) + + spinner?.stop() + + if (!result.initiativeArchive.success) { + console.error("Failed to archive initiative") + Deno.exit(1) + } + + console.log(`✓ Archived initiative: ${initiative.name}`) + } catch (error) { + spinner?.stop() + console.error("Failed to archive initiative:", error) + Deno.exit(1) + } +} + +async function handleBulkArchive( + // deno-lint-ignore no-explicit-any + client: any, + options: { + bulk?: string[] + bulkFile?: string + bulkStdin?: boolean + force?: boolean + colorEnabled?: boolean + }, +): Promise { + const { force, colorEnabled = true } = options + + // Collect all IDs + const ids = await collectBulkIds({ + bulk: options.bulk, + bulkFile: options.bulkFile, + bulkStdin: options.bulkStdin, + }) + + if (ids.length === 0) { + console.error("No initiative IDs provided for bulk archive.") + Deno.exit(1) + } + + console.log(`Found ${ids.length} initiative(s) to archive.`) + + // Confirm bulk operation + if (!force) { + const confirmed = await Confirm.prompt({ + message: `Archive ${ids.length} initiative(s)?`, + default: false, + }) + + if (!confirmed) { + console.log("Bulk archive cancelled.") + return + } + } + + // Define the archive operation + const archiveOperation = async ( + idOrSlugOrName: string, + ): Promise => { + // Resolve the ID + const resolvedId = await resolveInitiativeId(client, idOrSlugOrName) + if (!resolvedId) { + return { + id: idOrSlugOrName, + name: idOrSlugOrName, + success: false, + error: "Initiative not found", + } + } + + // Get initiative name for display + const detailsQuery = gql(` + query GetInitiativeNameForBulkArchive($id: String!) { + initiative(id: $id) { + id + name + archivedAt + } + } + `) + + let name = idOrSlugOrName + let alreadyArchived = false + + try { + const details = await client.request(detailsQuery, { id: resolvedId }) + if (details?.initiative) { + name = details.initiative.name + alreadyArchived = Boolean(details.initiative.archivedAt) + } + } catch { + // Continue with default name + } + + // Skip if already archived + if (alreadyArchived) { + return { + id: resolvedId, + name, + success: true, + error: undefined, + } + } + + // Archive the initiative + const archiveMutation = gql(` + mutation BulkArchiveInitiative($id: String!) { + initiativeArchive(id: $id) { + success + } + } + `) + + const result = await client.request(archiveMutation, { id: resolvedId }) + + if (!result.initiativeArchive.success) { + return { + id: resolvedId, + name, + success: false, + error: "Archive operation failed", + } + } + + return { + id: resolvedId, + name, + success: true, + } + } + + // Execute bulk operation + const summary = await executeBulkOperations(ids, archiveOperation, { + showProgress: true, + colorEnabled, + }) + + // Print summary + printBulkSummary(summary, { + entityName: "initiative", + operationName: "archived", + colorEnabled, + showDetails: true, + }) + + // Exit with error code if any failed + if (summary.failed > 0) { + Deno.exit(1) + } +} + +async function resolveInitiativeId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + return idOrSlugOrName + } + + // Try as slug + const slugQuery = gql(` + query GetInitiativeBySlugForArchive($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }) { + nodes { + id + slugId + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Continue to name lookup + } + + // Try as name + const nameQuery = gql(` + query GetInitiativeByNameForArchive($name: String!) { + initiatives(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Not found + } + + return undefined +} diff --git a/src/commands/initiative/initiative-create.ts b/src/commands/initiative/initiative-create.ts new file mode 100644 index 0000000..61e6603 --- /dev/null +++ b/src/commands/initiative/initiative-create.ts @@ -0,0 +1,262 @@ +import { Command } from "@cliffy/command" +import { Input, Select } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { lookupUserId } from "../../utils/linear.ts" + +const CreateInitiative = gql(` + mutation CreateInitiative($input: InitiativeCreateInput!) { + initiativeCreate(input: $input) { + success + initiative { + id + slugId + name + url + } + } + } +`) + +// Initiative statuses (enum values: Planned, Active, Completed) +const INITIATIVE_STATUSES = [ + { name: "Planned", value: "Planned" }, + { name: "Active", value: "Active" }, + { name: "Completed", value: "Completed" }, +] + +// Common initiative colors from Linear's palette +const DEFAULT_COLORS = [ + { name: "Red", value: "#EB5757" }, + { name: "Orange", value: "#F2994A" }, + { name: "Yellow", value: "#F2C94C" }, + { name: "Green", value: "#27AE60" }, + { name: "Teal", value: "#0D9488" }, + { name: "Blue", value: "#2F80ED" }, + { name: "Indigo", value: "#5E6AD2" }, + { name: "Purple", value: "#8B5CF6" }, + { name: "Pink", value: "#BB6BD9" }, + { name: "Gray", value: "#6B6F76" }, +] + +export const createCommand = new Command() + .name("create") + .description("Create a new Linear initiative") + .option("-n, --name ", "Initiative name (required)") + .option("-d, --description ", "Initiative description") + .option( + "-s, --status ", + "Status: planned, active, completed (default: planned)", + ) + .option( + "-o, --owner ", + "Owner (username, email, or @me for yourself)", + ) + .option("--target-date ", "Target completion date (YYYY-MM-DD)") + .option("-c, --color ", "Color hex code (e.g., #5E6AD2)") + .option("--icon ", "Icon name") + .option("-i, --interactive", "Interactive mode (default if no flags provided)") + .option("--no-color", "Disable colored output") + .action(async (options) => { + const { + name: providedName, + description: providedDescription, + status: providedStatus, + owner: providedOwner, + targetDate: providedTargetDate, + color: providedColor, + icon: providedIcon, + interactive: interactiveFlag, + } = options + + // Note: --no-color flag is separate from --color (hex color) flag + // When checking showSpinner, we use Deno.stdout.isTerminal() as the primary check + + const client = getGraphQLClient() + + let name = providedName + let description = providedDescription + let status = providedStatus + let owner = providedOwner + let targetDate = providedTargetDate + let color = providedColor + let icon = providedIcon + + // Determine if we should run in interactive mode + const noFlagsProvided = !name + const isInteractive = + (noFlagsProvided || interactiveFlag) && Deno.stdout.isTerminal() + + if (isInteractive) { + console.log("\nCreate a new initiative\n") + + // Name (required) + if (!name) { + name = await Input.prompt({ + message: "Initiative name:", + minLength: 1, + }) + } + + // Description (optional) + if (!description) { + description = await Input.prompt({ + message: "Description (optional):", + }) + if (!description) description = undefined + } + + // Status selection + if (!status) { + const selectedStatus = await Select.prompt({ + message: "Status:", + options: INITIATIVE_STATUSES, + default: "planned", + }) + status = selectedStatus + } + + // Owner (optional) + if (!owner) { + owner = await Input.prompt({ + message: "Owner (username, email, or @me - press Enter to skip):", + }) + if (!owner) owner = undefined + } + + // Target date (optional) + if (!targetDate) { + targetDate = await Input.prompt({ + message: "Target date (YYYY-MM-DD - press Enter to skip):", + }) + if (!targetDate) targetDate = undefined + } + + // Color selection (optional) + if (!color) { + const colorOptions = [ + { name: "Skip (use default)", value: "__skip__" }, + ...DEFAULT_COLORS.map((c) => ({ + name: `${c.name} (${c.value})`, + value: c.value, + })), + { name: "Custom color", value: "__custom__" }, + ] + + const selectedColor = await Select.prompt({ + message: "Color (optional):", + options: colorOptions, + default: "__skip__", + }) + + if (selectedColor === "__custom__") { + color = await Input.prompt({ + message: "Enter hex color (e.g., #FF5733):", + validate: (value) => { + if (!/^#[0-9A-Fa-f]{6}$/.test(value)) { + return "Please enter a valid hex color (e.g., #FF5733)" + } + return true + }, + }) + } else if (selectedColor !== "__skip__") { + color = selectedColor + } + } + } + + // Validate required fields + if (!name) { + console.error("Initiative name is required. Use --name or -n flag.") + Deno.exit(1) + } + + // Validate status if provided (user can input lowercase, we convert to API format) + if (status) { + const statusLower = status.toLowerCase() + const statusEntry = INITIATIVE_STATUSES.find( + (s) => s.value.toLowerCase() === statusLower + ) + if (!statusEntry) { + console.error( + `Invalid status: ${status}. Valid values: ${INITIATIVE_STATUSES.map((s) => s.value.toLowerCase()).join(", ")}`, + ) + Deno.exit(1) + } + status = statusEntry.value + } + + // Validate color format if provided + if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) { + console.error("Color must be a valid hex code (e.g., #5E6AD2)") + Deno.exit(1) + } + + // Validate target date format if provided + if (targetDate && !/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { + console.error("Target date must be in YYYY-MM-DD format") + Deno.exit(1) + } + + // Build input + const input: Record = { + name, + } + + if (description) { + input.description = description + } + + if (status) { + input.status = status + } + + if (owner) { + const ownerId = await lookupUserId(owner) + if (!ownerId) { + console.error(`Owner not found: ${owner}`) + Deno.exit(1) + } + input.ownerId = ownerId + } + + if (targetDate) { + input.targetDate = targetDate + } + + if (color) { + input.color = color + } + + if (icon) { + input.icon = icon + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + const result = await client.request(CreateInitiative, { input }) + + if (!result.initiativeCreate.success) { + spinner?.stop() + console.error("Failed to create initiative") + Deno.exit(1) + } + + const initiative = result.initiativeCreate.initiative + spinner?.stop() + + console.log(`✓ Created initiative: ${initiative.name}`) + console.log(` Slug: ${initiative.slugId}`) + if (initiative.url) { + console.log(` URL: ${initiative.url}`) + } + } catch (error) { + spinner?.stop() + console.error("Failed to create initiative:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/initiative/initiative-delete.ts b/src/commands/initiative/initiative-delete.ts new file mode 100644 index 0000000..23d959a --- /dev/null +++ b/src/commands/initiative/initiative-delete.ts @@ -0,0 +1,356 @@ +import { Command } from "@cliffy/command" +import { Confirm, Input } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { + type BulkOperationResult, + collectBulkIds, + executeBulkOperations, + isBulkMode, + printBulkSummary, +} from "../../utils/bulk.ts" + +interface InitiativeDeleteResult extends BulkOperationResult { + name: string +} + +export const deleteCommand = new Command() + .name("delete") + .description("Permanently delete a Linear initiative") + .arguments("[initiativeId:string]") + .option("-y, --force", "Skip confirmation prompt") + .option("--no-color", "Disable colored output") + .option( + "--bulk ", + "Delete multiple initiatives by ID, slug, or name", + ) + .option( + "--bulk-file ", + "Read initiative IDs from a file (one per line)", + ) + .option("--bulk-stdin", "Read initiative IDs from stdin") + .action( + async ( + { force, color: colorEnabled, bulk, bulkFile, bulkStdin }, + initiativeId, + ) => { + const client = getGraphQLClient() + + // Check if bulk mode + if (isBulkMode({ bulk, bulkFile, bulkStdin })) { + await handleBulkDelete(client, { + bulk, + bulkFile, + bulkStdin, + force, + colorEnabled, + }) + return + } + + // Single mode requires initiativeId + if (!initiativeId) { + console.error( + "Initiative ID required. Use --bulk for multiple initiatives.", + ) + Deno.exit(1) + } + + await handleSingleDelete(client, initiativeId, { force, colorEnabled }) + }, + ) + +async function handleSingleDelete( + // deno-lint-ignore no-explicit-any + client: any, + initiativeId: string, + options: { force?: boolean; colorEnabled?: boolean }, +): Promise { + const { force, colorEnabled } = options + + // Resolve initiative ID + const resolvedId = await resolveInitiativeId(client, initiativeId) + if (!resolvedId) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + // Get initiative details for confirmation message + const detailsQuery = gql(` + query GetInitiativeForDelete($id: String!) { + initiative(id: $id) { + id + slugId + name + projects { + nodes { + id + } + } + } + } + `) + + let initiativeDetails + try { + initiativeDetails = await client.request(detailsQuery, { id: resolvedId }) + } catch (error) { + console.error("Failed to fetch initiative details:", error) + Deno.exit(1) + } + + if (!initiativeDetails?.initiative) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + const initiative = initiativeDetails.initiative + const projectCount = initiative.projects?.nodes?.length || 0 + + // Warn about linked projects + if (projectCount > 0) { + console.log( + `\n⚠️ Initiative "${initiative.name}" has ${projectCount} linked project(s).`, + ) + console.log("Deleting the initiative will unlink these projects.\n") + } + + // Confirm deletion with typed confirmation for safety + if (!force) { + console.log(`\n⚠️ This action is PERMANENT and cannot be undone.\n`) + + const confirmed = await Confirm.prompt({ + message: `Are you sure you want to permanently delete "${initiative.name}"?`, + default: false, + }) + + if (!confirmed) { + console.log("Delete cancelled.") + return + } + + // Require typing the initiative name for extra safety + const typedName = await Input.prompt({ + message: `Type the initiative name to confirm deletion:`, + }) + + if (typedName !== initiative.name) { + console.log("Name does not match. Delete cancelled.") + return + } + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = colorEnabled && Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + // Delete the initiative + const deleteMutation = gql(` + mutation DeleteInitiative($id: String!) { + initiativeDelete(id: $id) { + success + } + } + `) + + try { + const result = await client.request(deleteMutation, { id: resolvedId }) + + spinner?.stop() + + if (!result.initiativeDelete.success) { + console.error("Failed to delete initiative") + Deno.exit(1) + } + + console.log(`✓ Permanently deleted initiative: ${initiative.name}`) + } catch (error) { + spinner?.stop() + console.error("Failed to delete initiative:", error) + Deno.exit(1) + } +} + +async function handleBulkDelete( + // deno-lint-ignore no-explicit-any + client: any, + options: { + bulk?: string[] + bulkFile?: string + bulkStdin?: boolean + force?: boolean + colorEnabled?: boolean + }, +): Promise { + const { force, colorEnabled = true } = options + + // Collect all IDs + const ids = await collectBulkIds({ + bulk: options.bulk, + bulkFile: options.bulkFile, + bulkStdin: options.bulkStdin, + }) + + if (ids.length === 0) { + console.error("No initiative IDs provided for bulk delete.") + Deno.exit(1) + } + + console.log(`Found ${ids.length} initiative(s) to delete.`) + console.log(`\n⚠️ This action is PERMANENT and cannot be undone.\n`) + + // Confirm bulk operation + if (!force) { + const confirmed = await Confirm.prompt({ + message: `Permanently delete ${ids.length} initiative(s)?`, + default: false, + }) + + if (!confirmed) { + console.log("Bulk delete cancelled.") + return + } + } + + // Define the delete operation + const deleteOperation = async ( + idOrSlugOrName: string, + ): Promise => { + // Resolve the ID + const resolvedId = await resolveInitiativeId(client, idOrSlugOrName) + if (!resolvedId) { + return { + id: idOrSlugOrName, + name: idOrSlugOrName, + success: false, + error: "Initiative not found", + } + } + + // Get initiative name for display + const detailsQuery = gql(` + query GetInitiativeNameForBulkDelete($id: String!) { + initiative(id: $id) { + id + name + } + } + `) + + let name = idOrSlugOrName + + try { + const details = await client.request(detailsQuery, { id: resolvedId }) + if (details?.initiative) { + name = details.initiative.name + } + } catch { + // Continue with default name + } + + // Delete the initiative + const deleteMutation = gql(` + mutation BulkDeleteInitiative($id: String!) { + initiativeDelete(id: $id) { + success + } + } + `) + + const result = await client.request(deleteMutation, { id: resolvedId }) + + if (!result.initiativeDelete.success) { + return { + id: resolvedId, + name, + success: false, + error: "Delete operation failed", + } + } + + return { + id: resolvedId, + name, + success: true, + } + } + + // Execute bulk operation + const summary = await executeBulkOperations(ids, deleteOperation, { + showProgress: true, + colorEnabled, + }) + + // Print summary + printBulkSummary(summary, { + entityName: "initiative", + operationName: "deleted", + colorEnabled, + showDetails: true, + }) + + // Exit with error code if any failed + if (summary.failed > 0) { + Deno.exit(1) + } +} + +async function resolveInitiativeId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + return idOrSlugOrName + } + + // Try as slug (including archived - user might want to delete archived initiative) + const slugQuery = gql(` + query GetInitiativeBySlugForDelete($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }, includeArchived: true) { + nodes { + id + slugId + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Continue to name lookup + } + + // Try as name (including archived) + const nameQuery = gql(` + query GetInitiativeByNameForDelete($name: String!) { + initiatives(filter: { name: { eqIgnoreCase: $name } }, includeArchived: true) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Not found + } + + return undefined +} diff --git a/src/commands/initiative/initiative-list.ts b/src/commands/initiative/initiative-list.ts new file mode 100644 index 0000000..8268295 --- /dev/null +++ b/src/commands/initiative/initiative-list.ts @@ -0,0 +1,306 @@ +import { Command } from "@cliffy/command" +import { unicodeWidth } from "@std/cli" +import { open } from "@opensrc/deno-open" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { padDisplay, truncateText } from "../../utils/display.ts" +import { getOption } from "../../config.ts" + +const GetInitiatives = gql(` + query GetInitiatives($filter: InitiativeFilter, $includeArchived: Boolean) { + initiatives(filter: $filter, includeArchived: $includeArchived) { + nodes { + id + slugId + name + description + status + targetDate + health + color + icon + url + archivedAt + owner { + id + displayName + initials + } + projects { + nodes { + id + name + status { + name + } + } + } + } + } + } +`) + +// Initiative status display names and order +// Note: InitiativeStatus enum values are: Planned, Active, Completed +const INITIATIVE_STATUS_ORDER: Record = { + "Active": 1, + "Planned": 2, + "Completed": 3, +} + +const INITIATIVE_STATUS_DISPLAY: Record = { + "Active": "Active", + "Planned": "Planned", + "Completed": "Completed", +} + +// Map user input (lowercase) to API values (capitalized) +const STATUS_INPUT_MAP: Record = { + "active": "Active", + "planned": "Planned", + "completed": "Completed", +} + +export const listCommand = new Command() + .name("list") + .description("List initiatives") + .option( + "-s, --status ", + "Filter by status (active, planned, completed)", + ) + .option("--all-statuses", "Show all statuses (default: active only)") + .option("-o, --owner ", "Filter by owner (username or email)") + .option("-w, --web", "Open initiatives page in web browser") + .option("-a, --app", "Open initiatives page in Linear.app") + .option("-j, --json", "Output as JSON") + .option("--archived", "Include archived initiatives") + .action(async ({ status, allStatuses, owner, web, app, json, archived }) => { + // Handle open in browser/app + if (web || app) { + let workspace = getOption("workspace") + if (!workspace) { + // Get workspace from viewer if not configured + const client = getGraphQLClient() + const viewerQuery = gql(` + query GetViewerForInitiatives { + viewer { + organization { + urlKey + } + } + } + `) + const result = await client.request(viewerQuery) + workspace = result.viewer.organization.urlKey + } + + const url = `https://linear.app/${workspace}/initiatives` + const destination = app ? "Linear.app" : "web browser" + console.log(`Opening ${url} in ${destination}`) + await open(url, app ? { app: { name: "Linear" } } : undefined) + return + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = Deno.stdout.isTerminal() && !json + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + // Build filter + // deno-lint-ignore no-explicit-any + let filter: any = {} + + // Status filter + if (status) { + const statusLower = status.toLowerCase() + const apiStatus = STATUS_INPUT_MAP[statusLower] + if (!apiStatus) { + spinner?.stop() + console.error( + `Invalid status: ${status}. Valid values: ${Object.keys(STATUS_INPUT_MAP).join(", ")}`, + ) + Deno.exit(1) + } + filter.status = { eq: apiStatus } + } else if (!allStatuses) { + // Default to active only + filter.status = { eq: "Active" } + } + + // Owner filter + if (owner) { + const { lookupUserId } = await import("../../utils/linear.ts") + const ownerId = await lookupUserId(owner) + if (!ownerId) { + spinner?.stop() + console.error(`Owner not found: ${owner}`) + Deno.exit(1) + } + filter.owner = { id: { eq: ownerId } } + } + + const client = getGraphQLClient() + const result = await client.request(GetInitiatives, { + filter: Object.keys(filter).length > 0 ? filter : undefined, + includeArchived: archived || false, + }) + spinner?.stop() + + let initiatives = result.initiatives?.nodes || [] + + if (initiatives.length === 0) { + if (json) { + console.log("[]") + } else { + console.log("No initiatives found.") + } + return + } + + // Sort initiatives by status then by name + initiatives = initiatives.sort((a, b) => { + const statusA = INITIATIVE_STATUS_ORDER[a.status] || 999 + const statusB = INITIATIVE_STATUS_ORDER[b.status] || 999 + + if (statusA !== statusB) { + return statusA - statusB + } + + return a.name.localeCompare(b.name) + }) + + // JSON output + if (json) { + const jsonOutput = initiatives.map((init) => ({ + id: init.id, + slugId: init.slugId, + name: init.name, + description: init.description, + status: init.status, + health: init.health, + targetDate: init.targetDate, + owner: init.owner + ? { + id: init.owner.id, + displayName: init.owner.displayName, + } + : null, + projectCount: init.projects?.nodes?.length || 0, + url: init.url, + archivedAt: init.archivedAt, + })) + console.log(JSON.stringify(jsonOutput, null, 2)) + return + } + + // Table output + const { columns } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 120 } + + // Calculate column widths + const SLUG_WIDTH = Math.max( + 4, + ...initiatives.map((init) => init.slugId.length), + ) + const STATUS_WIDTH = Math.max( + 6, + ...initiatives.map( + (init) => + (INITIATIVE_STATUS_DISPLAY[init.status] || init.status).length, + ), + ) + const HEALTH_WIDTH = Math.max( + 6, + ...initiatives.map((init) => (init.health || "-").length), + ) + const OWNER_WIDTH = Math.max( + 5, + ...initiatives.map((init) => (init.owner?.initials || "-").length), + ) + const PROJECTS_WIDTH = Math.max( + 4, + ...initiatives.map((init) => + String(init.projects?.nodes?.length || 0).length + ), + ) + const TARGET_WIDTH = Math.max( + 10, + ...initiatives.map((init) => (init.targetDate || "-").length), + ) + + const SPACE_WIDTH = 6 // Space between columns + const fixed = + SLUG_WIDTH + + STATUS_WIDTH + + HEALTH_WIDTH + + OWNER_WIDTH + + PROJECTS_WIDTH + + TARGET_WIDTH + + SPACE_WIDTH + const PADDING = 1 + const maxNameWidth = Math.max( + ...initiatives.map((init) => unicodeWidth(init.name)), + ) + const availableWidth = Math.max(columns - PADDING - fixed, 10) + const nameWidth = Math.min(maxNameWidth, availableWidth) + + // Print header + const headerCells = [ + padDisplay("SLUG", SLUG_WIDTH), + padDisplay("NAME", nameWidth), + padDisplay("STATUS", STATUS_WIDTH), + padDisplay("HEALTH", HEALTH_WIDTH), + padDisplay("OWNER", OWNER_WIDTH), + padDisplay("PROJ", PROJECTS_WIDTH), + padDisplay("TARGET", TARGET_WIDTH), + ] + + let headerMsg = "" + const headerStyles: string[] = [] + headerCells.forEach((cell, index) => { + headerMsg += `%c${cell}` + headerStyles.push("text-decoration: underline") + if (index < headerCells.length - 1) { + headerMsg += "%c %c" + headerStyles.push("text-decoration: none") + headerStyles.push("text-decoration: underline") + } + }) + console.log(headerMsg, ...headerStyles) + + // Print each initiative + for (const init of initiatives) { + const statusDisplay = + INITIATIVE_STATUS_DISPLAY[init.status] || init.status + const health = init.health || "-" + const owner = init.owner?.initials || "-" + const projectCount = String(init.projects?.nodes?.length || 0) + const target = init.targetDate || "-" + + const truncName = truncateText(init.name, nameWidth) + const paddedName = padDisplay(truncName, nameWidth) + + // Get status color + const statusColors: Record = { + Active: "#27AE60", + Planned: "#5E6AD2", + Completed: "#6B6F76", + } + const statusColor = statusColors[init.status] || "#6B6F76" + + console.log( + `${padDisplay(init.slugId, SLUG_WIDTH)} ${paddedName} %c${padDisplay(statusDisplay, STATUS_WIDTH)}%c ${padDisplay(health, HEALTH_WIDTH)} ${padDisplay(owner, OWNER_WIDTH)} ${padDisplay(projectCount, PROJECTS_WIDTH)} %c${padDisplay(target, TARGET_WIDTH)}%c`, + `color: ${statusColor}`, + "", + "color: gray", + "", + ) + } + } catch (error) { + spinner?.stop() + console.error("Failed to fetch initiatives:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/initiative/initiative-remove-project.ts b/src/commands/initiative/initiative-remove-project.ts new file mode 100644 index 0000000..0bb9bb7 --- /dev/null +++ b/src/commands/initiative/initiative-remove-project.ts @@ -0,0 +1,280 @@ +import { Command } from "@cliffy/command" +import { Confirm } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" + +const GetInitiativeToProjects = gql(` + query GetInitiativeToProjects($first: Int) { + initiativeToProjects(first: $first) { + nodes { + id + initiative { + id + } + project { + id + } + } + } + } +`) + +const RemoveProjectFromInitiative = gql(` + mutation RemoveProjectFromInitiative($id: String!) { + initiativeToProjectDelete(id: $id) { + success + } + } +`) + +async function resolveInitiativeId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise<{ id: string; name: string } | undefined> { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + // Get the name for display + const nameQuery = gql(` + query GetInitiativeNameByIdForRemove($id: String!) { + initiative(id: $id) { + id + name + } + } + `) + try { + const result = await client.request(nameQuery, { id: idOrSlugOrName }) + if (result.initiative) { + return { id: result.initiative.id, name: result.initiative.name } + } + } catch { + // Continue + } + return { id: idOrSlugOrName, name: idOrSlugOrName } + } + + // Try as slug + const slugQuery = gql(` + query GetInitiativeBySlugForRemoveProject($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }) { + nodes { + id + slugId + name + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + const init = result.initiatives.nodes[0] + return { id: init.id, name: init.name } + } + } catch { + // Continue to name lookup + } + + // Try as name + const nameQuery = gql(` + query GetInitiativeByNameForRemoveProject($name: String!) { + initiatives(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + const init = result.initiatives.nodes[0] + return { id: init.id, name: init.name } + } + } catch { + // Not found + } + + return undefined +} + +async function resolveProjectId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise<{ id: string; name: string } | undefined> { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + // Get the name for display + const nameQuery = gql(` + query GetProjectNameByIdForRemove($id: String!) { + project(id: $id) { + id + name + } + } + `) + try { + const result = await client.request(nameQuery, { id: idOrSlugOrName }) + if (result.project) { + return { id: result.project.id, name: result.project.name } + } + } catch { + // Continue + } + return { id: idOrSlugOrName, name: idOrSlugOrName } + } + + // Try as slug + const slugQuery = gql(` + query GetProjectBySlugForRemoveProject($slugId: String!) { + projects(filter: { slugId: { eq: $slugId } }) { + nodes { + id + slugId + name + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.projects?.nodes?.length > 0) { + const proj = result.projects.nodes[0] + return { id: proj.id, name: proj.name } + } + } catch { + // Continue to name lookup + } + + // Try as name + const nameQuery = gql(` + query GetProjectByNameForRemoveProject($name: String!) { + projects(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.projects?.nodes?.length > 0) { + const proj = result.projects.nodes[0] + return { id: proj.id, name: proj.name } + } + } catch { + // Not found + } + + return undefined +} + +export const removeProjectCommand = new Command() + .name("remove-project") + .description("Unlink a project from an initiative") + .arguments(" ") + .option("-y, --force", "Skip confirmation prompt") + .option("--no-color", "Disable colored output") + .action( + async ( + { force, color: colorEnabled }, + initiativeArg, + projectArg, + ) => { + const client = getGraphQLClient() + + // Resolve initiative + const initiative = await resolveInitiativeId(client, initiativeArg) + if (!initiative) { + console.error(`Initiative not found: ${initiativeArg}`) + Deno.exit(1) + } + + // Resolve project + const project = await resolveProjectId(client, projectArg) + if (!project) { + console.error(`Project not found: ${projectArg}`) + Deno.exit(1) + } + + // Find the initiative-to-project link + let linkId: string | undefined + + try { + const linkResult = await client.request(GetInitiativeToProjects, { + first: 250, + }) + + // Filter client-side for the matching link + const link = linkResult.initiativeToProjects?.nodes?.find( + (node) => + node.initiative?.id === initiative.id && + node.project?.id === project.id + ) + if (link) { + linkId = link.id + } + } catch (error) { + console.error("Failed to find project link:", error) + Deno.exit(1) + } + + if (!linkId) { + console.log(`Project "${project.name}" is not linked to initiative "${initiative.name}"`) + return + } + + // Confirm removal + if (!force) { + const confirmed = await Confirm.prompt({ + message: `Remove "${project.name}" from initiative "${initiative.name}"?`, + default: true, + }) + + if (!confirmed) { + console.log("Removal cancelled.") + return + } + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = colorEnabled && Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + const result = await client.request(RemoveProjectFromInitiative, { + id: linkId, + }) + + spinner?.stop() + + if (!result.initiativeToProjectDelete.success) { + console.error("Failed to remove project from initiative") + Deno.exit(1) + } + + console.log(`✓ Removed "${project.name}" from initiative "${initiative.name}"`) + } catch (error) { + spinner?.stop() + console.error("Failed to remove project from initiative:", error) + Deno.exit(1) + } + }, + ) diff --git a/src/commands/initiative/initiative-unarchive.ts b/src/commands/initiative/initiative-unarchive.ts new file mode 100644 index 0000000..765939b --- /dev/null +++ b/src/commands/initiative/initiative-unarchive.ts @@ -0,0 +1,169 @@ +import { Command } from "@cliffy/command" +import { Confirm } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" + +export const unarchiveCommand = new Command() + .name("unarchive") + .description("Unarchive a Linear initiative") + .arguments("") + .option("-y, --force", "Skip confirmation prompt") + .option("--no-color", "Disable colored output") + .action(async ({ force, color: colorEnabled }, initiativeId) => { + const client = getGraphQLClient() + + // Resolve initiative ID + const resolvedId = await resolveInitiativeId(client, initiativeId) + if (!resolvedId) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + // Get initiative details for confirmation message (must include archived) + const detailsQuery = gql(` + query GetInitiativeForUnarchive($id: ID!) { + initiatives(filter: { id: { eq: $id } }, includeArchived: true) { + nodes { + id + slugId + name + archivedAt + } + } + } + `) + + let initiativeDetails + try { + initiativeDetails = await client.request(detailsQuery, { id: resolvedId }) + } catch (error) { + console.error("Failed to fetch initiative details:", error) + Deno.exit(1) + } + + if (!initiativeDetails?.initiatives?.nodes?.length) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + const initiative = initiativeDetails.initiatives.nodes[0] + + // Check if already unarchived + if (!initiative.archivedAt) { + console.log(`Initiative "${initiative.name}" is not archived.`) + return + } + + // Confirm unarchive + if (!force) { + const confirmed = await Confirm.prompt({ + message: `Are you sure you want to unarchive "${initiative.name}"?`, + default: true, + }) + + if (!confirmed) { + console.log("Unarchive cancelled.") + return + } + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = colorEnabled && Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + // Unarchive the initiative + const unarchiveMutation = gql(` + mutation UnarchiveInitiative($id: String!) { + initiativeUnarchive(id: $id) { + success + entity { + id + slugId + name + url + } + } + } + `) + + try { + const result = await client.request(unarchiveMutation, { id: resolvedId }) + + spinner?.stop() + + if (!result.initiativeUnarchive.success) { + console.error("Failed to unarchive initiative") + Deno.exit(1) + } + + const unarchived = result.initiativeUnarchive.entity + console.log(`✓ Unarchived initiative: ${unarchived?.name}`) + if (unarchived?.url) { + console.log(unarchived.url) + } + } catch (error) { + spinner?.stop() + console.error("Failed to unarchive initiative:", error) + Deno.exit(1) + } + }) + +async function resolveInitiativeId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + return idOrSlugOrName + } + + // Try as slug (including archived) + const slugQuery = gql(` + query GetInitiativeBySlugIncludeArchived($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }, includeArchived: true) { + nodes { + id + slugId + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Continue to name lookup + } + + // Try as name (including archived) + const nameQuery = gql(` + query GetInitiativeByNameIncludeArchived($name: String!) { + initiatives(filter: { name: { eqIgnoreCase: $name } }, includeArchived: true) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Not found + } + + return undefined +} diff --git a/src/commands/initiative/initiative-update.ts b/src/commands/initiative/initiative-update.ts new file mode 100644 index 0000000..5cc92c0 --- /dev/null +++ b/src/commands/initiative/initiative-update.ts @@ -0,0 +1,279 @@ +import { Command } from "@cliffy/command" +import { Input, Select } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { lookupUserId } from "../../utils/linear.ts" + +// Initiative status options from Linear API +const INITIATIVE_STATUSES = [ + { name: "Planned", value: "planned" }, + { name: "Active", value: "active" }, + { name: "Completed", value: "completed" }, + { name: "Paused", value: "paused" }, +] + +export const updateCommand = new Command() + .name("update") + .description("Update a Linear initiative") + .arguments("") + .option("-n, --name ", "New name for the initiative") + .option("-d, --description ", "New description") + .option("--status ", "New status (planned, active, completed, paused)") + .option("--owner ", "New owner (username, email, or @me)") + .option("--target-date ", "Target completion date (YYYY-MM-DD)") + .option("--color ", "Initiative color (hex, e.g., #5E6AD2)") + .option("--icon ", "Initiative icon name") + .option("-i, --interactive", "Interactive mode for updates") + .option("--no-color", "Disable colored output") + .action( + async ( + { + name, + description, + status, + owner, + targetDate, + color: colorHex, + icon, + interactive, + color: colorEnabled, + }, + initiativeId, + ) => { + const client = getGraphQLClient() + + // Resolve initiative ID + const resolvedId = await resolveInitiativeId(client, initiativeId) + if (!resolvedId) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + // Get current initiative details + const detailsQuery = gql(` + query GetInitiativeForUpdate($id: String!) { + initiative(id: $id) { + id + slugId + name + description + status + targetDate + color + icon + owner { + id + displayName + } + } + } + `) + + let initiativeDetails + try { + initiativeDetails = await client.request(detailsQuery, { id: resolvedId }) + } catch (error) { + console.error("Failed to fetch initiative details:", error) + Deno.exit(1) + } + + if (!initiativeDetails?.initiative) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + const initiative = initiativeDetails.initiative + + // Interactive mode + interactive = interactive && Deno.stdout.isTerminal() + const noFlagsProvided = + !name && + !description && + !status && + !owner && + !targetDate && + !colorHex && + !icon + + if (noFlagsProvided && interactive) { + console.log(`\nUpdating initiative: ${initiative.name}\n`) + + // Prompt for name + const newName = await Input.prompt({ + message: "Name:", + default: initiative.name, + }) + if (newName !== initiative.name) { + name = newName + } + + // Prompt for description + const newDescription = await Input.prompt({ + message: "Description:", + default: initiative.description || "", + }) + if (newDescription !== (initiative.description || "")) { + description = newDescription || undefined + } + + // Prompt for status + const currentStatusIndex = INITIATIVE_STATUSES.findIndex( + (s) => s.value.toLowerCase() === initiative.status?.toLowerCase(), + ) + const newStatus = await Select.prompt({ + message: "Status:", + options: INITIATIVE_STATUSES, + default: currentStatusIndex >= 0 ? INITIATIVE_STATUSES[currentStatusIndex].value : undefined, + }) + if (newStatus !== initiative.status?.toLowerCase()) { + status = newStatus + } + + // Prompt for target date + const newTargetDate = await Input.prompt({ + message: "Target date (YYYY-MM-DD):", + default: initiative.targetDate || "", + }) + if (newTargetDate !== (initiative.targetDate || "")) { + targetDate = newTargetDate || undefined + } + + // Prompt for color + const newColor = await Input.prompt({ + message: "Color (hex, e.g., #5E6AD2):", + default: initiative.color || "", + }) + if (newColor !== (initiative.color || "")) { + colorHex = newColor || undefined + } + } + + // Build update input + const input: Record = {} + + if (name !== undefined) input.name = name + if (description !== undefined) input.description = description + if (status !== undefined) input.status = status.toLowerCase() + if (targetDate !== undefined) input.targetDate = targetDate + if (colorHex !== undefined) input.color = colorHex + if (icon !== undefined) input.icon = icon + + if (owner !== undefined) { + const ownerId = await lookupUserId(owner) + if (!ownerId) { + console.error(`Owner not found: ${owner}`) + Deno.exit(1) + } + input.ownerId = ownerId + } + + // Check if any updates to make + if (Object.keys(input).length === 0) { + console.log("No changes specified") + return + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = colorEnabled && Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + // Update the initiative + const updateMutation = gql(` + mutation UpdateInitiative($id: String!, $input: InitiativeUpdateInput!) { + initiativeUpdate(id: $id, input: $input) { + success + initiative { + id + slugId + name + url + } + } + } + `) + + try { + const result = await client.request(updateMutation, { + id: resolvedId, + input, + }) + + spinner?.stop() + + if (!result.initiativeUpdate.success) { + console.error("Failed to update initiative") + Deno.exit(1) + } + + const updated = result.initiativeUpdate.initiative + console.log(`✓ Updated initiative: ${updated.name}`) + if (updated.url) { + console.log(updated.url) + } + } catch (error) { + spinner?.stop() + console.error("Failed to update initiative:", error) + Deno.exit(1) + } + }, + ) + +async function resolveInitiativeId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + return idOrSlugOrName + } + + // Try as slug + const slugQuery = gql(` + query GetInitiativeBySlug($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }) { + nodes { + id + slugId + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Continue to name lookup + } + + // Try as name + const nameQuery = gql(` + query GetInitiativeByName($name: String!) { + initiatives(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Not found + } + + return undefined +} diff --git a/src/commands/initiative/initiative-view.ts b/src/commands/initiative/initiative-view.ts new file mode 100644 index 0000000..e8a854a --- /dev/null +++ b/src/commands/initiative/initiative-view.ts @@ -0,0 +1,331 @@ +import { Command } from "@cliffy/command" +import { renderMarkdown } from "@littletof/charmd" +import { open } from "@opensrc/deno-open" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { formatRelativeTime } from "../../utils/display.ts" + +const GetInitiativeDetails = gql(` + query GetInitiativeDetails($id: String!) { + initiative(id: $id) { + id + slugId + name + description + status + targetDate + health + color + icon + url + archivedAt + createdAt + updatedAt + owner { + id + name + displayName + } + projects { + nodes { + id + slugId + name + status { + name + type + } + } + } + } + } +`) + +// Initiative status display names +const INITIATIVE_STATUS_DISPLAY: Record = { + "active": "Active", + "planned": "Planned", + "paused": "Paused", + "completed": "Completed", + "canceled": "Canceled", +} + +// Status colors for terminal display +const STATUS_COLORS: Record = { + active: "#27AE60", + planned: "#5E6AD2", + paused: "#F2994A", + completed: "#6B6F76", + canceled: "#EB5757", +} + +export const viewCommand = new Command() + .name("view") + .description("View initiative details") + .alias("v") + .arguments("") + .option("-w, --web", "Open in web browser") + .option("-a, --app", "Open in Linear.app") + .option("-j, --json", "Output as JSON") + .action(async (options, initiativeId) => { + const { web, app, json } = options + + const client = getGraphQLClient() + + // Resolve initiative ID (can be UUID, slug, or name) + const resolvedId = await resolveInitiativeId(client, initiativeId) + if (!resolvedId) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + // Handle open in browser/app + if (web || app) { + // Get initiative URL + const result = await client.request(GetInitiativeDetails, { + id: resolvedId, + }) + const initiative = result.initiative + if (!initiative?.url) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + const destination = app ? "Linear.app" : "web browser" + console.log(`Opening ${initiative.url} in ${destination}`) + await open(initiative.url, app ? { app: { name: "Linear" } } : undefined) + return + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = Deno.stdout.isTerminal() && !json + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + const result = await client.request(GetInitiativeDetails, { + id: resolvedId, + }) + spinner?.stop() + + const initiative = result.initiative + if (!initiative) { + console.error(`Initiative with ID "${initiativeId}" not found.`) + Deno.exit(1) + } + + // JSON output + if (json) { + const jsonOutput = { + id: initiative.id, + slugId: initiative.slugId, + name: initiative.name, + description: initiative.description, + status: initiative.status, + health: initiative.health, + targetDate: initiative.targetDate, + color: initiative.color, + icon: initiative.icon, + url: initiative.url, + archivedAt: initiative.archivedAt, + createdAt: initiative.createdAt, + updatedAt: initiative.updatedAt, + owner: initiative.owner + ? { + id: initiative.owner.id, + name: initiative.owner.name, + displayName: initiative.owner.displayName, + } + : null, + projects: (initiative.projects?.nodes || []).map((p) => ({ + id: p.id, + slugId: p.slugId, + name: p.name, + status: p.status?.name, + })), + } + console.log(JSON.stringify(jsonOutput, null, 2)) + return + } + + // Build the display + const lines: string[] = [] + + // Title with icon + const icon = initiative.icon ? `${initiative.icon} ` : "" + lines.push(`# ${icon}${initiative.name}`) + lines.push("") + + // Basic info + lines.push(`**Slug:** ${initiative.slugId}`) + lines.push(`**URL:** ${initiative.url}`) + + // Status with color styling + const statusDisplay = + INITIATIVE_STATUS_DISPLAY[initiative.status] || initiative.status + const statusLine = `**Status:** ${statusDisplay}` + if (Deno.stdout.isTerminal()) { + const statusColor = STATUS_COLORS[initiative.status] || "#6B6F76" + console.log(`%c${statusLine}%c`, `color: ${statusColor}`, "") + } else { + lines.push(statusLine) + } + + // Health + if (initiative.health) { + lines.push(`**Health:** ${initiative.health}`) + } + + // Owner + if (initiative.owner) { + lines.push( + `**Owner:** ${initiative.owner.displayName || initiative.owner.name}`, + ) + } + + // Target date + if (initiative.targetDate) { + lines.push(`**Target Date:** ${initiative.targetDate}`) + } + + // Archived status + if (initiative.archivedAt) { + lines.push( + `**Archived:** ${formatRelativeTime(initiative.archivedAt)}`, + ) + } + + lines.push("") + lines.push(`**Created:** ${formatRelativeTime(initiative.createdAt)}`) + lines.push(`**Updated:** ${formatRelativeTime(initiative.updatedAt)}`) + + // Description + if (initiative.description) { + lines.push("") + lines.push("## Description") + lines.push("") + lines.push(initiative.description) + } + + // Projects + const projects = initiative.projects?.nodes || [] + if (projects.length > 0) { + lines.push("") + lines.push(`## Projects (${projects.length})`) + lines.push("") + + // Group projects by status + const projectsByStatus: Record = {} + for (const project of projects) { + const statusType = project.status?.type || "unknown" + if (!projectsByStatus[statusType]) { + projectsByStatus[statusType] = [] + } + projectsByStatus[statusType].push(project) + } + + // Sort by status type priority + const statusOrder = ["started", "planned", "backlog", "paused", "completed", "canceled"] + + for (const statusType of statusOrder) { + const statusProjects = projectsByStatus[statusType] + if (statusProjects && statusProjects.length > 0) { + for (const project of statusProjects) { + const statusName = project.status?.name || "Unknown" + lines.push(`- **${project.name}** (${statusName})`) + } + } + } + + // Any remaining statuses not in our order + for (const [statusType, statusProjects] of Object.entries(projectsByStatus)) { + if (!statusOrder.includes(statusType)) { + for (const project of statusProjects) { + const statusName = project.status?.name || "Unknown" + lines.push(`- **${project.name}** (${statusName})`) + } + } + } + } else { + lines.push("") + lines.push("## Projects") + lines.push("") + lines.push("*No projects linked to this initiative.*") + } + + const markdown = lines.join("\n") + + if (Deno.stdout.isTerminal()) { + const terminalWidth = Deno.consoleSize().columns + console.log(renderMarkdown(markdown, { lineWidth: terminalWidth })) + } else { + console.log(markdown) + } + } catch (error) { + spinner?.stop() + console.error("Failed to fetch initiative details:", error) + Deno.exit(1) + } + }) + +/** + * Resolve initiative ID from UUID, slug, or name + */ +async function resolveInitiativeId( + // deno-lint-ignore no-explicit-any + client: any, + idOrSlugOrName: string, +): Promise { + // Try as UUID first + if ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + idOrSlugOrName, + ) + ) { + return idOrSlugOrName + } + + // Try as slug + const slugQuery = gql(` + query GetInitiativeBySlugForView($slugId: String!) { + initiatives(filter: { slugId: { eq: $slugId } }) { + nodes { + id + slugId + } + } + } + `) + + try { + const result = await client.request(slugQuery, { slugId: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Continue to name lookup + } + + // Try as name (case-insensitive) + const nameQuery = gql(` + query GetInitiativeByNameForView($name: String!) { + initiatives(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(nameQuery, { name: idOrSlugOrName }) + if (result.initiatives?.nodes?.length > 0) { + return result.initiatives.nodes[0].id + } + } catch { + // Not found + } + + return undefined +} diff --git a/src/commands/initiative/initiative.ts b/src/commands/initiative/initiative.ts new file mode 100644 index 0000000..d5f88e2 --- /dev/null +++ b/src/commands/initiative/initiative.ts @@ -0,0 +1,27 @@ +import { Command } from "@cliffy/command" + +import { listCommand } from "./initiative-list.ts" +import { viewCommand } from "./initiative-view.ts" +import { createCommand } from "./initiative-create.ts" +import { archiveCommand } from "./initiative-archive.ts" +import { updateCommand } from "./initiative-update.ts" +import { unarchiveCommand } from "./initiative-unarchive.ts" +import { deleteCommand } from "./initiative-delete.ts" +import { addProjectCommand } from "./initiative-add-project.ts" +import { removeProjectCommand } from "./initiative-remove-project.ts" + +export const initiativeCommand = new Command() + .description("Manage Linear initiatives") + .action(function () { + this.showHelp() + }) + .command("list", listCommand) + .alias("ls") + .command("view", viewCommand) + .command("create", createCommand) + .command("archive", archiveCommand) + .command("update", updateCommand) + .command("unarchive", unarchiveCommand) + .command("delete", deleteCommand) + .command("add-project", addProjectCommand) + .command("remove-project", removeProjectCommand) diff --git a/src/commands/issue/issue-delete.ts b/src/commands/issue/issue-delete.ts index 693f118..8e709b3 100644 --- a/src/commands/issue/issue-delete.ts +++ b/src/commands/issue/issue-delete.ts @@ -3,81 +3,261 @@ import { Confirm } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getIssueIdentifier } from "../../utils/linear.ts" +import { + type BulkOperationResult, + collectBulkIds, + executeBulkOperations, + isBulkMode, + printBulkSummary, +} from "../../utils/bulk.ts" + +interface IssueDeleteResult extends BulkOperationResult { + identifier?: string +} export const deleteCommand = new Command() .name("delete") .description("Delete an issue") .alias("d") - .arguments("") + .arguments("[issueId:string]") .option("-y, --confirm", "Skip confirmation prompt") - .action(async ({ confirm }, issueId) => { - // First resolve the issue ID to get the issue details - const resolvedId = await getIssueIdentifier(issueId) - if (!resolvedId) { - console.error("Could not find issue with ID:", issueId) - Deno.exit(1) + .option("--no-color", "Disable colored output") + .option( + "--bulk ", + "Delete multiple issues by identifier (e.g., TC-123 TC-124)", + ) + .option( + "--bulk-file ", + "Read issue identifiers from a file (one per line)", + ) + .option("--bulk-stdin", "Read issue identifiers from stdin") + .action( + async ( + { confirm, color: colorEnabled, bulk, bulkFile, bulkStdin }, + issueId, + ) => { + const client = getGraphQLClient() + + // Check if bulk mode + if (isBulkMode({ bulk, bulkFile, bulkStdin })) { + await handleBulkDelete(client, { + bulk, + bulkFile, + bulkStdin, + confirm, + colorEnabled, + }) + return + } + + // Single mode requires issueId + if (!issueId) { + console.error("Issue ID required. Use --bulk for multiple issues.") + Deno.exit(1) + } + + await handleSingleDelete(client, issueId, { confirm, colorEnabled }) + }, + ) + +async function handleSingleDelete( + // deno-lint-ignore no-explicit-any + client: any, + issueId: string, + options: { confirm?: boolean; colorEnabled?: boolean }, +): Promise { + const { confirm } = options + + // First resolve the issue ID to get the issue details + const resolvedId = await getIssueIdentifier(issueId) + if (!resolvedId) { + console.error("Could not find issue with ID:", issueId) + Deno.exit(1) + } + + // Get issue details to show title in confirmation + const detailsQuery = gql(` + query GetIssueDeleteDetails($id: String!) { + issue(id: $id) { title, identifier } } + `) - // Get issue details to show title in confirmation - const client = getGraphQLClient() - const detailsQuery = gql(` - query GetIssueDeleteDetails($id: String!) { - issue(id: $id) { title, identifier } + let issueDetails + try { + issueDetails = await client.request(detailsQuery, { id: resolvedId }) + } catch (error) { + console.error("Failed to fetch issue details:", error) + Deno.exit(1) + } + + if (!issueDetails?.issue) { + console.error("Issue not found:", resolvedId) + Deno.exit(1) + } + + const { title, identifier } = issueDetails.issue + + // Show confirmation prompt unless --confirm flag is used + if (!confirm) { + const confirmed = await Confirm.prompt({ + message: `Are you sure you want to delete "${identifier}: ${title}"?`, + default: false, + }) + + if (!confirmed) { + console.log("Delete cancelled.") + return + } + } + + // Delete the issue + const deleteQuery = gql(` + mutation DeleteIssue($id: String!) { + issueDelete(id: $id) { + success + entity { + identifier + title + } } - `) + } + `) - let issueDetails - try { - issueDetails = await client.request(detailsQuery, { id: resolvedId }) - } catch (error) { - console.error("Failed to fetch issue details:", error) + try { + const result = await client.request(deleteQuery, { id: resolvedId }) + + if (result.issueDelete.success) { + console.log(`✓ Successfully deleted issue: ${identifier}: ${title}`) + } else { + console.error("Failed to delete issue") Deno.exit(1) } + } catch (error) { + console.error("Failed to delete issue:", error) + Deno.exit(1) + } +} - if (!issueDetails?.issue) { - console.error("Issue not found:", resolvedId) - Deno.exit(1) +async function handleBulkDelete( + // deno-lint-ignore no-explicit-any + client: any, + options: { + bulk?: string[] + bulkFile?: string + bulkStdin?: boolean + confirm?: boolean + colorEnabled?: boolean + }, +): Promise { + const { confirm, colorEnabled = true } = options + + // Collect all IDs + const ids = await collectBulkIds({ + bulk: options.bulk, + bulkFile: options.bulkFile, + bulkStdin: options.bulkStdin, + }) + + if (ids.length === 0) { + console.error("No issue identifiers provided for bulk delete.") + Deno.exit(1) + } + + console.log(`Found ${ids.length} issue(s) to delete.`) + + // Confirm bulk operation + if (!confirm) { + const confirmed = await Confirm.prompt({ + message: `Delete ${ids.length} issue(s)?`, + default: false, + }) + + if (!confirmed) { + console.log("Bulk delete cancelled.") + return } + } - const { title, identifier } = issueDetails.issue + // Define the delete operation + const deleteOperation = async ( + issueIdInput: string, + ): Promise => { + // Resolve the issue identifier + const resolvedId = await getIssueIdentifier(issueIdInput) + if (!resolvedId) { + return { + id: issueIdInput, + identifier: issueIdInput, + success: false, + error: "Issue not found", + } + } + + // Get issue details for display + const detailsQuery = gql(` + query GetIssueDetailsForBulkDelete($id: String!) { + issue(id: $id) { title, identifier } + } + `) - // Show confirmation prompt unless --confirm flag is used - if (!confirm) { - const confirmed = await Confirm.prompt({ - message: `Are you sure you want to delete "${identifier}: ${title}"?`, - default: false, - }) + let identifier = resolvedId + let title = "" - if (!confirmed) { - console.log("Delete cancelled.") - return + try { + const details = await client.request(detailsQuery, { id: resolvedId }) + if (details?.issue) { + identifier = details.issue.identifier + title = details.issue.title } + } catch { + // Continue with default identifier } // Delete the issue - const deleteQuery = gql(` - mutation DeleteIssue($id: String!) { + const deleteMutation = gql(` + mutation BulkDeleteIssue($id: String!) { issueDelete(id: $id) { success - entity { - identifier - title - } } } `) - try { - const result = await client.request(deleteQuery, { id: resolvedId }) + const result = await client.request(deleteMutation, { id: resolvedId }) - if (result.issueDelete.success) { - console.log(`✓ Successfully deleted issue: ${identifier}: ${title}`) - } else { - console.error("Failed to delete issue") - Deno.exit(1) + if (!result.issueDelete.success) { + return { + id: resolvedId, + identifier, + name: title ? `${identifier}: ${title}` : identifier, + success: false, + error: "Delete operation failed", } - } catch (error) { - console.error("Failed to delete issue:", error) - Deno.exit(1) } + + return { + id: resolvedId, + identifier, + name: title ? `${identifier}: ${title}` : identifier, + success: true, + } + } + + // Execute bulk operation + const summary = await executeBulkOperations(ids, deleteOperation, { + showProgress: true, + colorEnabled, }) + + // Print summary + printBulkSummary(summary, { + entityName: "issue", + operationName: "deleted", + colorEnabled, + showDetails: true, + }) + + // Exit with error code if any failed + if (summary.failed > 0) { + Deno.exit(1) + } +} diff --git a/src/commands/label/label-create.ts b/src/commands/label/label-create.ts new file mode 100644 index 0000000..702456f --- /dev/null +++ b/src/commands/label/label-create.ts @@ -0,0 +1,217 @@ +import { Command } from "@cliffy/command" +import { Input, Select } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getAllTeams, getTeamIdByKey, getTeamKey } from "../../utils/linear.ts" + +const CreateIssueLabel = gql(` + mutation CreateIssueLabel($input: IssueLabelCreateInput!) { + issueLabelCreate(input: $input) { + success + issueLabel { + id + name + color + description + team { + key + name + } + } + } + } +`) + +// Common label colors from Linear's palette +const DEFAULT_COLORS = [ + { name: "Red", value: "#EB5757" }, + { name: "Orange", value: "#F2994A" }, + { name: "Yellow", value: "#F2C94C" }, + { name: "Green", value: "#27AE60" }, + { name: "Teal", value: "#0D9488" }, + { name: "Blue", value: "#2F80ED" }, + { name: "Indigo", value: "#5E6AD2" }, + { name: "Purple", value: "#8B5CF6" }, + { name: "Pink", value: "#BB6BD9" }, + { name: "Gray", value: "#6B6F76" }, +] + +export const createCommand = new Command() + .name("create") + .description("Create a new issue label") + .option("-n, --name ", "Label name (required)") + .option( + "-c, --color ", + "Color hex code (e.g., #EB5757)", + ) + .option("-d, --description ", "Label description") + .option( + "-t, --team ", + "Team key for team-specific label (omit for workspace label)", + ) + .option("-i, --interactive", "Interactive mode (default if no flags provided)") + .action(async (options) => { + const { + name: providedName, + color: providedColor, + description: providedDescription, + team: providedTeam, + interactive: interactiveFlag, + } = options + + const client = getGraphQLClient() + + let name = providedName + let color = providedColor + let description = providedDescription + let teamKey = providedTeam + + // Determine if we should run in interactive mode + const noFlagsProvided = !name + const isInteractive = + (noFlagsProvided || interactiveFlag) && Deno.stdout.isTerminal() + + if (isInteractive) { + console.log("\nCreate a new label\n") + + // Name (required) + if (!name) { + name = await Input.prompt({ + message: "Label name:", + minLength: 1, + }) + } + + // Color selection + if (!color) { + const colorOptions = [ + ...DEFAULT_COLORS.map((c) => ({ + name: `${c.name} (${c.value})`, + value: c.value, + })), + { name: "Custom color", value: "custom" }, + ] + + const selectedColor = await Select.prompt({ + message: "Color:", + options: colorOptions, + default: DEFAULT_COLORS[6].value, // Indigo + }) + + if (selectedColor === "custom") { + color = await Input.prompt({ + message: "Enter hex color (e.g., #FF5733):", + validate: (value) => { + if (!/^#[0-9A-Fa-f]{6}$/.test(value)) { + return "Please enter a valid hex color (e.g., #FF5733)" + } + return true + }, + }) + } else { + color = selectedColor + } + } + + // Description (optional) + if (!description) { + description = await Input.prompt({ + message: "Description (optional):", + }) + if (!description) description = undefined + } + + // Team selection (optional) + if (teamKey === undefined) { + const allTeams = await getAllTeams() + const teamOptions = [ + { name: "Workspace (shared by all teams)", value: "__workspace__" }, + ...allTeams.map((t) => ({ + name: `${t.name} (${t.key})`, + value: t.key, + })), + ] + + // Try to get default team from config + const defaultTeam = getTeamKey() + const defaultIndex = defaultTeam + ? teamOptions.findIndex((t) => t.value === defaultTeam) + : 0 + + const selectedTeam = await Select.prompt({ + message: "Team:", + options: teamOptions, + default: defaultIndex >= 0 ? teamOptions[defaultIndex].value : "__workspace__", + }) + + teamKey = selectedTeam === "__workspace__" ? undefined : selectedTeam + } + } + + // Validate required fields + if (!name) { + console.error("Label name is required. Use --name or -n flag.") + Deno.exit(1) + } + + // Validate color format if provided + if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) { + console.error("Color must be a valid hex code (e.g., #EB5757)") + Deno.exit(1) + } + + // Default color if not provided + if (!color) { + color = DEFAULT_COLORS[6].value // Indigo + } + + // Build input + // deno-lint-ignore no-explicit-any + const input: Record = { + name, + color, + } + + if (description) { + input.description = description + } + + // Resolve team ID if team-specific + if (teamKey) { + const teamId = await getTeamIdByKey(teamKey.toUpperCase()) + if (!teamId) { + console.error(`Team not found: ${teamKey}`) + Deno.exit(1) + } + input.teamId = teamId + } + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + const result = await client.request(CreateIssueLabel, { input }) + + if (!result.issueLabelCreate.success) { + spinner?.stop() + console.error("Failed to create label") + Deno.exit(1) + } + + const label = result.issueLabelCreate.issueLabel + spinner?.stop() + + console.log(`✓ Created label: ${label.name}`) + console.log(` Color: ${label.color}`) + if (label.description) { + console.log(` Description: ${label.description}`) + } + console.log(` Scope: ${label.team?.name ? `${label.team.name} (${label.team.key})` : "Workspace"}`) + } catch (error) { + spinner?.stop() + console.error("Failed to create label:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/label/label-delete.ts b/src/commands/label/label-delete.ts new file mode 100644 index 0000000..6a1a5e2 --- /dev/null +++ b/src/commands/label/label-delete.ts @@ -0,0 +1,190 @@ +import { Command } from "@cliffy/command" +import { Confirm, Select } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getTeamKey } from "../../utils/linear.ts" + +const DeleteIssueLabel = gql(` + mutation DeleteIssueLabel($id: String!) { + issueLabelDelete(id: $id) { + success + } + } +`) + +const GetLabelByName = gql(` + query GetLabelByName($name: String!, $teamKey: String) { + issueLabels( + filter: { + name: { eqIgnoreCase: $name } + } + ) { + nodes { + id + name + color + team { + key + name + } + } + } + } +`) + +const GetLabelById = gql(` + query GetLabelById($id: String!) { + issueLabel(id: $id) { + id + name + color + team { + key + name + } + } + } +`) + +interface Label { + id: string + name: string + color: string + team?: { key: string; name: string } | null +} + +async function resolveLabelId( + // deno-lint-ignore no-explicit-any + client: any, + nameOrId: string, + teamKey?: string, +): Promise