From 83a6edb14959d39d230e7248b97a8de097fda485 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 14 Sep 2026 14:28:09 -0400 Subject: [PATCH] feat(issue): add --priority flag to lc issue update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `lc issue update --priority PRIORITY` for non-interactive priority updates. Accepts `none|urgent|high|medium|low` (case-insensitive), maps to Linear GraphQL integers 0–4, supports multiple issue IDs, and fails fast with a clear diagnostic and exit 22 on invalid input. Validation is handled in `Mutations.issue_update/1` (not the Optimus parser layer) so that invalid values route through the CLI's own `handle_error` path — printing to stderr and exiting 22 — rather than Optimus's formatter which behaves differently under the test halt stub. --- app/lib/linear_cli/cli.ex | 6 +- .../cli/commands/issues/mutations.ex | 30 +- app/lib/linear_cli/cli/issue/actions.ex | 25 +- app/lib/linear_cli/linear.ex | 1 + app/lib/linear_cli/linear/issue.ex | 18 ++ .../cli/commands/issues/mutations_test.exs | 268 ++++++++++++++++++ .../linear_cli/cli/issue/actions_test.exs | 84 ++++++ app/test/linear_cli/linear/issue_test.exs | 87 ++++++ documents/ash-domain-erd.adoc | 7 + 9 files changed, 522 insertions(+), 4 deletions(-) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index b18e63e..2f660f7 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -956,7 +956,11 @@ defmodule LinearCli.CLI do long: "--status", help: "Workflow state name to use with --close or --cancel" ], - reason: [long: "--reason", help: "Reason for closing the issue. - open an editor"] + reason: [long: "--reason", help: "Reason for closing the issue. - open an editor"], + priority: [ + long: "--priority", + help: "Set the issue priority: none, urgent, high, medium, low" + ] ] ], relation: [ diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index a779080..d95ec63 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -31,6 +31,7 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do with :ok <- validate_issue_ids(issue_ids), :ok <- validate_body_file_exclusion(options, :description, "--description"), {:ok, description} <- resolve_body_from_file(options, :description), + {:ok, priority} <- parse_priority(Map.get(options, :priority)), {:ok, issues} <- Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}) do update_opts = [ @@ -41,7 +42,8 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do close: flags.close, reason: options.reason, status: Map.get(options, :status), - trash: flags.trash + trash: flags.trash, + priority: priority ] Enum.reduce_while(issues, :ok, fn issue, :ok -> @@ -353,4 +355,30 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do ambiguous = Enum.map_join(matches, ", ", & &1.name) {:error, {:smells_bad, "Ambiguous assignee #{inspect(name)}: matches #{ambiguous}"}} end + + defp parse_priority(nil), do: {:ok, nil} + + defp parse_priority(value) do + case String.downcase(value) do + "none" -> + {:ok, 0} + + "urgent" -> + {:ok, 1} + + "high" -> + {:ok, 2} + + "medium" -> + {:ok, 3} + + "low" -> + {:ok, 4} + + _ -> + {:error, + {:smells_bad, + "invalid --priority #{inspect(value)}: must be one of: none, urgent, high, medium, low"}} + end + end end diff --git a/app/lib/linear_cli/cli/issue/actions.ex b/app/lib/linear_cli/cli/issue/actions.ex index b4b325e..7832de1 100644 --- a/app/lib/linear_cli/cli/issue/actions.ex +++ b/app/lib/linear_cli/cli/issue/actions.ex @@ -196,6 +196,25 @@ defmodule LinearCli.CLI.Issue.Actions do end end + @doc """ + Sets `issue`'s priority to `priority_value` (0–4, already validated and + converted by the CLI parser). + + 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low. + """ + @spec set_priority(%Linear.Issue{}, non_neg_integer()) :: + {:ok, %Linear.Issue{}} | {:error, term()} + def set_priority(issue, priority_value) do + case Linear.set_issue_priority(issue, priority_value) do + {:ok, updated} -> + Prompt.ok("#{issue.identifier} priority 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: @@ -207,8 +226,9 @@ defmodule LinearCli.CLI.Issue.Actions do 4. `:pr` -> `LinearCli.CLI.Issue.PullRequest.issue_pr/2` 5. `:project` -> `attach_project/2` 6. `:description` -> `update_description/2` - 7. otherwise, if only `:comment` was given, stop silently - 8. otherwise, warn "No action taken" and report "not updated" + 7. `:priority` -> `set_priority/2` + 8. otherwise, if only `:comment` was given, stop silently + 9. otherwise, warn "No action taken" and report "not updated" Ported from `CLI::Issue#update_issue`. Unlike every other function in this module, normalizes its result down to `:ok | {:error, reason}` (dropping @@ -238,6 +258,7 @@ defmodule LinearCli.CLI.Issue.Actions do opts[:pr] -> PullRequest.issue_pr(issue, opts) opts[:project] -> normalize(attach_project(issue, opts[:project])) opts[:description] -> normalize(update_description(issue, opts[:description])) + not is_nil(opts[:priority]) -> normalize(set_priority(issue, opts[:priority])) 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 5d30872..5a0d74f 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -38,6 +38,7 @@ defmodule LinearCli.Linear do 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] + define :set_issue_priority, action: :set_priority, args: [:priority] 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 fc7dbfb..07f8d86 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -62,6 +62,11 @@ defmodule LinearCli.Linear.Issue do argument :description, :string, allow_nil?: false manual LinearCli.Linear.Issue.Update.UpdateDescription end + + update :set_priority do + argument :priority, :integer, allow_nil?: false + manual LinearCli.Linear.Issue.Update.SetPriority + end end attributes do @@ -511,3 +516,16 @@ defmodule LinearCli.Linear.Issue.Update.UpdateDescription do }) end end + +defmodule LinearCli.Linear.Issue.Update.SetPriority do + @moduledoc false + use Ash.Resource.ManualUpdate + + alias LinearCli.Linear.Issue + + def update(changeset, _opts, _context) do + Issue.Update.run(changeset.data.identifier, %{ + "priority" => changeset.arguments.priority + }) + end +end diff --git a/app/test/linear_cli/cli/commands/issues/mutations_test.exs b/app/test/linear_cli/cli/commands/issues/mutations_test.exs index de4574f..e03324f 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -803,6 +803,274 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end end + describe "issue update --priority" do + test "--priority high sends priority: 2 in the mutation input" 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, {:priority, decoded["variables"]["input"]["priority"]}) + Req.Test.json(conn, issue_updated(%{"priority" => 2.0, "priorityLabel" => "High"})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "update", "--priority", "high", "CRY-1"]) + end) + + assert_received {:priority, 2} + assert output =~ "CRY-1 priority updated" + end + + test "--priority none sends priority: 0 to clear it" 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, {:priority, decoded["variables"]["input"]["priority"]}) + + Req.Test.json( + conn, + issue_updated(%{"priority" => 0.0, "priorityLabel" => "No priority"}) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "update", "--priority", "none", "CRY-1"]) + end) + + assert_received {:priority, 0} + assert output =~ "CRY-1 priority updated" + end + + test "--priority is case-insensitive (URGENT maps to 1)" 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, {:priority, decoded["variables"]["input"]["priority"]}) + Req.Test.json(conn, issue_updated()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "update", "--priority", "URGENT", "CRY-1"]) + end) + + assert_received {:priority, 1} + end + + test "--priority with unknown value exits 22 without calling issueUpdate" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "update", "--priority", "critical", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "critical" + assert stderr =~ "none, urgent, high, medium, low" + end + + test "--priority with multiple issue IDs updates each" 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)") -> + id = decoded["variables"]["id"] + Req.Test.json(conn, %{"data" => %{"issue" => issue_map(%{"identifier" => id})}}) + + String.contains?(query, "issueUpdate") -> + id = decoded["variables"]["id"] + send(test_pid, {:updated, id}) + Req.Test.json(conn, issue_updated()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "--priority", + "low", + "CRY-1", + "CRY-2" + ]) + end) + + assert_received {:updated, "CRY-1"} + assert_received {:updated, "CRY-2"} + end + + test "--output json still updates priority and confirms via stdout" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + query = Jason.decode!(body)["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, issue_updated(%{"priority" => 3.0, "priorityLabel" => "Medium"})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "--priority", + "medium", + "--output", + "json", + "CRY-1" + ]) + end) + + assert output =~ "CRY-1 priority updated" + end + + test "--priority combined with --comment posts comment first then updates priority" 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, "commentCreate") -> + send(test_pid, :comment_created) + Req.Test.json(conn, comment_created()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:priority, decoded["variables"]["input"]["priority"]}) + Req.Test.json(conn, issue_updated()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "--priority", + "high", + "--comment", + "bumping priority", + "CRY-1" + ]) + end) + + assert_received :comment_created + assert_received {:priority, 2} + assert output =~ "Comment added to CRY-1" + assert output =~ "CRY-1 priority updated" + end + + test "all documented priority names map to their correct integer values" do + priorities = [ + {"none", 0}, + {"urgent", 1}, + {"high", 2}, + {"medium", 3}, + {"low", 4} + ] + + for {name, expected_int} <- priorities 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, {:priority, decoded["variables"]["input"]["priority"]}) + Req.Test.json(conn, issue_updated()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "update", "--priority", name, "CRY-1"]), + "expected #{name} to succeed" + end) + + assert_received {:priority, ^expected_int}, "expected #{name} => #{expected_int}" + end + end + end + describe "issue assign" do defp member_map(id, name, email \\ nil) do %{"id" => id, "name" => name, "email" => email || "#{id}@example.com"} diff --git a/app/test/linear_cli/cli/issue/actions_test.exs b/app/test/linear_cli/cli/issue/actions_test.exs index b634fbf..2c401a9 100644 --- a/app/test/linear_cli/cli/issue/actions_test.exs +++ b/app/test/linear_cli/cli/issue/actions_test.exs @@ -349,6 +349,44 @@ defmodule LinearCli.CLI.Issue.ActionsTest do assert output =~ "CRY-1 description updated" end + test "with :priority, updates the issue priority" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated()) + end) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), priority: 2) + end) + + assert_received {:input, %{"priority" => 2}} + assert output =~ "CRY-1 priority updated" + end + + test "with :priority 0 (none), sends integer 0 and updates" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated()) + end) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), priority: 0) + end) + + assert_received {:input, %{"priority" => 0}} + assert output =~ "CRY-1 priority updated" + end + test "with only :comment, comments and stops without the 'no action taken' warning" do stub_responses([{"commentCreate", comment_created()}]) @@ -382,5 +420,51 @@ defmodule LinearCli.CLI.Issue.ActionsTest do Actions.update_issue(issue(), close: true, reason: "x") end) =~ "Comment added to CRY-1" end + + test "with :priority, updates priority and reports :ok" do + stub_responses([ + {"issueUpdate", issue_updated(%{"priority" => 2.0, "priorityLabel" => "High"})} + ]) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), priority: 2) + end) + + assert output =~ "priority updated" + end + + test "with priority: 0 (none), still dispatches (not treated as falsy)" do + stub_responses([ + {"issueUpdate", issue_updated(%{"priority" => 0.0, "priorityLabel" => "No priority"})} + ]) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), priority: 0) + end) + + assert output =~ "priority updated" + end + end + + describe "set_priority/2" do + test "calls set_issue_priority and prints a confirmation" do + stub_responses([ + {"issueUpdate", issue_updated(%{"priority" => 3.0, "priorityLabel" => "Medium"})} + ]) + + assert capture_io(fn -> + assert {:ok, %Issue{priority: 3.0}} = Actions.set_priority(issue(), 3) + end) =~ "CRY-1 priority 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{}} = Actions.set_priority(issue(), 1) + end) == "" + end end end diff --git a/app/test/linear_cli/linear/issue_test.exs b/app/test/linear_cli/linear/issue_test.exs index 9164dff..60ee2e0 100644 --- a/app/test/linear_cli/linear/issue_test.exs +++ b/app/test/linear_cli/linear/issue_test.exs @@ -556,6 +556,93 @@ defmodule LinearCli.Linear.IssueTest do end end + describe "set_issue_priority/2" do + test "sends priority as integer 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 == %{"priority" => 2} + + Req.Test.json(conn, %{ + "data" => %{ + "issueUpdate" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix it", + "branchName" => "cry-1-fix-it", + "description" => nil, + "priority" => 2.0, + "priorityLabel" => "High", + "prioritySortOrder" => 0.0, + "createdAt" => "2024-01-15T10:30:00.000Z", + "updatedAt" => "2024-01-16T12:00:00.000Z", + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + } + } + } + }) + end) + + assert {:ok, updated} = Linear.set_issue_priority(issue, 2) + assert updated.priority == 2.0 + assert updated.priority_label == "High" + end + + test "priority 0 (none) sends integer 0 in the mutation input" 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" => %{"input" => input}} = Jason.decode!(body) + + assert input == %{"priority" => 0} + + Req.Test.json(conn, %{ + "data" => %{ + "issueUpdate" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix it", + "branchName" => "cry-1-fix-it", + "description" => nil, + "priority" => 0.0, + "priorityLabel" => "No priority", + "prioritySortOrder" => 0.0, + "createdAt" => "2024-01-15T10:30:00.000Z", + "updatedAt" => "2024-01-16T12:00:00.000Z", + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + } + } + } + }) + end) + + assert {:ok, updated} = Linear.set_issue_priority(issue, 0) + assert updated.priority == 0.0 + assert updated.priority_label == "No priority" + 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.set_issue_priority(issue, 1) + end + end + describe "issues/1 label filtering" do test "issues/1 with labels requests labels fields in the GraphQL query" do Req.Test.stub(LinearCli.Api, fn conn -> diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 72dd7d1..f528682 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -403,6 +403,13 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `Linear.Issue.Update.SetStatus` | `issueUpdate(id:, input: { stateId })` via `Issue.Update.run/2` +| `Issue` +| `set_issue_priority` +| `:set_priority` +| update +| `Linear.Issue.Update.SetPriority` +| `issueUpdate(id:, input: { priority })` via `Issue.Update.run/2`. Argument: `priority` (integer 0–4: 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low). Name-to-integer mapping lives in `CLI.Commands.Issues.Mutations.parse_priority/1`; the GraphQL response returns `priority` as `Float!`. + | `IssueRelation` | `issue_relations` | `:list`