Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,15 @@ $ lc whoami --teams <1>
$ lcls
$ lcls --full
$ lcls -f CRY-1
----
$ lcls -N <1>
$ lcls --state started <2>
$ lcls --status "Human Review" <3>
$ lcls --state started --status "Human Review,Gate Approved" <4>
----
<1> Include issues not assigned to you (short for `--no-mine`)
<2> Filter by Linear's internal workflow state type
<3> Filter by a friendly workflow status name (case-insensitive)
<4> Combine state and status filters; both must match

==== Assign one or more issues to yourself (take em!)

Expand Down
60 changes: 44 additions & 16 deletions app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ defmodule LinearCli.CLI do

alias LinearCli.CLI.Commands

@workflow_state_types ~w(triage backlog unstarted started completed canceled duplicate)

def main(argv, halt \\ &System.halt/1) do
argv =
argv
Expand Down Expand Up @@ -424,6 +426,34 @@ defmodule LinearCli.CLI do

defp maybe_print_backtrace(_debug), do: :ok

defp parse_states(value) do
states =
value
|> split_filter_values()
|> Enum.map(&normalize_state/1)

case Enum.find(states, &(&1 not in @workflow_state_types)) do
nil ->
{:ok, states}

state ->
{:error,
"unknown state #{inspect(state)}, must be one of: #{Enum.join(@workflow_state_types, ", ")}"}
end
end

defp parse_statuses(value), do: {:ok, split_filter_values(value)}

defp split_filter_values(value) do
value
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end

defp normalize_state("cancelled"), do: "canceled"
defp normalize_state(state), do: String.downcase(state)

def spec do
Optimus.new!(
name: "lc",
Expand Down Expand Up @@ -473,7 +503,11 @@ defmodule LinearCli.CLI do
name: "list",
about: "List teams",
flags: [
no_mine: [long: "--no-mine", help: "List all teams, not just your own"],
no_mine: [
short: "-N",
long: "--no-mine",
help: "List all teams, not just your own"
],
all: [long: "--all", help: "Ignore favorites (doesn't affect --no-mine)"]
]
],
Expand Down Expand Up @@ -607,6 +641,7 @@ defmodule LinearCli.CLI do
help: "Show unassigned issues only"
],
no_mine: [
short: "-N",
long: "--no-mine",
help: "List the most recent issues, not just your own"
],
Expand All @@ -628,24 +663,17 @@ defmodule LinearCli.CLI do
help:
"Show issues for only this project. Can be name, URL, ID, or - to select from a list"
],
state: [
long: "--state",
help:
"Filter by workflow state type(s): triage, backlog, unstarted, started, completed, canceled, duplicate (comma-separated)",
parser: &parse_states/1
],
status: [
short: "-s",
long: "--status",
help:
"Filter by workflow state type(s): triage, backlog, unstarted, started, completed, cancelled (comma-separated)",
parser: fn v ->
valid = ~w(triage backlog unstarted started completed cancelled canceled)
types = String.split(v, ",", trim: true)

case Enum.find(types, &(&1 not in valid)) do
nil ->
{:ok, types}

bad ->
{:error,
"unknown status #{inspect(bad)}, must be one of: #{Enum.join(valid, ", ")}"}
end
end
help: "Filter by friendly workflow status name(s) (comma-separated)",
parser: &parse_statuses/1
]
]
],
Expand Down
1 change: 1 addition & 0 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ defmodule LinearCli.CLI.Commands do
team_key: team_key,
project_id: project_id,
all: Map.get(flags, :all, false),
state: Map.get(options, :state) || [],
status: Map.get(options, :status) || []
}

