diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 129f28a5c..c4528299e 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -315,8 +315,7 @@ jobs: ### Homebrew (Recommended) ```bash - brew tap humanlayer/humanlayer - brew install --cask codelayer${{ steps.version.outputs.is_nightly == 'true' && '-nightly' || '' }} + brew install --cask --no-quarantine humanlayer/humanlayer/codelayer${{ steps.version.outputs.is_nightly == 'true' && '-nightly' || '' }} ``` ### Manual Installation diff --git a/hack/linear/linear-cli.ts b/hack/linear/linear-cli.ts index 5bece2856..864a7d5b6 100755 --- a/hack/linear/linear-cli.ts +++ b/hack/linear/linear-cli.ts @@ -112,8 +112,8 @@ function getGitBranch(): string { } function extractIssueId(branchName: string): string | null { - // Match patterns like ENG-123, eng-123, etc. - const match = branchName.match(/[A-Za-z]+-\d+/); + // Match patterns like ENG-123, eng-123, B2BPROD-206, etc. + const match = branchName.match(/[A-Za-z0-9]+-\d+/); return match ? match[0].toUpperCase() : null; } @@ -121,10 +121,10 @@ async function getIssueIdInteractively(defaultId: string | null = null): Promise const { issueId } = await inquirer.prompt({ type: "input", name: "issueId", - message: "Enter Linear issue ID (e.g. ENG-123):", + message: "Enter Linear issue ID (e.g. ENG-123, B2BPROD-206):", default: defaultId, validate: (input) => { - return /^[A-Za-z]+-\d+$/i.test(input) ? true : "Please enter a valid issue ID (e.g. ENG-123)"; + return /^[A-Za-z0-9]+-\d+$/i.test(input) ? true : "Please enter a valid issue ID (e.g. ENG-123, B2BPROD-206)"; }, }); @@ -133,7 +133,7 @@ async function getIssueIdInteractively(defaultId: string | null = null): Promise async function resolveIssueId(providedId?: string): Promise { // If ID is provided as argument, use it - if (providedId && /^[A-Za-z]+-\d+$/i.test(providedId)) { + if (providedId && /^[A-Za-z0-9]+-\d+$/i.test(providedId)) { return providedId.toUpperCase(); } @@ -206,70 +206,102 @@ async function getIssue(issueId?: string) { if (!linear) { throw new Error("Linear client not initialized. Check your API key."); } - + const resolvedId = await resolveIssueId(issueId); const issue = await linear.issue(resolvedId); - + if (!issue) { console.error(chalk.red(`Issue ${resolvedId} not found.`)); process.exit(1); } - - const comments = await issue.comments(); - const assignee = await issue.assignee; - const state = await issue.state; - - // Format issue details with branch name in header - console.log(chalk.bold(`\n[${issue.identifier}] ${issue.title}`)); - if (issue.branchName) { - console.log(chalk.dim(`Branch: ${issue.branchName}`)); - } - console.log(chalk.dim(`Status: ${state?.name || "Unknown"}`)); - - if (assignee) { - console.log(chalk.dim(`Assignee: ${assignee.name}`)); - } - - if (issue.description) { - console.log(chalk.bold("\nDescription:")); - console.log(issue.description); + + // Display main issue + await displayIssue(issue); + + // Fetch and display parent issues + const parentIssues = []; + let currentIssue = issue; + + while (true) { + const parent = await currentIssue.parent; + if (!parent) break; + + parentIssues.push(parent); + currentIssue = parent; } - - // Format comments - if (comments.nodes.length > 0) { - console.log(chalk.bold("\nComments:")); - - // Reverse the comments array to show oldest first - const reversedComments = [...comments.nodes].reverse(); - - for (const comment of reversedComments) { - let commentUser; - try { - commentUser = await comment.user; - } catch (error) { - // Handle case where user is null (bot comments, deleted users, etc.) - commentUser = null; - } - const commentDate = new Date(comment.createdAt); - const dateStr = commentDate.toISOString().split("T")[0]; - const timeStr = commentDate.toTimeString().split(" ")[0]; // HH:MM:SS format + // Display parent issues in reverse order (oldest parent first) + if (parentIssues.length > 0) { + console.log(chalk.bold.yellow("\n═══════════════════════════════════════════════════════════════")); + console.log(chalk.bold.yellow("PARENT ISSUES:")); + console.log(chalk.bold.yellow("═══════════════════════════════════════════════════════════════")); - console.log(chalk.dim(`[${dateStr} ${timeStr}] ${commentUser?.name || "Unknown"}:`)); - console.log(comment.body); - console.log(); // Empty line between comments + for (let i = parentIssues.length - 1; i >= 0; i--) { + const levelPrefix = " ".repeat(parentIssues.length - i - 1) + "↳ "; + console.log(chalk.yellow(`\n${levelPrefix}Parent Level ${parentIssues.length - i}:`)); + await displayIssue(parentIssues[i], false); } - } else { - console.log(chalk.dim("\nNo comments on this issue.")); } - - console.log(chalk.dim(`\nView in Linear: ${issue.url}`)); } catch (error) { console.error(chalk.red("Error fetching issue:"), error instanceof Error ? error.message : String(error)); process.exit(1); } } +async function displayIssue(issue: any, showLinearUrl: boolean = true) { + const comments = await issue.comments(); + const assignee = await issue.assignee; + const state = await issue.state; + + // Format issue details with branch name in header + console.log(chalk.bold(`\n[${issue.identifier}] ${issue.title}`)); + if (issue.branchName) { + console.log(chalk.dim(`Branch: ${issue.branchName}`)); + } + console.log(chalk.dim(`Status: ${state?.name || "Unknown"}`)); + + if (assignee) { + console.log(chalk.dim(`Assignee: ${assignee.name}`)); + } + + if (issue.description) { + console.log(chalk.bold("\nDescription:")); + console.log(issue.description); + } + + // Format comments + if (comments.nodes.length > 0) { + console.log(chalk.bold("\nComments:")); + + // Reverse the comments array to show oldest first + const reversedComments = [...comments.nodes].reverse(); + + for (const comment of reversedComments) { + let commentUser; + try { + commentUser = await comment.user; + } catch (error) { + // Handle case where user is null (bot comments, deleted users, etc.) + commentUser = null; + } + + const commentDate = new Date(comment.createdAt); + const dateStr = commentDate.toISOString().split("T")[0]; + const timeStr = commentDate.toTimeString().split(" ")[0]; // HH:MM:SS format + + console.log(chalk.dim(`[${dateStr} ${timeStr}] ${commentUser?.name || "Unknown"}:`)); + console.log(comment.body); + console.log(); // Empty line between comments + } + } else { + console.log(chalk.dim("\nNo comments on this issue.")); + } + + if (showLinearUrl) { + console.log(chalk.dim(`\nView in Linear: ${issue.url}`)); + } +} + async function addComment(message: string, options: { issueId?: string }) { try { if (!linear) { @@ -311,8 +343,8 @@ async function updateStatus(issueId: string, statusName: string): Promise } // Validate issue ID format - if (!issueId || !/^[A-Za-z]+-\d+$/i.test(issueId)) { - console.error(chalk.red("Error: Invalid issue ID format. Expected format: ENG-123")); + if (!issueId || !/^[A-Za-z0-9]+-\d+$/i.test(issueId)) { + console.error(chalk.red("Error: Invalid issue ID format. Expected format: ENG-123 or B2BPROD-206")); process.exit(1); } @@ -373,8 +405,8 @@ async function addLink(issueId: string, url: string, options: { title?: string } } // Validate issue ID format - if (!issueId || !/^[A-Za-z]+-\d+$/i.test(issueId)) { - console.error(chalk.red("Error: Invalid issue ID format. Expected format: ENG-123")); + if (!issueId || !/^[A-Za-z0-9]+-\d+$/i.test(issueId)) { + console.error(chalk.red("Error: Invalid issue ID format. Expected format: ENG-123 or B2BPROD-206")); process.exit(1); } @@ -424,6 +456,51 @@ async function addLink(issueId: string, url: string, options: { title?: string } } } +async function assignToMe(issueId?: string): Promise { + try { + if (!linear) { + throw new Error("Linear client not initialized. Check your API key."); + } + + // Resolve issue ID (from argument, git branch, or interactive prompt) + const resolvedId = await resolveIssueId(issueId); + + // Get the current authenticated user + const viewer = await linear.viewer; + + if (!viewer) { + console.error(chalk.red("Error: Could not get authenticated user information.")); + process.exit(1); + } + + // Update the issue with the authenticated user as assignee + const result = await linear.issueUpdate(resolvedId, { + assigneeId: viewer.id + }); + + if (result.success) { + console.log(chalk.green(`✓ Assigned ${resolvedId} to ${viewer.name || viewer.email}`)); + + // Fetch and display the updated issue details + const updatedIssue = await linear.issue(resolvedId); + if (updatedIssue) { + const state = await updatedIssue.state; + console.log(chalk.dim(` Status: ${state?.name || "Unknown"}`)); + console.log(chalk.dim(` Title: ${updatedIssue.title}`)); + if (updatedIssue.url) { + console.log(chalk.dim(` View in Linear: ${updatedIssue.url}`)); + } + } + } else { + console.error(chalk.red(`Failed to assign ${resolvedId} to you.`)); + process.exit(1); + } + } catch (error) { + console.error(chalk.red("Error assigning issue:"), error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + async function fetchImages(issueId: string): Promise { try { // Re-initialize Linear client with signed URL headers to get JWT-signed URLs @@ -435,8 +512,8 @@ async function fetchImages(issueId: string): Promise { }); // Validate issue ID format - if (!issueId || !/^[A-Za-z]+-\d+$/i.test(issueId)) { - console.error(chalk.red("Error: Invalid issue ID format. Expected format: ENG-123")); + if (!issueId || !/^[A-Za-z0-9]+-\d+$/i.test(issueId)) { + console.error(chalk.red("Error: Invalid issue ID format. Expected format: ENG-123 or B2BPROD-206")); process.exit(1); } @@ -530,8 +607,8 @@ async function getIssueV2(issueId: string, options: { } // Validate issue ID format - if (!issueId || !/^[A-Za-z]+-\d+$/i.test(issueId)) { - console.error(chalk.red("Error: Invalid issue ID format. Expected format: ENG-123")); + if (!issueId || !/^[A-Za-z0-9]+-\d+$/i.test(issueId)) { + console.error(chalk.red("Error: Invalid issue ID format. Expected format: ENG-123 or B2BPROD-206")); process.exit(1); } @@ -596,6 +673,62 @@ async function getIssueV2(issueId: string, options: { } } + // Fetch parent issues if requested + if (requestedFields.includes("parents")) { + const parentIssues = []; + let currentIssue = issue; + + while (true) { + const parent = await currentIssue.parent; + if (!parent) break; + + const parentData: any = { + identifier: parent.identifier, + title: parent.title + }; + + // Fetch additional parent fields if needed + if (requestedFields.includes("description")) { + parentData.description = parent.description || null; + } + + if (requestedFields.includes("assignee")) { + const parentAssignee = await parent.assignee; + parentData.assignee = parentAssignee?.name || null; + } + + if (requestedFields.includes("comments")) { + const parentComments = await parent.comments(); + parentData.comments = []; + + for (const comment of parentComments.nodes) { + let commentUser; + try { + commentUser = await comment.user; + } catch (error) { + commentUser = null; + } + + parentData.comments.push({ + author: commentUser?.name || "Unknown", + body: comment.body, + createdAt: comment.createdAt + }); + } + } + + const parentState = await parent.state; + parentData._state = parentState?.name || "Unknown"; + + parentIssues.push(parentData); + currentIssue = parent; + } + + if (parentIssues.length > 0) { + issueData.parents = parentIssues.reverse(); // Reverse to show oldest parent first + } + } + // Always fetch state and estimate for markdown format const state = await issue.state; const assignee = await issue.assignee; @@ -656,6 +789,49 @@ async function getIssueV2(issueId: string, options: { console.log(""); } } + + // Display parent issues if requested + if (requestedFields.includes("parents") && issueData.parents && issueData.parents.length > 0) { + console.log(chalk.bold.yellow("\n═══════════════════════════════════════════════════════════════")); + console.log(chalk.bold.yellow("PARENT ISSUES:")); + console.log(chalk.bold.yellow("═══════════════════════════════════════════════════════════════")); + + for (let i = 0; i < issueData.parents.length; i++) { + const parent = issueData.parents[i]; + const levelPrefix = " ".repeat(i) + "↳ "; + console.log(chalk.yellow(`\n${levelPrefix}Parent Level ${i + 1}:`)); + + console.log(chalk.bold(`\n${levelPrefix}[${parent.identifier}] ${parent.title}`)); + console.log(chalk.dim(`${levelPrefix}Status: ${parent._state}`)); + + if (requestedFields.includes("assignee") && parent.assignee) { + console.log(chalk.dim(`${levelPrefix}Assignee: ${parent.assignee}`)); + } + + if (requestedFields.includes("description") && parent.description) { + console.log(chalk.bold(`\n${levelPrefix}Description:`)); + const descLines = parent.description.split('\n'); + for (const line of descLines) { + console.log(`${levelPrefix}${line}`); + } + } + + if (requestedFields.includes("comments") && parent.comments && parent.comments.length > 0) { + console.log(chalk.bold(`\n${levelPrefix}Comments:`)); + for (const comment of parent.comments) { + const commentDate = new Date(comment.createdAt); + const dateStr = commentDate.toISOString().split("T")[0]; + const timeStr = commentDate.toTimeString().split(" ")[0]; + console.log(chalk.dim(`${levelPrefix}[${dateStr} ${timeStr}] ${comment.author}:`)); + const commentLines = comment.body.split('\n'); + for (const line of commentLines) { + console.log(`${levelPrefix}${line}`); + } + console.log(""); + } + } + } + } } } catch (error) { console.error(chalk.red("Error fetching issue:"), error instanceof Error ? error.message : String(error)); @@ -950,7 +1126,7 @@ program .command("get-issue-v2 ") .description("Get a single issue with the same output format and field options as list-issues") .option("--output-format ", "Output format: markdown, json (compact), or rich-json (pretty)", "json") - .option("--fields ", "Comma-separated fields to include: identifier,title,branch,assignee,description,comments", "identifier,title,branch") + .option("--fields ", "Comma-separated fields to include: identifier,title,branch,assignee,description,comments,parents", "identifier,title,branch") .action(getIssueV2); program @@ -975,6 +1151,11 @@ program .option("-t, --title ", "Optional title for the link (defaults to URL)") .action(addLink); +program + .command("assign-to-me [id]") + .description("Assign an issue to yourself (ID optional if in git branch)") + .action(assignToMe); + program .command("list-issues") .description("List and filter issues with advanced options") @@ -996,7 +1177,7 @@ program .option("--zsh", "Generate Zsh completion script") .option("--fish", "Generate Fish completion script") .action((options) => { - const commands = ["my-issues", "list-issues", "get-issue", "get-issue-v2", "add-comment", "fetch-images", "update-status", "add-link", "completion", "help"]; + const commands = ["my-issues", "list-issues", "get-issue", "get-issue-v2", "add-comment", "fetch-images", "update-status", "add-link", "assign-to-me", "completion", "help"]; if (options.bash) { // Basic bash completion @@ -1035,6 +1216,7 @@ _linear() { 'fetch-images:Download all images from an issue' 'update-status:Update the status of a Linear issue' 'add-link:Add a link/attachment to a Linear issue' + 'assign-to-me:Assign an issue to yourself' 'completion:Generate shell completion script' 'help:Display help for command' ) @@ -1089,6 +1271,7 @@ complete -c linear -n "__fish_use_subcommand" -a "add-comment" -d "Add a comment complete -c linear -n "__fish_use_subcommand" -a "fetch-images" -d "Download all images from an issue" complete -c linear -n "__fish_use_subcommand" -a "update-status" -d "Update the status of a Linear issue" complete -c linear -n "__fish_use_subcommand" -a "add-link" -d "Add a link/attachment to a Linear issue" +complete -c linear -n "__fish_use_subcommand" -a "assign-to-me" -d "Assign an issue to yourself" complete -c linear -n "__fish_use_subcommand" -a "completion" -d "Generate shell completion script" complete -c linear -n "__fish_use_subcommand" -a "help" -d "Display help for command"