diff --git a/README.md b/README.md index 5afaaf2..cb32883 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,42 @@ linear milestone delete # delete a milestone linear m delete --force # delete without confirmation ``` +### document commands + +manage Linear documents from the command line. documents can be attached to projects or issues, or exist at the workspace level. + +```bash +# list documents +linear document list # list all accessible documents +linear docs list # alias for document +linear document list --project # filter by project +linear document list --issue TC-123 # filter by issue +linear document list --json # output as JSON + +# view a document +linear document view # view document rendered in terminal +linear document view --raw # output raw markdown (for piping) +linear document view --web # open in browser +linear document view --json # output as JSON + +# create a document +linear document create --title "My Doc" --content "# Hello" # inline content +linear document create --title "Spec" --content-file ./spec.md # from file +linear document create --title "Doc" --project # attach to project +linear document create --title "Notes" --issue TC-123 # attach to issue +cat spec.md | linear document create --title "Spec" # from stdin + +# update a document +linear document update --title "New Title" # update title +linear document update --content-file ./updated.md # update content +linear document update --edit # open in $EDITOR + +# delete a document +linear document delete # soft delete (move to trash) +linear document delete --permanent # permanent delete +linear document delete --bulk # bulk delete +``` + ### other commands ```bash diff --git a/deno.json b/deno.json index 4a3725c..f18d51d 100644 --- a/deno.json +++ b/deno.json @@ -13,7 +13,8 @@ "check": "deno check src/main.ts", "test": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app --allow-sys=hostname --quiet", "snapshot": "deno test '--allow-env=GITHUB_*,GH_*,LINEAR_*,NODE_ENV,EDITOR,PAGER,SNAPSHOT_TEST_NAME,CLIFFY_SNAPSHOT_FAKE_TIME,NO_COLOR,TMPDIR,TMP,TEMP,XDG_CONFIG_HOME,HOME,APPDATA,PATH,SystemRoot' --allow-read --allow-write --allow-run --allow-net=api.linear.app,uploads.linear.app --allow-sys=hostname -- --update", - "lefthook-install": "deno run --allow-run --allow-read --allow-write --allow-env npm:lefthook install" + "lefthook-install": "deno run --allow-run --allow-read --allow-write --allow-env npm:lefthook install", + "validate": "deno task check && deno fmt && deno lint" }, "imports": { "@cliffy/ansi": "jsr:@cliffy/ansi@^1.0.0-rc.8", 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/document/document-create.ts b/src/commands/document/document-create.ts new file mode 100644 index 0000000..fbecfc1 --- /dev/null +++ b/src/commands/document/document-create.ts @@ -0,0 +1,404 @@ +import { Command } from "@cliffy/command" +import { Input, Select } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getEditor, openEditor } from "../../utils/editor.ts" +import { readIdsFromStdin } from "../../utils/bulk.ts" + +/** + * Read content from stdin if available (piped input, with timeout) + */ +async function readContentFromStdin(): Promise { + // Check if stdin has data (not a TTY) + if (Deno.stdin.isTerminal()) { + return undefined + } + + try { + // Use timeout to avoid hanging when stdin is not a terminal but has no data + // (e.g., in test subprocess environments) + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("stdin timeout")), 100) + }) + + const lines = await Promise.race([readIdsFromStdin(), timeoutPromise]) + // Join back with newlines since it's content, not IDs + const content = lines.join("\n") + return content.length > 0 ? content : undefined + } catch { + return undefined + } +} + +export const createCommand = new Command() + .name("create") + .description("Create a new document") + .alias("c") + .option("-t, --title ", "Document title (required)") + .option("-c, --content ", "Markdown content (inline)") + .option("-f, --content-file ", "Read content from file") + .option("--project ", "Attach to project (slug or ID)") + .option("--issue ", "Attach to issue (identifier like TC-123)") + .option("--icon ", "Document icon (emoji)") + .option("-i, --interactive", "Interactive mode with prompts") + .option("--no-color", "Disable colored output") + .action( + async ({ + title, + content, + contentFile, + project, + issue, + icon, + interactive, + color: _colorEnabled, + }) => { + const client = getGraphQLClient() + + // Determine if we should use interactive mode + let useInteractive = interactive && Deno.stdout.isTerminal() + + // If no title and not interactive, check if we should enter interactive mode + const noFlagsProvided = !title && !content && !contentFile && !project && + !issue && !icon + if (noFlagsProvided && Deno.stdout.isTerminal()) { + useInteractive = true + } + + // Interactive mode + if (useInteractive) { + const result = await promptInteractiveCreate() + + if (!result.title) { + console.error("Title is required") + Deno.exit(1) + } + + const input: Record = { + title: result.title, + content: result.content, + icon: result.icon, + projectId: result.projectId, + issueId: result.issueId, + } + + // Remove undefined values + Object.keys(input).forEach((key) => { + if (input[key] === undefined) { + delete input[key] + } + }) + + await createDocument(client, input) + return + } + + // Non-interactive mode requires title + if (!title) { + console.error( + "Title is required. Use --title or run with -i for interactive mode.", + ) + Deno.exit(1) + } + + // Resolve content from various sources + let finalContent: string | undefined + + if (content) { + // Content provided inline via --content + finalContent = content + } else if (contentFile) { + // Content from file via --content-file + try { + finalContent = await Deno.readTextFile(contentFile) + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + console.error(`File not found: ${contentFile}`) + } else { + console.error( + "Failed to read content file:", + error instanceof Error ? error.message : String(error), + ) + } + Deno.exit(1) + } + } else if (!Deno.stdin.isTerminal()) { + // Try reading from stdin if piped + const stdinContent = await readContentFromStdin() + if (stdinContent) { + finalContent = stdinContent + } + } else if (Deno.stdout.isTerminal()) { + // No content provided, open editor + console.log("Opening editor for document content...") + finalContent = await openEditor() + if (!finalContent) { + console.log("No content entered. Creating document without content.") + } + } + + // Resolve project ID if provided + let projectId: string | undefined + if (project) { + projectId = await resolveProjectId(client, project) + if (!projectId) { + console.error(`Could not resolve project: ${project}`) + Deno.exit(1) + } + } + + // Resolve issue ID if provided + let issueId: string | undefined + if (issue) { + issueId = await resolveIssueId(client, issue) + if (!issueId) { + console.error(`Could not resolve issue: ${issue}`) + Deno.exit(1) + } + } + + // Build input + const input: Record = { + title, + content: finalContent, + icon, + projectId, + issueId, + } + + // Remove undefined values + Object.keys(input).forEach((key) => { + if (input[key] === undefined) { + delete input[key] + } + }) + + await createDocument(client, input) + }, + ) + +async function promptInteractiveCreate(): Promise<{ + title?: string + content?: string + icon?: string + projectId?: string + issueId?: string +}> { + // Prompt for title + const title = await Input.prompt({ + message: "Document title", + minLength: 1, + }) + + // Prompt for description entry method + const editorName = await getEditor() + const editorDisplayName = editorName ? editorName.split("/").pop() : null + + const contentMethod = await Select.prompt({ + message: "How would you like to enter content?", + options: [ + { name: "Skip (no content)", value: "skip" }, + { name: "Enter inline", value: "inline" }, + ...(editorDisplayName + ? [{ name: `Open ${editorDisplayName}`, value: "editor" }] + : []), + { name: "Read from file", value: "file" }, + ], + default: "skip", + }) + + let content: string | undefined + + if (contentMethod === "inline") { + const inlineContent = await Input.prompt({ + message: "Content (markdown)", + default: "", + }) + content = inlineContent.trim() || undefined + } else if (contentMethod === "editor" && editorDisplayName) { + console.log(`Opening ${editorDisplayName}...`) + content = await openEditor() + if (content) { + console.log(`Content entered (${content.length} characters)`) + } + } else if (contentMethod === "file") { + const filePath = await Input.prompt({ + message: "File path", + }) + try { + content = await Deno.readTextFile(filePath) + } catch (error) { + console.error( + "Failed to read file:", + error instanceof Error ? error.message : String(error), + ) + } + } + + // Prompt for icon + const icon = await Input.prompt({ + message: "Icon (emoji, leave blank for none)", + default: "", + }) + + // Ask about attachment + const attachTo = await Select.prompt({ + message: "Attach document to", + options: [ + { name: "Nothing (workspace document)", value: "none" }, + { name: "Project", value: "project" }, + { name: "Issue", value: "issue" }, + ], + default: "none", + }) + + let projectId: string | undefined + let issueId: string | undefined + + if (attachTo === "project") { + const projectInput = await Input.prompt({ + message: "Project slug or ID", + }) + const client = getGraphQLClient() + projectId = await resolveProjectId(client, projectInput) + if (!projectId) { + console.error(`Could not resolve project: ${projectInput}`) + } + } else if (attachTo === "issue") { + const issueInput = await Input.prompt({ + message: "Issue identifier (e.g., TC-123)", + }) + const client = getGraphQLClient() + issueId = await resolveIssueId(client, issueInput) + if (!issueId) { + console.error(`Could not resolve issue: ${issueInput}`) + } + } + + return { + title, + content, + icon: icon.trim() || undefined, + projectId, + issueId, + } +} + +async function resolveProjectId( + // deno-lint-ignore no-explicit-any + client: any, + projectInput: string, +): Promise { + // First try to get by slug/ID directly + const projectQuery = gql(` + query GetProjectForDocument($slugId: String!) { + project(id: $slugId) { + id + name + } + } + `) + + try { + const result = await client.request(projectQuery, { slugId: projectInput }) + if (result.project) { + return result.project.id + } + } catch { + // Project not found by ID, try searching by name + } + + // Search by name + const searchQuery = gql(` + query SearchProjectsForDocument($filter: ProjectFilter) { + projects(filter: $filter, first: 1) { + nodes { + id + name + } + } + } + `) + + try { + const result = await client.request(searchQuery, { + filter: { + name: { containsIgnoreCase: projectInput }, + }, + }) + if (result.projects.nodes.length > 0) { + return result.projects.nodes[0].id + } + } catch { + // Search failed + } + + return undefined +} + +async function resolveIssueId( + // deno-lint-ignore no-explicit-any + client: any, + issueIdentifier: string, +): Promise { + const issueQuery = gql(` + query GetIssueForDocument($id: String!) { + issue(id: $id) { + id + identifier + } + } + `) + + try { + const result = await client.request(issueQuery, { id: issueIdentifier }) + if (result.issue) { + return result.issue.id + } + } catch { + // Issue not found + } + + return undefined +} + +async function createDocument( + // deno-lint-ignore no-explicit-any + client: any, + input: Record, +): Promise { + const createMutation = gql(` + mutation CreateDocument($input: DocumentCreateInput!) { + documentCreate(input: $input) { + success + document { + id + slugId + title + url + } + } + } + `) + + try { + const result = await client.request(createMutation, { input }) + + if (!result.documentCreate.success) { + console.error("Failed to create document") + Deno.exit(1) + } + + const document = result.documentCreate.document + if (!document) { + console.error("Document creation failed - no document returned") + Deno.exit(1) + } + + console.log(`✓ Created document: ${document.title}`) + console.log(document.url) + } catch (error) { + console.error("Failed to create document:", error) + Deno.exit(1) + } +} diff --git a/src/commands/document/document-delete.ts b/src/commands/document/document-delete.ts new file mode 100644 index 0000000..e282433 --- /dev/null +++ b/src/commands/document/document-delete.ts @@ -0,0 +1,255 @@ +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 DocumentDeleteResult extends BulkOperationResult { + title?: string +} + +export const deleteCommand = new Command() + .name("delete") + .description("Delete a document (moves to trash)") + .alias("d") + .arguments("[documentId:string]") + .option("-y, --yes", "Skip confirmation prompt") + .option("--no-color", "Disable colored output") + .option( + "--bulk ", + "Delete multiple documents by slug or ID", + ) + .option( + "--bulk-file ", + "Read document slugs/IDs from a file (one per line)", + ) + .option("--bulk-stdin", "Read document slugs/IDs from stdin") + .action( + async ( + { yes, color: colorEnabled, bulk, bulkFile, bulkStdin }, + documentId, + ) => { + const client = getGraphQLClient() + + // Check if bulk mode + if (isBulkMode({ bulk, bulkFile, bulkStdin })) { + await handleBulkDelete(client, { + bulk, + bulkFile, + bulkStdin, + yes, + colorEnabled, + }) + return + } + + // Single mode requires documentId + if (!documentId) { + console.error( + "Document ID required. Use --bulk for multiple documents.", + ) + Deno.exit(1) + } + + await handleSingleDelete(client, documentId, { yes, colorEnabled }) + }, + ) + +async function handleSingleDelete( + // deno-lint-ignore no-explicit-any + client: any, + documentId: string, + options: { yes?: boolean; colorEnabled?: boolean }, +): Promise { + const { yes } = options + + // Get document details for confirmation message + const detailsQuery = gql(` + query GetDocumentForDelete($id: String!) { + document(id: $id) { + id + slugId + title + } + } + `) + + let documentDetails + try { + documentDetails = await client.request(detailsQuery, { id: documentId }) + } catch (error) { + console.error("Failed to fetch document details:", error) + Deno.exit(1) + } + + if (!documentDetails?.document) { + console.error(`Document not found: ${documentId}`) + Deno.exit(1) + } + + const document = documentDetails.document + + // Confirm deletion + if (!yes) { + const confirmed = await Confirm.prompt({ + message: `Are you sure you want to delete "${document.title}"?`, + default: false, + }) + + if (!confirmed) { + console.log("Delete cancelled.") + return + } + } + + // Delete the document (moves to trash) + const deleteMutation = gql(` + mutation DeleteDocument($id: String!) { + documentDelete(id: $id) { + success + } + } + `) + + try { + const result = await client.request(deleteMutation, { id: document.id }) + + if (result.documentDelete.success) { + console.log(`✓ Deleted document: ${document.title}`) + } else { + console.error("Failed to delete document") + Deno.exit(1) + } + } catch (error) { + console.error("Failed to delete document:", error) + Deno.exit(1) + } +} + +async function handleBulkDelete( + // deno-lint-ignore no-explicit-any + client: any, + options: { + bulk?: string[] + bulkFile?: string + bulkStdin?: boolean + yes?: boolean + colorEnabled?: boolean + }, +): Promise { + const { yes, 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 document IDs provided for bulk delete.") + Deno.exit(1) + } + + console.log(`Found ${ids.length} document(s) to delete.`) + + // Confirm bulk operation + if (!yes) { + const confirmed = await Confirm.prompt({ + message: `Delete ${ids.length} document(s)?`, + default: false, + }) + + if (!confirmed) { + console.log("Bulk delete cancelled.") + return + } + } + + // Define the delete operation + const deleteOperation = async ( + docId: string, + ): Promise => { + // Get document details for display + const detailsQuery = gql(` + query GetDocumentForBulkDelete($id: String!) { + document(id: $id) { + id + slugId + title + } + } + `) + + let documentUuid = docId + let title = docId + + try { + const details = await client.request(detailsQuery, { id: docId }) + if (details?.document) { + documentUuid = details.document.id + title = details.document.title + } + } catch { + return { + id: docId, + title: docId, + success: false, + error: "Document not found", + } + } + + // Delete the document + const deleteMutation = gql(` + mutation BulkDeleteDocument($id: String!) { + documentDelete(id: $id) { + success + } + } + `) + + const result = await client.request(deleteMutation, { id: documentUuid }) + + if (!result.documentDelete.success) { + return { + id: documentUuid, + name: title, + title, + success: false, + error: "Delete operation failed", + } + } + + return { + id: documentUuid, + name: title, + title, + success: true, + } + } + + // Execute bulk operation + const summary = await executeBulkOperations(ids, deleteOperation, { + showProgress: true, + colorEnabled, + }) + + // Print summary + printBulkSummary(summary, { + entityName: "document", + operationName: "deleted", + colorEnabled, + showDetails: true, + }) + + // Exit with error code if any failed + if (summary.failed > 0) { + Deno.exit(1) + } +} diff --git a/src/commands/document/document-list.ts b/src/commands/document/document-list.ts new file mode 100644 index 0000000..b58a8e6 --- /dev/null +++ b/src/commands/document/document-list.ts @@ -0,0 +1,166 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getTimeAgo, padDisplay } from "../../utils/display.ts" + +const ListDocuments = gql(` + query ListDocuments($filter: DocumentFilter, $first: Int) { + documents(filter: $filter, first: $first) { + nodes { + id + title + slugId + url + updatedAt + project { + name + slugId + } + issue { + identifier + title + } + creator { + name + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`) + +export const listCommand = new Command() + .name("list") + .description("List documents") + .alias("l") + .option("--project ", "Filter by project (slug or name)") + .option("--issue ", "Filter by issue (identifier like TC-123)") + .option("--json", "Output as JSON") + .option("--limit ", "Limit results", { default: 50 }) + .action(async ({ project, issue, json, limit }) => { + 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 based on options + // deno-lint-ignore no-explicit-any + let filter: any = undefined + + if (project) { + filter = { + ...filter, + project: { slugId: { eq: project } }, + } + } + + if (issue) { + filter = { + ...filter, + issue: { identifier: { eq: issue.toUpperCase() } }, + } + } + + const client = getGraphQLClient() + const result = await client.request(ListDocuments, { + filter, + first: limit, + }) + spinner?.stop() + + const documents = result.documents?.nodes || [] + + if (json) { + console.log(JSON.stringify(documents, null, 2)) + return + } + + if (documents.length === 0) { + console.log("No documents found.") + return + } + + // Calculate column widths based on actual data + const { columns } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 120 } + + const SLUG_WIDTH = Math.max( + 4, // minimum width for "SLUG" header + ...documents.map((doc) => doc.slugId.length), + ) + + // Get attachment column (project name or issue identifier) + const getAttachment = (doc: typeof documents[0]) => { + if (doc.project?.name) return doc.project.name + if (doc.issue?.identifier) return doc.issue.identifier + return "-" + } + + const ATTACHMENT_WIDTH = Math.max( + 10, // minimum width for "ATTACHMENT" header + ...documents.map((doc) => getAttachment(doc).length), + ) + + const UPDATED_WIDTH = Math.max( + 7, // minimum width for "UPDATED" header + ...documents.map((doc) => getTimeAgo(new Date(doc.updatedAt)).length), + ) + + const SPACE_WIDTH = 3 // spaces between columns + const fixed = SLUG_WIDTH + ATTACHMENT_WIDTH + UPDATED_WIDTH + SPACE_WIDTH + const PADDING = 1 + const availableWidth = Math.max(columns - PADDING - fixed, 10) + const maxTitleWidth = Math.max( + ...documents.map((doc) => doc.title.length), + ) + const titleWidth = Math.min(maxTitleWidth, availableWidth) + + // Print header + const header = [ + padDisplay("SLUG", SLUG_WIDTH), + padDisplay("TITLE", titleWidth), + padDisplay("ATTACHMENT", ATTACHMENT_WIDTH), + padDisplay("UPDATED", UPDATED_WIDTH), + ] + + let headerMsg = "" + const headerStyles: string[] = [] + header.forEach((cell, index) => { + headerMsg += `%c${cell}` + headerStyles.push("text-decoration: underline") + if (index < header.length - 1) { + headerMsg += "%c %c" + headerStyles.push("text-decoration: none") + headerStyles.push("text-decoration: underline") + } + }) + console.log(headerMsg, ...headerStyles) + + // Print each document + for (const doc of documents) { + const truncTitle = doc.title.length > titleWidth + ? doc.title.slice(0, titleWidth - 3) + "..." + : padDisplay(doc.title, titleWidth) + + const attachment = getAttachment(doc) + const updated = getTimeAgo(new Date(doc.updatedAt)) + + console.log( + `${padDisplay(doc.slugId, SLUG_WIDTH)} ${truncTitle} ${ + padDisplay(attachment, ATTACHMENT_WIDTH) + } %c${padDisplay(updated, UPDATED_WIDTH)}%c`, + "color: gray", + "", + ) + } + } catch (error) { + spinner?.stop() + console.error("Failed to fetch documents:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/document/document-update.ts b/src/commands/document/document-update.ts new file mode 100644 index 0000000..88f5912 --- /dev/null +++ b/src/commands/document/document-update.ts @@ -0,0 +1,248 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getEditor } from "../../utils/editor.ts" +import { readIdsFromStdin } from "../../utils/bulk.ts" + +/** + * Open editor with initial content and return the edited content + */ +async function openEditorWithContent( + initialContent: string, +): Promise { + const editor = await getEditor() + if (!editor) { + console.error( + "No editor found. Please set EDITOR environment variable or configure git editor with: git config --global core.editor ", + ) + return undefined + } + + // Create a temporary file with initial content + const tempFile = await Deno.makeTempFile({ suffix: ".md" }) + + try { + // Write initial content to temp file + await Deno.writeTextFile(tempFile, initialContent) + + // Open the editor + const process = new Deno.Command(editor, { + args: [tempFile], + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + + const { success } = await process.output() + + if (!success) { + console.error("Editor exited with an error") + return undefined + } + + // Read the content back + const content = await Deno.readTextFile(tempFile) + const cleaned = content.trim() + + return cleaned.length > 0 ? cleaned : undefined + } catch (error) { + console.error( + "Failed to open editor:", + error instanceof Error ? error.message : String(error), + ) + return undefined + } finally { + // Clean up the temporary file + try { + await Deno.remove(tempFile) + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Read content from stdin if available (with timeout to avoid hanging) + */ +async function readContentFromStdin(): Promise { + // Check if stdin has data (not a TTY) + if (Deno.stdin.isTerminal()) { + return undefined + } + + try { + // Use timeout to avoid hanging when stdin is not a terminal but has no data + // (e.g., in test subprocess environments) + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("stdin timeout")), 100) + }) + + const ids = await Promise.race([readIdsFromStdin(), timeoutPromise]) + // Join back with newlines since it's content, not IDs + const content = ids.join("\n") + return content.length > 0 ? content : undefined + } catch { + return undefined + } +} + +export const updateCommand = new Command() + .name("update") + .description("Update an existing document") + .alias("u") + .arguments("") + .option("-t, --title ", "New title for the document") + .option("-c, --content ", "New markdown content (inline)") + .option( + "-f, --content-file ", + "Read new content from file", + ) + .option("--icon ", "New icon (emoji)") + .option("-e, --edit", "Open current content in $EDITOR for editing") + .option("--no-color", "Disable colored output") + .action( + async ( + { title, content, contentFile, icon, edit, color: _colorEnabled }, + documentId, + ) => { + const client = getGraphQLClient() + + // Build the update input + const input: Record = {} + + // Add title if provided + if (title) { + input.title = title + } + + // Add icon if provided + if (icon) { + input.icon = icon + } + + // Resolve content from various sources + let finalContent: string | undefined + + if (content) { + // Content provided inline + finalContent = content + } else if (contentFile) { + // Content from file + try { + finalContent = await Deno.readTextFile(contentFile) + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + console.error(`File not found: ${contentFile}`) + } else { + console.error( + "Failed to read content file:", + error instanceof Error ? error.message : String(error), + ) + } + Deno.exit(1) + } + } else if (edit) { + // Edit mode: fetch current content and open in editor + const getDocumentQuery = gql(` + query GetDocumentForEdit($id: String!) { + document(id: $id) { + id + title + content + } + } + `) + + let documentData + try { + documentData = await client.request(getDocumentQuery, { + id: documentId, + }) + } catch (error) { + console.error("Failed to fetch document:", error) + Deno.exit(1) + } + + if (!documentData?.document) { + console.error(`Document not found: ${documentId}`) + Deno.exit(1) + } + + const currentContent = documentData.document.content || "" + console.log(`Opening ${documentData.document.title} in editor...`) + + finalContent = await openEditorWithContent(currentContent) + + if (finalContent === undefined) { + console.log("No changes made, update cancelled.") + return + } + + // Check if content actually changed + if (finalContent === currentContent) { + console.log("No changes detected, update cancelled.") + return + } + } else if (!Deno.stdin.isTerminal() && Object.keys(input).length === 0) { + // Only try reading from stdin if no other update fields were provided + // This avoids hanging when stdin is piped but has no data (e.g., in test environments) + const stdinContent = await readContentFromStdin() + if (stdinContent) { + finalContent = stdinContent + } + } + + // Add content to input if resolved + if (finalContent !== undefined) { + input.content = finalContent + } + + // Validate that at least one field is being updated + if (Object.keys(input).length === 0) { + console.error( + "No update fields provided. Use --title, --content, --content-file, --icon, or --edit.", + ) + Deno.exit(1) + } + + // Execute the update + const updateMutation = gql(` + mutation UpdateDocument($id: String!, $input: DocumentUpdateInput!) { + documentUpdate(id: $id, input: $input) { + success + document { + id + slugId + title + url + updatedAt + } + } + } + `) + + try { + const result = await client.request(updateMutation, { + id: documentId, + input, + }) + + if (!result.documentUpdate.success) { + console.error("Failed to update document") + Deno.exit(1) + } + + const document = result.documentUpdate.document + if (!document) { + console.error("Document update failed - no document returned") + Deno.exit(1) + } + + console.log(`✓ Updated document: ${document.title}`) + console.log(document.url) + } catch (error) { + console.error("Failed to update document:", error) + Deno.exit(1) + } + }, + ) diff --git a/src/commands/document/document-view.ts b/src/commands/document/document-view.ts new file mode 100644 index 0000000..deecf41 --- /dev/null +++ b/src/commands/document/document-view.ts @@ -0,0 +1,131 @@ +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 GetDocument = gql(` + query GetDocument($id: String!) { + document(id: $id) { + id + title + slugId + content + url + createdAt + updatedAt + creator { + name + email + } + project { + name + slugId + } + issue { + identifier + title + } + } + } +`) + +export const viewCommand = new Command() + .name("view") + .description("View a document's content") + .alias("v") + .arguments("") + .option("--raw", "Output raw markdown without rendering") + .option("-w, --web", "Open document in browser") + .option("--json", "Output full document as JSON") + .action(async ({ raw, web, json }, id) => { + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = Deno.stdout.isTerminal() && !raw && !json + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + const client = getGraphQLClient() + const result = await client.request(GetDocument, { id }) + spinner?.stop() + + const document = result.document + if (!document) { + console.error(`Document not found: ${id}`) + Deno.exit(1) + } + + // Open in browser if requested + if (web) { + console.log(`Opening ${document.url} in web browser`) + await open(document.url) + return + } + + // JSON output + if (json) { + console.log(JSON.stringify(document, null, 2)) + return + } + + // Raw output (for piping) + if (raw || !Deno.stdout.isTerminal()) { + if (document.content) { + console.log(document.content) + } + return + } + + // Rendered output + const lines: string[] = [] + + // Title + lines.push(`# ${document.title}`) + lines.push("") + + // Metadata + lines.push(`**Slug:** ${document.slugId}`) + lines.push(`**URL:** ${document.url}`) + + if (document.creator) { + lines.push(`**Creator:** ${document.creator.name}`) + } + + if (document.project) { + lines.push(`**Project:** ${document.project.name}`) + } + + if (document.issue) { + lines.push( + `**Issue:** ${document.issue.identifier} - ${document.issue.title}`, + ) + } + + lines.push(`**Created:** ${formatRelativeTime(document.createdAt)}`) + lines.push(`**Updated:** ${formatRelativeTime(document.updatedAt)}`) + + // Content + if (document.content) { + lines.push("") + lines.push("---") + lines.push("") + lines.push(document.content) + } + + const markdown = lines.join("\n") + const terminalWidth = Deno.consoleSize().columns + console.log(renderMarkdown(markdown, { lineWidth: terminalWidth })) + } catch (error) { + spinner?.stop() + if ( + error instanceof Error && + error.message.includes("Entity not found") + ) { + console.error(`Document not found: ${id}`) + Deno.exit(1) + } + console.error("Failed to fetch document:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/document/document.ts b/src/commands/document/document.ts new file mode 100644 index 0000000..2f55ba6 --- /dev/null +++ b/src/commands/document/document.ts @@ -0,0 +1,20 @@ +import { Command } from "@cliffy/command" +import { listCommand } from "./document-list.ts" +import { viewCommand } from "./document-view.ts" +import { createCommand } from "./document-create.ts" +import { updateCommand } from "./document-update.ts" +import { deleteCommand } from "./document-delete.ts" + +export const documentCommand = new Command() + .name("document") + .description("Manage Linear documents") + .alias("docs") + .alias("doc") + .action(() => { + console.log("Use --help to see available subcommands") + }) + .command("list", listCommand) + .command("view", viewCommand) + .command("create", createCommand) + .command("update", updateCommand) + .command("delete", deleteCommand) diff --git a/src/commands/initiative-update/initiative-update-create.ts b/src/commands/initiative-update/initiative-update-create.ts new file mode 100644 index 0000000..240f11a --- /dev/null +++ b/src/commands/initiative-update/initiative-update-create.ts @@ -0,0 +1,368 @@ +import { Command } from "@cliffy/command" +import { Input, Select } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getEditor, openEditor } from "../../utils/editor.ts" +import { readIdsFromStdin } from "../../utils/bulk.ts" + +const HEALTH_VALUES = ["onTrack", "atRisk", "offTrack"] as const +type HealthValue = (typeof HEALTH_VALUES)[number] + +/** + * Read content from stdin if available (piped input) + */ +async function readContentFromStdin(): Promise { + // Check if stdin has data (not a TTY) + if (Deno.stdin.isTerminal()) { + return undefined + } + + try { + const lines = await readIdsFromStdin() + // Join back with newlines since it's content, not IDs + const content = lines.join("\n") + return content.length > 0 ? content : undefined + } catch { + return undefined + } +} + +/** + * 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 GetInitiativeBySlugForStatusUpdate($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 GetInitiativeByNameForStatusUpdate($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 +} + +export const createCommand = new Command() + .name("create") + .description("Create a new status update for an initiative") + .alias("c") + .arguments("") + .option("--body ", "Update content (markdown)") + .option("--body-file ", "Read content from file") + .option( + "--health ", + "Health status (onTrack, atRisk, offTrack)", + ) + .option("-i, --interactive", "Interactive mode with prompts") + .option("--no-color", "Disable colored output") + .action( + async ( + { body, bodyFile, health, 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 initiative name for display + const initiativeQuery = gql(` + query GetInitiativeNameForStatusUpdate($id: String!) { + initiative(id: $id) { + name + slugId + } + } + `) + let initiativeName = initiativeId + try { + const result = await client.request(initiativeQuery, { id: resolvedId }) + if (result.initiative?.name) { + initiativeName = result.initiative.name + } + } catch { + // Use provided ID as fallback + } + + // Determine if we should use interactive mode + let useInteractive = interactive && Deno.stdout.isTerminal() + + // If no flags provided and we have a TTY, enter interactive mode + const noFlagsProvided = !body && !bodyFile && !health + if (noFlagsProvided && Deno.stdout.isTerminal()) { + useInteractive = true + } + + // Interactive mode + if (useInteractive) { + const result = await promptInteractiveCreate(initiativeName) + + await createInitiativeUpdate(client, { + initiativeId: resolvedId, + body: result.body, + health: result.health, + colorEnabled: colorEnabled !== false, + }) + return + } + + // Resolve body content from various sources + let finalBody: string | undefined + + if (body) { + // Content provided inline via --body + finalBody = body + } else if (bodyFile) { + // Content from file via --body-file + try { + finalBody = await Deno.readTextFile(bodyFile) + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + console.error(`File not found: ${bodyFile}`) + } else { + console.error( + "Failed to read body file:", + error instanceof Error ? error.message : String(error), + ) + } + Deno.exit(1) + } + } else if (!Deno.stdin.isTerminal()) { + // Try reading from stdin if piped + const stdinContent = await readContentFromStdin() + if (stdinContent) { + finalBody = stdinContent + } + } else if (Deno.stdout.isTerminal()) { + // No content provided, open editor + console.log("Opening editor for status update content...") + finalBody = await openEditor() + if (!finalBody) { + console.log("No content entered.") + } + } + + // Validate health value if provided + let validatedHealth: HealthValue | undefined + if (health) { + if (!HEALTH_VALUES.includes(health as HealthValue)) { + console.error( + `Invalid health value: ${health}. Valid values: ${ + HEALTH_VALUES.join(", ") + }`, + ) + Deno.exit(1) + } + validatedHealth = health as HealthValue + } + + await createInitiativeUpdate(client, { + initiativeId: resolvedId, + body: finalBody, + health: validatedHealth, + colorEnabled: colorEnabled !== false, + }) + }, + ) + +async function promptInteractiveCreate(initiativeName: string): Promise<{ + body?: string + health?: HealthValue +}> { + console.log(`\nCreating status update for: ${initiativeName}\n`) + + // Prompt for health status + const healthChoice = await Select.prompt({ + message: "Health status", + options: [ + { name: "Skip (no change)", value: "skip" }, + { name: "On Track", value: "onTrack" }, + { name: "At Risk", value: "atRisk" }, + { name: "Off Track", value: "offTrack" }, + ], + default: "skip", + }) + + const health = healthChoice === "skip" + ? undefined + : (healthChoice as HealthValue) + + // Prompt for body entry method + const editorName = await getEditor() + const editorDisplayName = editorName ? editorName.split("/").pop() : null + + const contentMethod = await Select.prompt({ + message: "How would you like to enter the update content?", + options: [ + { name: "Skip (no content)", value: "skip" }, + { name: "Enter inline", value: "inline" }, + ...(editorDisplayName + ? [{ name: `Open ${editorDisplayName}`, value: "editor" }] + : []), + { name: "Read from file", value: "file" }, + ], + default: "skip", + }) + + let body: string | undefined + + if (contentMethod === "inline") { + const inlineContent = await Input.prompt({ + message: "Content (markdown)", + default: "", + }) + body = inlineContent.trim() || undefined + } else if (contentMethod === "editor" && editorDisplayName) { + console.log(`Opening ${editorDisplayName}...`) + body = await openEditor() + if (body) { + console.log(`Content entered (${body.length} characters)`) + } + } else if (contentMethod === "file") { + const filePath = await Input.prompt({ + message: "File path", + }) + try { + body = await Deno.readTextFile(filePath) + } catch (error) { + console.error( + "Failed to read file:", + error instanceof Error ? error.message : String(error), + ) + } + } + + return { body, health } +} + +async function createInitiativeUpdate( + // deno-lint-ignore no-explicit-any + client: any, + options: { + initiativeId: string + body?: string + health?: HealthValue + colorEnabled: boolean + }, +): Promise { + const { initiativeId, body, health, colorEnabled } = options + + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = colorEnabled && Deno.stdout.isTerminal() + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + const createMutation = gql(` + mutation CreateInitiativeUpdate($input: InitiativeUpdateCreateInput!) { + initiativeUpdateCreate(input: $input) { + success + initiativeUpdate { + id + body + health + url + createdAt + initiative { + name + slugId + } + } + } + } + `) + + // Build input - only include fields that are provided + // deno-lint-ignore no-explicit-any + const input: Record = { + initiativeId, + } + + if (body != null) { + input.body = body + } + + if (health != null) { + input.health = health + } + + try { + const result = await client.request(createMutation, { input }) + + spinner?.stop() + + if (!result.initiativeUpdateCreate.success) { + console.error("Failed to create initiative status update") + Deno.exit(1) + } + + const update = result.initiativeUpdateCreate.initiativeUpdate + if (!update) { + console.error("Initiative update creation failed - no update returned") + Deno.exit(1) + } + + const initiativeName = update.initiative?.name || "Unknown" + console.log(`Created status update for: ${initiativeName}`) + if (update.health) { + console.log(`Health: ${update.health}`) + } + if (update.url) { + console.log(update.url) + } + } catch (error) { + spinner?.stop() + console.error("Failed to create initiative status update:", error) + Deno.exit(1) + } +} diff --git a/src/commands/initiative-update/initiative-update-list.ts b/src/commands/initiative-update/initiative-update-list.ts new file mode 100644 index 0000000..6039cff --- /dev/null +++ b/src/commands/initiative-update/initiative-update-list.ts @@ -0,0 +1,265 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { + formatRelativeTime, + padDisplay, + truncateText, +} from "../../utils/display.ts" + +/** + * 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 GetInitiativeBySlugForListUpdates($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 GetInitiativeByNameForListUpdates($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 +} + +// Health display colors +const HEALTH_COLORS: Record = { + onTrack: "#27AE60", + atRisk: "#F2994A", + offTrack: "#EB5757", +} + +const HEALTH_DISPLAY: Record = { + onTrack: "On Track", + atRisk: "At Risk", + offTrack: "Off Track", +} + +export const listCommand = new Command() + .name("list") + .description("List status updates for an initiative") + .alias("l") + .arguments("") + .option("-j, --json", "Output as JSON") + .option("--limit ", "Limit results", { default: 10 }) + .action(async ({ json, limit }, initiativeId) => { + const { Spinner } = await import("@std/cli/unstable-spinner") + const showSpinner = Deno.stdout.isTerminal() && !json + const spinner = showSpinner ? new Spinner() : null + spinner?.start() + + try { + const client = getGraphQLClient() + + // Resolve initiative ID + const resolvedId = await resolveInitiativeId(client, initiativeId) + if (!resolvedId) { + spinner?.stop() + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + const listQuery = gql(` + query ListInitiativeUpdates($id: String!, $first: Int) { + initiative(id: $id) { + name + slugId + initiativeUpdates(first: $first) { + nodes { + id + body + health + url + createdAt + user { + name + } + } + } + } + } + `) + + const result = await client.request(listQuery, { + id: resolvedId, + first: limit, + }) + + spinner?.stop() + + const initiative = result.initiative + if (!initiative) { + console.error(`Initiative not found: ${initiativeId}`) + Deno.exit(1) + } + + const updates = initiative.initiativeUpdates?.nodes || [] + + // JSON output + if (json) { + const jsonOutput = { + initiative: { + name: initiative.name, + slugId: initiative.slugId, + }, + updates: updates.map((update) => ({ + id: update.id, + body: update.body, + health: update.health, + url: update.url, + createdAt: update.createdAt, + author: update.user?.name || null, + })), + } + console.log(JSON.stringify(jsonOutput, null, 2)) + return + } + + if (updates.length === 0) { + console.log(`No status updates found for: ${initiative.name}`) + return + } + + console.log(`Status updates for: ${initiative.name}\n`) + + // Calculate column widths + const { columns } = Deno.stdout.isTerminal() + ? Deno.consoleSize() + : { columns: 120 } + + // ID column - show first 8 chars of UUID + const ID_WIDTH = 8 + + // Health column + const HEALTH_WIDTH = Math.max( + 6, + ...updates.map((u) => + u.health ? (HEALTH_DISPLAY[u.health] || u.health).length : 1 + ), + ) + + // Date column + const DATE_WIDTH = Math.max( + 4, + ...updates.map((u) => formatRelativeTime(u.createdAt).length), + ) + + // Author column + const AUTHOR_WIDTH = Math.max( + 6, + ...updates.map((u) => (u.user?.name || "-").length), + ) + + const SPACE_WIDTH = 4 // spaces between columns + const fixed = ID_WIDTH + HEALTH_WIDTH + DATE_WIDTH + AUTHOR_WIDTH + + SPACE_WIDTH + const PADDING = 1 + const availableWidth = Math.max(columns - PADDING - fixed, 10) + + // Print header + const headerCells = [ + padDisplay("ID", ID_WIDTH), + padDisplay("HEALTH", HEALTH_WIDTH), + padDisplay("DATE", DATE_WIDTH), + padDisplay("AUTHOR", AUTHOR_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 update + for (const update of updates) { + const shortId = update.id.slice(0, 8) + const healthDisplay = update.health + ? (HEALTH_DISPLAY[update.health] || update.health) + : "-" + const healthColor = update.health + ? (HEALTH_COLORS[update.health] || "#6B6F76") + : "#6B6F76" + const date = formatRelativeTime(update.createdAt) + const author = update.user?.name || "-" + + console.log( + `${padDisplay(shortId, ID_WIDTH)} %c${ + padDisplay(healthDisplay, HEALTH_WIDTH) + }%c %c${padDisplay(date, DATE_WIDTH)}%c ${ + padDisplay(author, AUTHOR_WIDTH) + }`, + `color: ${healthColor}`, + "", + "color: gray", + "", + ) + + // Print body preview if available (indented, on next line) + if (update.body) { + const bodyPreview = truncateText( + update.body.replace(/\n/g, " ").trim(), + availableWidth, + ) + console.log(` %c${bodyPreview}%c`, "color: gray", "") + } + } + } catch (error) { + spinner?.stop() + console.error("Failed to fetch initiative updates:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/initiative-update/initiative-update.ts b/src/commands/initiative-update/initiative-update.ts new file mode 100644 index 0000000..0600550 --- /dev/null +++ b/src/commands/initiative-update/initiative-update.ts @@ -0,0 +1,14 @@ +import { Command } from "@cliffy/command" + +import { createCommand } from "./initiative-update-create.ts" +import { listCommand } from "./initiative-update-list.ts" + +export const initiativeUpdateCommand = new Command() + .name("initiative-update") + .description("Manage initiative status updates (timeline posts)") + .action(function () { + this.showHelp() + }) + .command("create", createCommand) + .command("list", listCommand) + .alias("ls") diff --git a/src/commands/initiative/initiative-add-project.ts b/src/commands/initiative/initiative-add-project.ts new file mode 100644 index 0000000..939b59d --- /dev/null +++ b/src/commands/initiative/initiative-add-project.ts @@ -0,0 +1,244 @@ +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 = { + initiativeId: initiative.id, + projectId: project.id, + ...(sortOrder !== undefined && { 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..c96bf93 --- /dev/null +++ b/src/commands/initiative/initiative-create.ts @@ -0,0 +1,257 @@ +import { Command } from "@cliffy/command" +import { Input, Select } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import type { InitiativeStatus } from "../../__codegen__/graphql.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() + const icon = providedIcon + + let name = providedName + let description = providedDescription + let status = providedStatus + let owner = providedOwner + let targetDate = providedTargetDate + let color = providedColor + + // 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 + let ownerId: string | undefined + if (owner) { + ownerId = await lookupUserId(owner) + if (!ownerId) { + console.error(`Owner not found: ${owner}`) + Deno.exit(1) + } + } + + const input = { + name: name as string, + ...(description && { description }), + ...(status && { status: status as InitiativeStatus }), + ...(ownerId && { ownerId }), + ...(targetDate && { targetDate }), + ...(color && { color }), + ...(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..a3392cb --- /dev/null +++ b/src/commands/initiative/initiative-delete.ts @@ -0,0 +1,357 @@ +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..09306e7 --- /dev/null +++ b/src/commands/initiative/initiative-list.ts @@ -0,0 +1,313 @@ +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 + const 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..faf4717 --- /dev/null +++ b/src/commands/initiative/initiative-remove-project.ts @@ -0,0 +1,285 @@ +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..b13cae8 --- /dev/null +++ b/src/commands/initiative/initiative-unarchive.ts @@ -0,0 +1,173 @@ +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..7fa9abb --- /dev/null +++ b/src/commands/initiative/initiative-update.ts @@ -0,0 +1,290 @@ +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 ( + options, + initiativeId, + ) => { + // Extract options - use let for variables that may be reassigned in interactive mode + let name = options.name + let description = options.description + let status = options.status + const owner = options.owner + let targetDate = options.targetDate + const color = options.color + const icon = options.icon + const interactive = options.interactive + // color can be a string (hex color) or boolean (from --no-color flag) + let colorHex = typeof color === "string" ? color : undefined + const colorEnabled = color !== false + 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 + const isInteractive = interactive && Deno.stdout.isTerminal() + const noFlagsProvided = !name && + !description && + !status && + !owner && + !targetDate && + !colorHex && + !icon + + if (noFlagsProvided && isInteractive) { + 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..490a187 --- /dev/null +++ b/src/commands/initiative/initiative-view.ts @@ -0,0 +1,340 @@ +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-create.ts b/src/commands/issue/issue-create.ts index 410a436..1293c56 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -469,8 +469,9 @@ export const createCommand = new Command() "Description of the issue", ) .option( - "-l, --label [label...:string]", + "-l, --label ", "Issue label associated with the issue. May be repeated.", + { collect: true }, ) .option( "--team ", @@ -516,8 +517,7 @@ export const createCommand = new Command() // If no flags are provided (or only parent is provided), use interactive mode const noFlagsProvided = !title && !assignee && !dueDate && priority === undefined && estimate === undefined && !description && - (!labels || labels === true || - (Array.isArray(labels) && labels.length === 0)) && + (!labels || labels.length === 0) && !team && !project && !state && !start if (noFlagsProvided && interactive) { @@ -678,7 +678,7 @@ export const createCommand = new Command() } const labelIds = [] - if (labels !== undefined && labels !== true && labels.length > 0) { + if (labels != null && labels.length > 0) { // sequential in case of questions for (const label of labels) { let labelId = await getIssueLabelIdByNameForTeam(label, team) 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/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 20a7de5..4d0bd6b 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -40,8 +40,9 @@ export const updateCommand = new Command() "Description of the issue", ) .option( - "-l, --label [label...:string]", + "-l, --label ", "Issue label associated with the issue. May be repeated.", + { collect: true }, ) .option( "--team ", @@ -135,7 +136,7 @@ export const updateCommand = new Command() } const labelIds = [] - if (labels !== undefined && labels !== true && labels.length > 0) { + if (labels != null && labels.length > 0) { for (const label of labels) { const labelId = await getIssueLabelIdByNameForTeam(label, teamKey) if (!labelId) { diff --git a/src/commands/label/label-create.ts b/src/commands/label/label-create.ts new file mode 100644 index 0000000..0c3c2e5 --- /dev/null +++ b/src/commands/label/label-create.ts @@ -0,0 +1,224 @@ +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 + let teamId: string | undefined + if (teamKey) { + teamId = await getTeamIdByKey(teamKey.toUpperCase()) + if (!teamId) { + console.error(`Team not found: ${teamKey}`) + Deno.exit(1) + } + } + + const input = { + name, + color, + ...(description && { description }), + ...(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