Expand Down
55 changes: 39 additions & 16 deletions app/lib/linear_cli/linear/issue.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule LinearCli.Linear.Issue do
argument :team_key, :string, allow_nil?: true
argument :project_id, :string, allow_nil?: true
argument :all, :boolean, default: false
argument :state, {:array, :string}, default: []
argument :status, {:array, :string}, default: []
manual LinearCli.Linear.Issue.Read.List
end
Expand Down Expand Up @@ -188,9 +189,9 @@ defmodule LinearCli.Linear.Issue.Read.List do
# Ported from Rubyists::Linear::Operations::Issue::List#build_filter. `unassigned`
# is checked after `mine` here too, so it wins if both are set - same as Ruby.
# `all: true` removes the completedAt/canceledAt null-checks so closed/cancelled
# issues are included. `status` injects a state.type filter; when it includes
# "completed" or "cancelled"/"canceled", the corresponding date null-checks are
# also dropped so those issues aren't filtered out before the type filter applies.
# issues are included. A type filter only removes the date guard for the closed
# state it requests. A friendly-name filter removes both guards because its type
# is unknown until Linear evaluates it.
defp build_filter(args) do
%{}
|> maybe_put_date_filters(args)
Expand All @@ -201,30 +202,30 @@ defmodule LinearCli.Linear.Issue.Read.List do
end

@completed_types ~w(completed)
@cancelled_types ~w(cancelled canceled)
@cancelled_types ~w(cancelled canceled duplicate)

defp maybe_put_date_filters(filter, %{all: true}), do: filter

defp maybe_put_date_filters(filter, %{status: status}) when status != [] do
defp maybe_put_date_filters(filter, %{state: [_ | _] = states}) do
filter
|> maybe_put_completed_date_filter(status)
|> maybe_put_cancelled_date_filter(status)
|> maybe_put_completed_date_filter(states)
|> maybe_put_cancelled_date_filter(states)
end

defp maybe_put_date_filters(filter, %{status: [_ | _]}), do: filter

defp maybe_put_date_filters(filter, _args) do
Map.merge(filter, %{"completedAt" => %{"null" => true}, "canceledAt" => %{"null" => true}})
end

# Suppress the completedAt null-check only when the status list doesn't ask for completed.
defp maybe_put_completed_date_filter(filter, status) do
if Enum.any?(status, &(&1 in @completed_types)),
defp maybe_put_completed_date_filter(filter, states) do
if Enum.any?(states, &(&1 in @completed_types)),
do: filter,
else: Map.put(filter, "completedAt", %{"null" => true})
end

# Suppress the canceledAt null-check only when the status list doesn't ask for cancelled.
defp maybe_put_cancelled_date_filter(filter, status) do
if Enum.any?(status, &(&1 in @cancelled_types)),
defp maybe_put_cancelled_date_filter(filter, states) do
if Enum.any?(states, &(&1 in @cancelled_types)),
do: filter,
else: Map.put(filter, "canceledAt", %{"null" => true})
end
Expand All @@ -251,11 +252,33 @@ defmodule LinearCli.Linear.Issue.Read.List do

defp maybe_put_project_filter(filter, _args), do: filter

defp maybe_put_state_filter(filter, %{status: [_ | _] = types}) do
Map.put(filter, "state", %{"type" => %{"in" => types}})
defp maybe_put_state_filter(filter, %{state: [], status: []}), do: filter

defp maybe_put_state_filter(filter, %{state: states, status: statuses}) do
state_filter =
%{}
|> maybe_put_state_types(states)
|> maybe_put_status_names(statuses)

Map.put(filter, "state", state_filter)
end

defp maybe_put_state_types(filter, []), do: filter

defp maybe_put_state_types(filter, states) do
Map.put(filter, "type", %{"in" => states})
end

defp maybe_put_state_filter(filter, _args), do: filter
defp maybe_put_status_names(filter, []), do: filter

defp maybe_put_status_names(filter, [status]) do
Map.put(filter, "name", %{"eqIgnoreCase" => status})
end

defp maybe_put_status_names(filter, statuses) do
names = Enum.map(statuses, &%{"name" => %{"eqIgnoreCase" => &1}})
Map.put(filter, "or", names)
end
end

defmodule LinearCli.Linear.Issue.Create do
Expand Down
90 changes: 82 additions & 8 deletions app/test/linear_cli/cli/issue_commands_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,24 @@ defmodule LinearCli.CLI.IssueCommandsTest do
assert output =~ "CRY-1"
end

test "-N aliases --no-mine" 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, {:filter, decoded["variables"]["filter"]})
Req.Test.json(conn, issues_response([issue_map()]))
end)

capture_io(fn ->
assert :ok = LinearCli.CLI.main(["issue", "list", "-N"])
end)

assert_received {:filter, filter}
refute Map.has_key?(filter, "assignee")
end

test "--all removes completedAt and canceledAt null-check filters" do
test_pid = self()

