From fa08530500607f1330b3e9cf6e3d385525c2748c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:41:43 -0700 Subject: [PATCH 1/2] Add issue comment subcommand with --issue flag and branch inference (#2) * Initial plan * Add issue comment subcommand with --issue flag and branch inference Co-authored-by: hoodiecollin <5888427+hoodiecollin@users.noreply.github.com> * Refactor tests to use helper functions from test-helpers.ts Co-authored-by: hoodiecollin <5888427+hoodiecollin@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: hoodiecollin <5888427+hoodiecollin@users.noreply.github.com> --- src/commands/issue/comment.ts | 54 ++++++++++ src/commands/issue/issue.ts | 2 + test/commands/issue/issue-comment.test.ts | 123 ++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/commands/issue/comment.ts create mode 100644 test/commands/issue/issue-comment.test.ts diff --git a/src/commands/issue/comment.ts b/src/commands/issue/comment.ts new file mode 100644 index 0000000..3e51e2f --- /dev/null +++ b/src/commands/issue/comment.ts @@ -0,0 +1,54 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getIssueIdentifier } from "../../utils/linear.ts" + +export const commentCommand = new Command() + .description("Add a comment to an issue.") + .option("-i, --issue ", "The ID of the issue to comment on (e.g., PROJ-123).") + .arguments("") + .action(async (options, comment) => { + const client = getGraphQLClient() + let issueId: string | undefined = options.issue + + // If no issue ID was provided via the flag, try to get it from the branch + if (!issueId) { + issueId = await getIssueIdentifier() + } + + // If we still don't have an ID, exit with an error + if (!issueId) { + console.error( + "Error: Could not determine issue ID. Please provide it using the --issue flag or run this command from a branch with the ID in its name (e.g., feature/PROJ-123-my-feature)." + ) + Deno.exit(1) + } + + const mutation = gql(/* GraphQL */ ` + mutation CommentCreate($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + comment { + id + } + } + } + `) + + try { + const result = await client.request(mutation, { + issueId, + body: comment, + }) + + if (result.commentCreate.success) { + console.log(`Comment added successfully to issue ${issueId}.`) + } else { + console.error("Failed to add comment.") + Deno.exit(1) + } + } catch (error) { + console.error("Failed to add comment:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/issue/issue.ts b/src/commands/issue/issue.ts index d2ca171..b1d67e2 100644 --- a/src/commands/issue/issue.ts +++ b/src/commands/issue/issue.ts @@ -9,6 +9,7 @@ import { urlCommand } from "./issue-url.ts" import { deleteCommand } from "./issue-delete.ts" import { pullRequestCommand } from "./issue-pull-request.ts" import { updateCommand } from "./issue-update.ts" +import { commentCommand } from "./comment.ts" export const issueCommand = new Command() .description("Manage Linear issues") @@ -25,3 +26,4 @@ export const issueCommand = new Command() .command("delete", deleteCommand) .command("create", createCommand) .command("update", updateCommand) + .command("comment", commentCommand) diff --git a/test/commands/issue/issue-comment.test.ts b/test/commands/issue/issue-comment.test.ts new file mode 100644 index 0000000..c88dee6 --- /dev/null +++ b/test/commands/issue/issue-comment.test.ts @@ -0,0 +1,123 @@ +import { snapshotTest } from "@cliffy/testing" +import { commentCommand } from "../../../src/commands/issue/comment.ts" +import { commonDenoArgs, setupMockLinearServer } from "../../utils/test-helpers.ts" + +// Test help output +await snapshotTest({ + name: "Issue Comment Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await commentCommand.parse() + }, +}) + +// Test with explicit issue ID flag +await snapshotTest({ + name: "Issue Comment Command - With Issue Flag", + meta: import.meta, + colors: false, + args: ["--issue", "TEST-123", "This is a test comment."], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "CommentCreate", + variables: { + issueId: "TEST-123", + body: "This is a test comment.", + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-123", + }, + }, + }, + }, + }, + ]) + + try { + await commentCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// Test with short flag +await snapshotTest({ + name: "Issue Comment Command - With Short Flag", + meta: import.meta, + colors: false, + args: ["-i", "PROJ-456", "Another comment with short flag."], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "CommentCreate", + variables: { + issueId: "PROJ-456", + body: "Another comment with short flag.", + }, + response: { + data: { + commentCreate: { + success: true, + comment: { + id: "comment-456", + }, + }, + }, + }, + }, + ]) + + try { + await commentCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// Test comment failure +await snapshotTest({ + name: "Issue Comment Command - Comment Creation Failed", + meta: import.meta, + colors: false, + args: ["--issue", "TEST-999", "This comment will fail."], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "CommentCreate", + variables: { + issueId: "TEST-999", + body: "This comment will fail.", + }, + response: { + data: { + commentCreate: { + success: false, + comment: null, + }, + }, + }, + }, + ]) + + try { + await commentCommand.parse() + } catch (_error) { + // Expected to exit with error due to failed comment creation + } finally { + await cleanup() + } + }, +}) From 5ef9a5fba83ed669ab703e95025fff9abdff4713 Mon Sep 17 00:00:00 2001 From: Collin Kokotas Date: Tue, 11 Nov 2025 13:48:33 -0800 Subject: [PATCH 2/2] Add triage actions and milestone support - Add issue accept, decline, and snooze commands - Add milestone option to issue create and update - Add helper functions for team states and project milestones --- src/commands/issue/issue-accept.ts | 104 ++++++++++++++++++++++++++ src/commands/issue/issue-create.ts | 41 +++++++++++ src/commands/issue/issue-decline.ts | 104 ++++++++++++++++++++++++++ src/commands/issue/issue-snooze.ts | 110 ++++++++++++++++++++++++++++ src/commands/issue/issue-update.ts | 29 ++++++++ src/commands/issue/issue.ts | 6 ++ src/utils/linear.ts | 105 ++++++++++++++++++++++++++ 7 files changed, 499 insertions(+) create mode 100644 src/commands/issue/issue-accept.ts create mode 100644 src/commands/issue/issue-decline.ts create mode 100644 src/commands/issue/issue-snooze.ts diff --git a/src/commands/issue/issue-accept.ts b/src/commands/issue/issue-accept.ts new file mode 100644 index 0000000..565e8a6 --- /dev/null +++ b/src/commands/issue/issue-accept.ts @@ -0,0 +1,104 @@ +import { Command } from "@cliffy/command" +import { Input } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { + getDefaultIssueState, + getIssueIdentifier, + getIssueTeamKey, +} from "../../utils/linear.ts" + +export const acceptCommand = new Command() + .name("accept") + .description("Accept an issue from triage") + .arguments("") + .option( + "-c, --comment ", + "Add a comment when accepting the issue", + ) + .action(async ({ comment }, issueIdArg) => { + try { + // Get the issue ID + const issueId = await getIssueIdentifier(issueIdArg) + if (!issueId) { + console.error( + "Could not determine issue ID. Please provide an issue ID like 'ENG-123'", + ) + Deno.exit(1) + } + + // Get the team key from the issue + const teamKey = await getIssueTeamKey(issueId) + if (!teamKey) { + console.error(`Could not determine team for issue ${issueId}`) + Deno.exit(1) + } + + // Get the team's default issue state + const defaultState = await getDefaultIssueState(teamKey) + + // Prompt for comment if not provided and user wants to add one + let finalComment = comment + if (!finalComment) { + const addComment = await Input.prompt({ + message: "Add a comment? (leave blank to skip)", + default: "", + }) + if (addComment.trim()) { + finalComment = addComment.trim() + } + } + + // Update the issue state + const updateMutation = gql(` + mutation AcceptIssue($issueId: String!, $stateId: String!) { + issueUpdate(id: $issueId, input: { stateId: $stateId }) { + success + issue { + id + identifier + title + } + } + } + `) + + const client = getGraphQLClient() + const result = await client.request(updateMutation, { + issueId, + stateId: defaultState.id, + }) + + if (!result.issueUpdate.success) { + throw new Error("Failed to accept issue") + } + + // Add comment if provided + if (finalComment) { + const commentMutation = gql(` + mutation AddComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + } + } + `) + + await client.request(commentMutation, { + issueId, + body: finalComment, + }) + } + + const issue = result.issueUpdate.issue + console.log( + `✓ Accepted issue ${issue?.identifier}: ${issue?.title}`, + ) + console.log(` Moved to state: ${defaultState.name}`) + if (finalComment) { + console.log(` Comment added`) + } + } catch (error) { + console.error("✗ Failed to accept issue:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index 410a436..977f1d6 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -13,6 +13,8 @@ import { getIssueLabelOptionsByNameForTeam, getLabelsForTeam, getProjectIdByName, + getProjectMilestoneIdByNameForProject, + getProjectMilestoneOptionsByNameForProject, getProjectOptionsByName, getTeamIdByKey, getTeamKey, @@ -480,6 +482,10 @@ export const createCommand = new Command() "--project ", "Name of the project with the issue", ) + .option( + "--milestone ", + "Name of the project milestone for the issue", + ) .option( "-s, --state ", "Workflow state for the issue (by name or type)", @@ -505,6 +511,7 @@ export const createCommand = new Command() label: labels, team, project, + milestone, state, color, interactive, @@ -715,6 +722,39 @@ export const createCommand = new Command() } } + let projectMilestoneId: string | undefined = undefined + if (milestone !== undefined) { + if (projectId === undefined) { + console.error( + "Cannot set milestone without a project. Use --project to specify the project.", + ) + Deno.exit(1) + } + projectMilestoneId = await getProjectMilestoneIdByNameForProject( + milestone, + projectId, + ) + if (projectMilestoneId === undefined && interactive) { + const milestoneIds = await getProjectMilestoneOptionsByNameForProject( + milestone, + projectId, + ) + spinner?.stop() + projectMilestoneId = await selectOption( + "Project milestone", + milestone, + milestoneIds, + ) + spinner?.start() + } + if (projectMilestoneId === undefined) { + console.error( + `Could not determine ID for project milestone ${milestone}`, + ) + Deno.exit(1) + } + } + // Date validation done at graphql level // Convert parent identifier if provided and fetch parent data @@ -756,6 +796,7 @@ export const createCommand = new Command() labelIds, teamId: teamId, projectId: projectId || parentData?.projectId, + projectMilestoneId, stateId, useDefaultTemplate, description, diff --git a/src/commands/issue/issue-decline.ts b/src/commands/issue/issue-decline.ts new file mode 100644 index 0000000..958f52f --- /dev/null +++ b/src/commands/issue/issue-decline.ts @@ -0,0 +1,104 @@ +import { Command } from "@cliffy/command" +import { Input } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { + getCanceledState, + getIssueIdentifier, + getIssueTeamKey, +} from "../../utils/linear.ts" + +export const declineCommand = new Command() + .name("decline") + .description("Decline an issue from triage") + .arguments("") + .option( + "-c, --comment ", + "Add a comment explaining why the issue is declined", + ) + .action(async ({ comment }, issueIdArg) => { + try { + // Get the issue ID + const issueId = await getIssueIdentifier(issueIdArg) + if (!issueId) { + console.error( + "Could not determine issue ID. Please provide an issue ID like 'ENG-123'", + ) + Deno.exit(1) + } + + // Get the team key from the issue + const teamKey = await getIssueTeamKey(issueId) + if (!teamKey) { + console.error(`Could not determine team for issue ${issueId}`) + Deno.exit(1) + } + + // Get the team's canceled state + const canceledState = await getCanceledState(teamKey) + + // Prompt for comment if not provided + let finalComment = comment + if (!finalComment) { + const addComment = await Input.prompt({ + message: "Add a comment explaining why? (leave blank to skip)", + default: "", + }) + if (addComment.trim()) { + finalComment = addComment.trim() + } + } + + // Update the issue state to canceled + const updateMutation = gql(` + mutation DeclineIssue($issueId: String!, $stateId: String!) { + issueUpdate(id: $issueId, input: { stateId: $stateId }) { + success + issue { + id + identifier + title + } + } + } + `) + + const client = getGraphQLClient() + const result = await client.request(updateMutation, { + issueId, + stateId: canceledState.id, + }) + + if (!result.issueUpdate.success) { + throw new Error("Failed to decline issue") + } + + // Add comment if provided + if (finalComment) { + const commentMutation = gql(` + mutation AddComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + } + } + `) + + await client.request(commentMutation, { + issueId, + body: finalComment, + }) + } + + const issue = result.issueUpdate.issue + console.log( + `✓ Declined issue ${issue?.identifier}: ${issue?.title}`, + ) + console.log(` Moved to state: ${canceledState.name}`) + if (finalComment) { + console.log(` Comment added`) + } + } catch (error) { + console.error("✗ Failed to decline issue:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/issue/issue-snooze.ts b/src/commands/issue/issue-snooze.ts new file mode 100644 index 0000000..0b2ddfe --- /dev/null +++ b/src/commands/issue/issue-snooze.ts @@ -0,0 +1,110 @@ +import { Command } from "@cliffy/command" +import { Select } from "@cliffy/prompt" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getIssueIdentifier } from "../../utils/linear.ts" + +function parseDuration(duration: string): Date { + const now = new Date() + const regex = /^(\d+)([hdwm])$/ + const match = duration.match(regex) + + if (!match) { + throw new Error( + "Invalid duration format. Use format like: 1h, 2d, 1w, 1m", + ) + } + + const value = parseInt(match[1]) + const unit = match[2] + + switch (unit) { + case "h": // hours + return new Date(now.getTime() + value * 60 * 60 * 1000) + case "d": // days + return new Date(now.getTime() + value * 24 * 60 * 60 * 1000) + case "w": // weeks + return new Date(now.getTime() + value * 7 * 24 * 60 * 60 * 1000) + case "m": // months (approximate as 30 days) + return new Date(now.getTime() + value * 30 * 24 * 60 * 60 * 1000) + default: + throw new Error(`Unknown duration unit: ${unit}`) + } +} + +export const snoozeCommand = new Command() + .name("snooze") + .description("Snooze an issue in triage") + .arguments(" [duration:string]") + .action(async (_options, issueIdArg, durationArg) => { + try { + // Get the issue ID + const issueId = await getIssueIdentifier(issueIdArg) + if (!issueId) { + console.error( + "Could not determine issue ID. Please provide an issue ID like 'ENG-123'", + ) + Deno.exit(1) + } + + let snoozeUntil: Date + + if (durationArg) { + // Parse the provided duration + snoozeUntil = parseDuration(durationArg) + } else { + // Prompt for duration + const choice = await Select.prompt({ + message: "How long do you want to snooze this issue?", + options: [ + { name: "1 hour", value: "1h" }, + { name: "4 hours", value: "4h" }, + { name: "1 day", value: "1d" }, + { name: "3 days", value: "3d" }, + { name: "1 week", value: "1w" }, + { name: "2 weeks", value: "2w" }, + { name: "1 month", value: "1m" }, + ], + }) + snoozeUntil = parseDuration(choice) + } + + // Update the issue with snoozed until time + const updateMutation = gql(` + mutation SnoozeIssue($issueId: String!, $snoozedUntilAt: DateTime!) { + issueUpdate(id: $issueId, input: { snoozedUntilAt: $snoozedUntilAt }) { + success + issue { + id + identifier + title + snoozedUntilAt + } + } + } + `) + + const client = getGraphQLClient() + const result = await client.request(updateMutation, { + issueId, + snoozedUntilAt: snoozeUntil.toISOString(), + }) + + if (!result.issueUpdate.success) { + throw new Error("Failed to snooze issue") + } + + const issue = result.issueUpdate.issue + console.log( + `✓ Snoozed issue ${issue?.identifier}: ${issue?.title}`, + ) + console.log( + ` Will return on: ${ + new Date(issue?.snoozedUntilAt || "").toLocaleString() + }`, + ) + } catch (error) { + console.error("✗ Failed to snooze issue:", error) + Deno.exit(1) + } + }) diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 20a7de5..306403a 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -6,6 +6,7 @@ import { getIssueIdentifier, getIssueLabelIdByNameForTeam, getProjectIdByName, + getProjectMilestoneIdByNameForProject, getTeamIdByKey, getWorkflowStateByNameOrType, lookupUserId, @@ -51,6 +52,10 @@ export const updateCommand = new Command() "--project ", "Name of the project with the issue", ) + .option( + "--milestone ", + "Name of the project milestone for the issue", + ) .option( "-s, --state ", "Workflow state for the issue (by name or type)", @@ -69,6 +74,7 @@ export const updateCommand = new Command() label: labels, team, project, + milestone, state, color, title, @@ -157,6 +163,26 @@ export const updateCommand = new Command() } } + let projectMilestoneId: string | undefined = undefined + if (milestone !== undefined) { + if (projectId === undefined) { + console.error( + "Cannot set milestone without a project. Use --project to specify the project.", + ) + Deno.exit(1) + } + projectMilestoneId = await getProjectMilestoneIdByNameForProject( + milestone, + projectId, + ) + if (projectMilestoneId === undefined) { + console.error( + `Could not determine ID for project milestone ${milestone}`, + ) + Deno.exit(1) + } + } + // Build the update input object, only including fields that were provided const input: Record = {} @@ -186,6 +212,9 @@ export const updateCommand = new Command() if (labelIds.length > 0) input.labelIds = labelIds if (teamId !== undefined) input.teamId = teamId if (projectId !== undefined) input.projectId = projectId + if (projectMilestoneId !== undefined) { + input.projectMilestoneId = projectMilestoneId + } if (stateId !== undefined) input.stateId = stateId spinner?.stop() diff --git a/src/commands/issue/issue.ts b/src/commands/issue/issue.ts index b1d67e2..f44d11b 100644 --- a/src/commands/issue/issue.ts +++ b/src/commands/issue/issue.ts @@ -10,6 +10,9 @@ import { deleteCommand } from "./issue-delete.ts" import { pullRequestCommand } from "./issue-pull-request.ts" import { updateCommand } from "./issue-update.ts" import { commentCommand } from "./comment.ts" +import { acceptCommand } from "./issue-accept.ts" +import { declineCommand } from "./issue-decline.ts" +import { snoozeCommand } from "./issue-snooze.ts" export const issueCommand = new Command() .description("Manage Linear issues") @@ -27,3 +30,6 @@ export const issueCommand = new Command() .command("create", createCommand) .command("update", updateCommand) .command("comment", commentCommand) + .command("accept", acceptCommand) + .command("decline", declineCommand) + .command("snooze", snoozeCommand) diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 1bd4ad4..d4dc859 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -81,6 +81,24 @@ export async function getIssueId( return data.issue?.id } +export async function getIssueTeamKey( + identifier: string, +): Promise { + const query = gql(/* GraphQL */ ` + query GetIssueTeamKey($id: String!) { + issue(id: $id) { + team { + key + } + } + } + `) + + const client = getGraphQLClient() + const data = await client.request(query, { id: identifier }) + return data.issue?.team?.key +} + export async function getWorkflowStates( teamKey: string, ) { @@ -123,6 +141,43 @@ export async function getStartedState( return { id: startedStates[0].id, name: startedStates[0].name } } +export async function getDefaultIssueState( + teamKey: string, +): Promise<{ id: string; name: string }> { + const client = getGraphQLClient() + const query = gql(/* GraphQL */ ` + query GetDefaultIssueState($teamKey: String!) { + team(id: $teamKey) { + defaultIssueState { + id + name + } + } + } + `) + const result = await client.request(query, { teamKey }) + if (!result.team.defaultIssueState) { + throw new Error("No default issue state found for team") + } + return { + id: result.team.defaultIssueState.id, + name: result.team.defaultIssueState.name, + } +} + +export async function getCanceledState( + teamKey: string, +): Promise<{ id: string; name: string }> { + const states = await getWorkflowStates(teamKey) + const canceledStates = states.filter((s) => s.type === "canceled") + + if (!canceledStates.length) { + throw new Error("No 'canceled' state found in workflow") + } + + return { id: canceledStates[0].id, name: canceledStates[0].name } +} + export async function getWorkflowStateByNameOrType( teamKey: string, nameOrType: string, @@ -721,6 +776,56 @@ export async function getTeamMembers(teamKey: string) { ) } +export async function getProjectMilestoneIdByNameForProject( + name: string, + projectId: string, +): Promise { + const client = getGraphQLClient() + const query = gql(/* GraphQL */ ` + query GetProjectMilestoneIdByNameForProject($projectId: String!, $name: String!) { + project(id: $projectId) { + projectMilestones(filter: { name: { eqIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + } + `) + const data = await client.request(query, { projectId, name }) + return data.project?.projectMilestones?.nodes[0]?.id +} + +export async function getProjectMilestoneOptionsByNameForProject( + name: string, + projectId: string, +): Promise> { + const client = getGraphQLClient() + const query = gql(/* GraphQL */ ` + query GetProjectMilestoneIdOptionsByNameForProject( + $projectId: String! + $name: String! + ) { + project(id: $projectId) { + projectMilestones(filter: { name: { containsIgnoreCase: $name } }) { + nodes { + id + name + } + } + } + } + `) + const data = await client.request(query, { projectId, name }) + const qResults = data.project?.projectMilestones?.nodes || [] + // Sort milestones alphabetically (case insensitive) + const sortedResults = qResults.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ) + return Object.fromEntries(sortedResults.map((t) => [t.id, t.name])) +} + export async function selectOption( dataName: string, originalValue: string,