From 2522c23066ed94c995fb38cef297e3db0d35362f Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 18 Aug 2026 09:49:54 -0400 Subject: [PATCH] feat(issue): add --description option to issue update Adds `lc issue update --description` (short: `-d`) to update an issue's body text via the Linear `issueUpdate` mutation with `description` input. Supports `-` to open an editor (consistent with issue create and other long-form inputs), and follows the existing update-option precedence order (after --project, before the comment-only fallback). Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli.ex | 5 ++ app/lib/linear_cli/cli/commands.ex | 1 + app/lib/linear_cli/cli/issue_helpers.ex | 21 +++++++ app/lib/linear_cli/linear.ex | 1 + app/lib/linear_cli/linear/issue.ex | 18 ++++++ .../linear_cli/cli/issue_commands_test.exs | 58 +++++++++++++++++++ .../linear_cli/cli/issue_helpers_test.exs | 31 ++++++++++ app/test/linear_cli/linear/issue_test.exs | 45 ++++++++++++++ 8 files changed, 180 insertions(+) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index d2dc190..f9b474a 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -645,6 +645,11 @@ defmodule LinearCli.CLI do long: "--comment", help: "Comment to add to the issue. - open an editor" ], + description: [ + short: "-d", + long: "--description", + help: "Update the issue description. - to open an editor" + ], project: [ short: "-p", long: "--project", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 760f016..d96bc39 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -457,6 +457,7 @@ defmodule LinearCli.CLI.Commands do Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}) do update_opts = [ comment: options.comment, + description: Map.get(options, :description), project: options.project, cancel: flags.cancel, close: flags.close, diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index a173641..da6d204 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -301,6 +301,26 @@ defmodule LinearCli.CLI.IssueHelpers do end end + @doc """ + Updates `issue`'s description to `description_input`, resolving it (asking, + or opening an editor, if not already given - via + `LinearCli.CLI.WhatFor.description_for/1`) first. + """ + @spec update_description(%Linear.Issue{}, String.t() | nil) :: + {:ok, %Linear.Issue{}} | {:error, term()} + def update_description(issue, description_input) do + description = WhatFor.description_for(description_input) + + case Linear.update_issue_description(issue, description) do + {:ok, updated} -> + Prompt.ok("#{issue.identifier} description updated") + {:ok, updated} + + {:error, reason} -> + {:error, reason} + end + end + @doc """ Dispatches an issue update per whichever of `opts`' keys is set, in Ruby's exact precedence order: @@ -341,6 +361,7 @@ defmodule LinearCli.CLI.IssueHelpers do opts[:cancel] -> normalize(cancel_issue(issue, opts)) opts[:pr] -> issue_pr(issue, opts) opts[:project] -> normalize(attach_project(issue, opts[:project])) + opts[:description] -> normalize(update_description(issue, opts[:description])) opts[:comment] -> :ok true -> no_action_taken() end diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index ded07c0..ee3be18 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -36,6 +36,7 @@ defmodule LinearCli.Linear do define :attach_issue_to_project, action: :attach_to_project, args: [:project_id] define :close_issue, action: :close, args: [:state_id] define :set_issue_status, action: :set_status, args: [:state_id] + define :update_issue_description, action: :update_description, args: [:description] end resource LinearCli.Linear.Label do diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index 269aa3f..b39da05 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -53,6 +53,11 @@ defmodule LinearCli.Linear.Issue do argument :state_id, :string, allow_nil?: false manual LinearCli.Linear.Issue.Update.SetStatus end + + update :update_description do + argument :description, :string, allow_nil?: false + manual LinearCli.Linear.Issue.Update.UpdateDescription + end end attributes do @@ -378,3 +383,16 @@ defmodule LinearCli.Linear.Issue.Update.SetStatus do }) end end + +defmodule LinearCli.Linear.Issue.Update.UpdateDescription do + @moduledoc false + use Ash.Resource.ManualUpdate + + alias LinearCli.Linear.Issue + + def update(changeset, _opts, _context) do + Issue.Update.run(changeset.data.identifier, %{ + "description" => changeset.arguments.description + }) + end +end diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index c10bc9c..61dfd83 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -970,6 +970,64 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "CRY-1 was closed" end + test "--description updates the issue description via the issueUpdate mutation" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:description, decoded["variables"]["input"]["description"]}) + Req.Test.json(conn, issue_updated(%{"description" => "Updated body"})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "--description", + "Updated body", + "CRY-1" + ]) + end) + + assert_received {:description, "Updated body"} + assert output =~ "CRY-1 description updated" + end + + test "-d short flag also updates the issue description" do + stub_responses([ + {"issue(id: $id)", %{"data" => %{"issue" => issue_map()}}}, + {"issueUpdate", issue_updated(%{"description" => "Short flag body"})} + ]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "-d", + "Short flag body", + "CRY-1" + ]) + end) + + assert output =~ "CRY-1 description updated" + end + test "with no issue ids, exits 22 (Ruby: raise SmellsBad -> exit 22)" do test_pid = self() halt = fn code -> send(test_pid, {:halted, code}) end diff --git a/app/test/linear_cli/cli/issue_helpers_test.exs b/app/test/linear_cli/cli/issue_helpers_test.exs index 54a7226..5c3603e 100644 --- a/app/test/linear_cli/cli/issue_helpers_test.exs +++ b/app/test/linear_cli/cli/issue_helpers_test.exs @@ -263,6 +263,26 @@ defmodule LinearCli.CLI.IssueHelpersTest do end end + describe "update_description/2" do + test "resolves and sends the description, printing a confirmation" do + stub_responses([{"issueUpdate", issue_updated(%{"description" => "New body"})}]) + + assert capture_io(fn -> + assert {:ok, %Issue{description: "New body"}} = + IssueHelpers.update_description(issue(), "New body") + end) =~ "CRY-1 description updated" + end + + test "propagates an API error without printing confirmation" do + stub_responses([{"issueUpdate", %{"errors" => [%{"message" => "boom"}]}}]) + + assert capture_io(fn -> + assert {:error, %Ash.Error.Invalid{}} = + IssueHelpers.update_description(issue(), "New body") + end) == "" + end + end + describe "attach_project/2 (Ruby: CLI::Issue#attach_project)" do test "resolves the project by name against the team's projects and attaches it" do stub_responses([ @@ -363,6 +383,17 @@ defmodule LinearCli.CLI.IssueHelpersTest do assert output =~ "CRY-1 was attached to Manhattan Rollout" end + test "with :description, updates the issue description" do + stub_responses([{"issueUpdate", issue_updated(%{"description" => "New body"})}]) + + output = + capture_io(fn -> + assert :ok = IssueHelpers.update_issue(issue(), description: "New body") + end) + + assert output =~ "CRY-1 description updated" + end + test "with only :comment, comments and stops without the 'no action taken' warning" do stub_responses([{"commentCreate", comment_created()}]) diff --git a/app/test/linear_cli/linear/issue_test.exs b/app/test/linear_cli/linear/issue_test.exs index f1d7fab..d8128c8 100644 --- a/app/test/linear_cli/linear/issue_test.exs +++ b/app/test/linear_cli/linear/issue_test.exs @@ -376,4 +376,49 @@ defmodule LinearCli.Linear.IssueTest do assert {:error, %Ash.Error.Invalid{}} = Linear.close_issue(issue, "nope") end end + + describe "update_issue_description/2" do + test "sends description and returns the issue refetched via full_fields" do + issue = struct!(LinearCli.Linear.Issue, id: "i1", identifier: "CRY-1") + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"id" => id, "input" => input}} = Jason.decode!(body) + + assert id == "CRY-1" + assert input == %{"description" => "Updated body"} + + Req.Test.json(conn, %{ + "data" => %{ + "issueUpdate" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix it", + "branchName" => "cry-1-fix-it", + "description" => "Updated body", + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + } + } + } + }) + end) + + assert {:ok, updated} = Linear.update_issue_description(issue, "Updated body") + assert updated.description == "Updated body" + end + + test "surfaces a GraphQL error" do + issue = struct!(LinearCli.Linear.Issue, id: "i1", identifier: "CRY-1") + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, %{"errors" => [%{"message" => "unauthorized"}]}) + end) + + assert {:error, %Ash.Error.Invalid{}} = + Linear.update_issue_description(issue, "Updated body") + end + end end