Expand All @@ -276,7 +294,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
refute Map.has_key?(filter, "canceledAt")
end

test "--status filters by workflow state type and removes corresponding date filters" do
test "--state filters by workflow state type and removes corresponding date filters" do
test_pid = self()

Req.Test.stub(LinearCli.Api, fn conn ->
Expand All @@ -287,7 +305,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
end)

capture_io(fn ->
assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "started"])
assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "started"])
end)

assert_received {:filter, filter}
Expand All @@ -297,7 +315,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
assert Map.has_key?(filter, "canceledAt")
end

test "--status completed removes completedAt filter but keeps canceledAt filter" do
test "--state completed removes completedAt filter but keeps canceledAt filter" do
test_pid = self()

Req.Test.stub(LinearCli.Api, fn conn ->
Expand All @@ -308,7 +326,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
end)

capture_io(fn ->
assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "completed"])
assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "completed"])
end)

assert_received {:filter, filter}
Expand All @@ -317,7 +335,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
assert Map.has_key?(filter, "canceledAt")
end

test "--status accepts multiple comma-separated types" do
test "--state accepts multiple comma-separated types" do
test_pid = self()

Req.Test.stub(LinearCli.Api, fn conn ->
Expand All @@ -328,7 +346,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
end)

capture_io(fn ->
assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "started,completed"])
assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "started,completed"])
end)

assert_received {:filter, filter}
Expand All @@ -337,6 +355,62 @@ defmodule LinearCli.CLI.IssueCommandsTest do
assert Map.has_key?(filter, "canceledAt")
end

test "--status filters by friendly workflow status name" 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, {:filter, decoded["variables"]["filter"]})
Req.Test.json(conn, issues_response([issue_map()]))
end)

capture_io(fn ->
assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "Human Review"])
end)

assert_received {:filter, filter}
assert filter["state"] == %{"name" => %{"eqIgnoreCase" => "Human Review"}}
refute Map.has_key?(filter, "completedAt")
refute Map.has_key?(filter, "canceledAt")
end

test "--state and comma-separated --status values combine as type AND friendly name" 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, {:filter, decoded["variables"]["filter"]})
Req.Test.json(conn, issues_response([issue_map()]))
end)

capture_io(fn ->
assert :ok =
LinearCli.CLI.main([
"issue",
"list",
"--state",
"started",
"--status",
"Human Review, Gate Approved"
])
end)

assert_received {:filter, filter}

assert filter["state"] == %{
"type" => %{"in" => ["started"]},
"or" => [
%{"name" => %{"eqIgnoreCase" => "Human Review"}},
%{"name" => %{"eqIgnoreCase" => "Gate Approved"}}
]
}

assert Map.has_key?(filter, "completedAt")
assert Map.has_key?(filter, "canceledAt")
end

test "--no-profile bypasses active profile defaults via the full CLI dispatch path" do
test_pid = self()

Expand Down Expand Up @@ -364,7 +438,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
refute Map.has_key?(filter, "project")
end

test "--status with an unknown type exits 1 (Optimus parse error)" do
test "--state with an unknown type exits 1 (Optimus parse error)" do
test_pid = self()
halt = fn code -> send(test_pid, {:halted, code}) end

Expand All @@ -373,7 +447,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
# (same artifact as the --help test in cli_test.exs). Rescue it so the test
# can still verify halt was called with the right code.
try do
LinearCli.CLI.main(["issue", "list", "--status", "badtype"], halt)
LinearCli.CLI.main(["issue", "list", "--state", "badtype"], halt)
rescue
_ -> :ok
end
Expand Down
4 changes: 2 additions & 2 deletions app/test/linear_cli/cli_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ defmodule LinearCli.CLITest do
assert capture_io(fn -> LinearCli.CLI.main(["team", "list"]) end) =~ "Engineering"
end

test "team list --no-mine lists all teams" do
assert capture_io(fn -> LinearCli.CLI.main(["team", "list", "--no-mine"]) end) =~ "Ops"
test "team list -N aliases --no-mine" do
assert capture_io(fn -> LinearCli.CLI.main(["team", "list", "-N"]) end) =~ "Ops"
end

test "project list defaults to all projects (Ruby: --mine defaults false)" do
Expand Down
Loading