diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index b18e63e..c13fb4c 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -805,7 +805,12 @@ defmodule LinearCli.CLI do ], project: [short: "-p", long: "--project", help: "Project Identifier"], team: [short: "-T", long: "--team", help: "Team Identifier"], - title: [short: "-t", long: "--title", help: "Issue Title"] + title: [short: "-t", long: "--title", help: "Issue Title"], + priority: [ + long: "--priority", + help: "Issue priority: none, urgent, high, medium, low", + parser: &LinearCli.CLI.Priority.parse/1 + ] ], flags: [ develop: [long: "--dev", help: "Start development after creating the issue"], diff --git a/app/lib/linear_cli/cli/commands/issues/create.ex b/app/lib/linear_cli/cli/commands/issues/create.ex index b0807f3..cdea0fa 100644 --- a/app/lib/linear_cli/cli/commands/issues/create.ex +++ b/app/lib/linear_cli/cli/commands/issues/create.ex @@ -36,6 +36,7 @@ defmodule LinearCli.CLI.Commands.Issues.Create do description: description, team: options.team, labels: options.labels, + priority: Map.get(options, :priority), project: options.project, yes: flags.yes ], diff --git a/app/lib/linear_cli/cli/issue/creation.ex b/app/lib/linear_cli/cli/issue/creation.ex index dab6f6b..fd23295 100644 --- a/app/lib/linear_cli/cli/issue/creation.ex +++ b/app/lib/linear_cli/cli/issue/creation.ex @@ -53,7 +53,11 @@ defmodule LinearCli.CLI.Issue.Creation do with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: project_search}) do project = Projects.project_for(projects, project_search) label_ids = Enum.map(labels, & &1.id) - params = maybe_put_project_id(%{label_ids: label_ids}, project) + + params = + %{label_ids: label_ids} + |> maybe_put_project_id(project) + |> maybe_put_priority(opts[:priority]) Linear.create_issue(title, description, team.id, params) end @@ -79,7 +83,12 @@ defmodule LinearCli.CLI.Issue.Creation do else: nil label_ids = Enum.map(labels, & &1.id) - params = maybe_put_project_id(%{label_ids: label_ids}, project) + + params = + %{label_ids: label_ids} + |> maybe_put_project_id(project) + |> maybe_put_priority(opts[:priority]) + Linear.create_issue(title, description, team.id, params) end end @@ -115,4 +124,7 @@ defmodule LinearCli.CLI.Issue.Creation do defp maybe_put_project_id(params, nil), do: params defp maybe_put_project_id(params, project), do: Map.put(params, :project_id, project.id) + + defp maybe_put_priority(params, nil), do: params + defp maybe_put_priority(params, priority), do: Map.put(params, :priority, priority) end diff --git a/app/lib/linear_cli/cli/priority.ex b/app/lib/linear_cli/cli/priority.ex new file mode 100644 index 0000000..c37e0ad --- /dev/null +++ b/app/lib/linear_cli/cli/priority.ex @@ -0,0 +1,37 @@ +defmodule LinearCli.CLI.Priority do + @moduledoc """ + Shared priority name parser for `lc issue create` and `lc issue update`. + + Maps the canonical, case-insensitive friendly names (`none`, `urgent`, + `high`, `medium`, `low`) to the integer values Linear's API expects + (0–4, matching `IssueCreateInput.priority` and `IssueUpdateInput.priority`). + """ + + @priority_map %{ + "none" => 0, + "urgent" => 1, + "high" => 2, + "medium" => 3, + "low" => 4 + } + + @valid_values Map.keys(@priority_map) |> Enum.sort() + + @doc """ + Parses a priority name string into a Linear priority integer. + + Accepts `none`, `urgent`, `high`, `medium`, `low` (case-insensitive). + Returns `{:ok, integer}` on success or `{:error, message}` for unknown values. + """ + @spec parse(String.t()) :: {:ok, 0..4} | {:error, String.t()} + def parse(value) when is_binary(value) do + case Map.fetch(@priority_map, String.downcase(value)) do + {:ok, int} -> + {:ok, int} + + :error -> + {:error, + "unknown priority #{inspect(value)}; must be one of: #{Enum.join(@valid_values, ", ")}"} + end + end +end diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index fc7dbfb..146718e 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -28,6 +28,7 @@ defmodule LinearCli.Linear.Issue do argument :team_id, :string, allow_nil?: false argument :project_id, :string, allow_nil?: true argument :label_ids, {:array, :string}, default: [] + argument :priority, :integer, allow_nil?: true manual LinearCli.Linear.Issue.Create end @@ -368,6 +369,7 @@ defmodule LinearCli.Linear.Issue.Create do %{"title" => args.title, "description" => args.description, "teamId" => args.team_id} |> maybe_put_label_ids(args.label_ids) |> maybe_put_project_id(Map.get(args, :project_id)) + |> maybe_put_priority(Map.get(args, :priority)) case Api.call(document(), %{"input" => input}) do {:ok, %{"issueCreate" => %{"issue" => issue_map}}} when is_map(issue_map) -> @@ -387,6 +389,9 @@ defmodule LinearCli.Linear.Issue.Create do defp maybe_put_project_id(input, nil), do: input defp maybe_put_project_id(input, project_id), do: Map.put(input, "projectId", project_id) + defp maybe_put_priority(input, nil), do: input + defp maybe_put_priority(input, priority), do: Map.put(input, "priority", priority) + # A function, not a module attribute: Issue.base_fields/0 reaches into # User (another file), so it must be evaluated at call time - see house # rule on cross-file compile-time module attribute evaluation order. diff --git a/app/test/linear_cli/cli/commands/issues/create_test.exs b/app/test/linear_cli/cli/commands/issues/create_test.exs index 6d06f3d..81b2f57 100644 --- a/app/test/linear_cli/cli/commands/issues/create_test.exs +++ b/app/test/linear_cli/cli/commands/issues/create_test.exs @@ -469,6 +469,289 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do assert output =~ "--team is required" end + test "--priority high sends priority=2 in the GraphQL 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:sent_priority, decoded["variables"]["input"]["priority"]}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => + issue_map(%{ + "identifier" => "CRY-2", + "title" => "T", + "priority" => 2.0, + "priorityLabel" => "High" + }) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--title", + "T", + "--description", + "D", + "--team", + "ENG", + "--yes", + "--no-take", + "--priority", + "high" + ]) + end) + + assert_received {:sent_priority, 2} + end + + test "--priority none sends priority=0 in the GraphQL 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:sent_priority, decoded["variables"]["input"]["priority"]}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"identifier" => "CRY-2", "title" => "T"}) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--title", + "T", + "--description", + "D", + "--team", + "ENG", + "--yes", + "--no-take", + "--priority", + "none" + ]) + end) + + assert_received {:sent_priority, 0} + end + + test "omitting --priority does not send priority in the GraphQL 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:input_keys, Map.keys(decoded["variables"]["input"])}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"identifier" => "CRY-2", "title" => "T"}) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--title", + "T", + "--description", + "D", + "--team", + "ENG", + "--yes", + "--no-take" + ]) + end) + + assert_received {:input_keys, keys} + refute "priority" in keys + end + + test "an invalid --priority value exits nonzero with a diagnostic before creating an issue" 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) + + # Optimus prints the "invalid value" diagnostic to stdout before halting. + output = + capture_io(fn -> + LinearCli.CLI.main( + [ + "issue", + "create", + "--title", + "T", + "--team", + "ENG", + "--priority", + "critical" + ], + halt + ) + end) + + assert_received {:halted, _code} + assert output =~ "critical" + end + + test "--priority is case-insensitive (HIGH maps to 2)" 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:sent_priority, decoded["variables"]["input"]["priority"]}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"identifier" => "CRY-2", "title" => "T"}) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--title", + "T", + "--description", + "D", + "--team", + "ENG", + "--yes", + "--no-take", + "--priority", + "HIGH" + ]) + end) + + assert_received {:sent_priority, 2} + end + + test "--output json with --priority returns the created issue with priority metadata" do + created_issue = + issue_map(%{ + "id" => "i2", + "identifier" => "CRY-2", + "title" => "T", + "priority" => 2.0, + "priorityLabel" => "High", + "prioritySortOrder" => 0.0 + }) + + stub_responses([ + {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, + {"projects(first: 100", team_projects([])}, + {"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}} + ]) + + output = + capture_io(fn -> + assert :ok = + Create.issue_create( + %{ + options: %{ + title: "T", + description: "D", + team: "ENG", + labels: [], + priority: 2, + project: nil, + output: "json" + }, + flags: %{develop: false, yes: true, no_take: true} + }, + [] + ) + end) + + decoded = Jason.decode!(output) + assert decoded["priority"] === 2.0 + assert decoded["priority_label"] == "High" + end + test "-y with --project resolves it by exact match and uses it" do test_pid = self() me = %User{id: "u1", name: "Ada", email: "ada@x.com"} diff --git a/app/test/linear_cli/cli/priority_test.exs b/app/test/linear_cli/cli/priority_test.exs new file mode 100644 index 0000000..c721c6e --- /dev/null +++ b/app/test/linear_cli/cli/priority_test.exs @@ -0,0 +1,50 @@ +defmodule LinearCli.CLI.PriorityTest do + use ExUnit.Case, async: true + + alias LinearCli.CLI.Priority + + describe "parse/1" do + test "maps none to 0" do + assert {:ok, 0} = Priority.parse("none") + end + + test "maps urgent to 1" do + assert {:ok, 1} = Priority.parse("urgent") + end + + test "maps high to 2" do + assert {:ok, 2} = Priority.parse("high") + end + + test "maps medium to 3" do + assert {:ok, 3} = Priority.parse("medium") + end + + test "maps low to 4" do + assert {:ok, 4} = Priority.parse("low") + end + + test "matching is case-insensitive" do + assert {:ok, 1} = Priority.parse("URGENT") + assert {:ok, 2} = Priority.parse("High") + assert {:ok, 3} = Priority.parse("MEDIUM") + assert {:ok, 4} = Priority.parse("Low") + assert {:ok, 0} = Priority.parse("None") + end + + test "returns error for unknown priority" do + assert {:error, message} = Priority.parse("critical") + assert message =~ "unknown priority \"critical\"" + assert message =~ "must be one of:" + end + + test "error message lists all valid values" do + assert {:error, message} = Priority.parse("bad") + assert message =~ "high" + assert message =~ "low" + assert message =~ "medium" + assert message =~ "none" + assert message =~ "urgent" + 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..1a50f00 100644 --- a/app/test/linear_cli/linear/issue_test.exs +++ b/app/test/linear_cli/linear/issue_test.exs @@ -940,4 +940,81 @@ defmodule LinearCli.Linear.IssueTest do assert {:ok, []} = Linear.issues(%{include_labels: false, labels: ["Bug"], mine: false}) end end + + describe "Issue.Create priority input" do + test "includes priority in the GraphQL input when provided" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"input" => input}} = Jason.decode!(body) + send(test_pid, {:input, input}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "T", + "branchName" => "cry-1-t", + "description" => nil, + "priority" => 2.0, + "priorityLabel" => "High", + "prioritySortOrder" => 0.0, + "createdAt" => nil, + "updatedAt" => nil, + "assignee" => nil, + "state" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"} + } + } + } + }) + end) + + assert {:ok, _issue} = + Linear.create_issue("T", nil, "team-id", %{label_ids: [], priority: 2}) + + assert_received {:input, input} + assert input["priority"] == 2 + end + + test "omits priority from the GraphQL input when not provided" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"input" => input}} = Jason.decode!(body) + send(test_pid, {:input, input}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "T", + "branchName" => "cry-1-t", + "description" => nil, + "priority" => 0.0, + "priorityLabel" => "No priority", + "prioritySortOrder" => 0.0, + "createdAt" => nil, + "updatedAt" => nil, + "assignee" => nil, + "state" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"} + } + } + } + }) + end) + + assert {:ok, _issue} = Linear.create_issue("T", nil, "team-id", %{label_ids: []}) + + assert_received {:input, input} + refute Map.has_key?(input, "priority") + end + end end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 72dd7d1..e934127 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -372,7 +372,7 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `:create` | create | `Linear.Issue.Create` -| `issueCreate(input: { title, description, teamId, labelIds?, projectId? })` mutation +| `issueCreate(input: { title, description, teamId, labelIds?, projectId?, priority? })` mutation | `Issue` | `assign_issue`