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
2 changes: 1 addition & 1 deletion app/config/test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Config
config :ash, policies: [show_policy_breakdowns?: true]
config :linear_cli, req_options: [plug: {Req.Test, LinearCli.Api}]
config :linear_cli, req_options: [plug: {Req.Test, LinearCli.Api}, retry: false]

# LinearCli.ObanRepo's connection details live in config/runtime.exs - see
# that file and ObanRepo's moduledoc. Doesn't matter functionally today
Expand Down
17 changes: 17 additions & 0 deletions app/lib/linear_cli/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ defmodule LinearCli.Api do
the Ruby `Issue#add_comment` path.
"""

require Logger

@base_url "https://api.linear.app/graphql"

@doc """
Expand Down Expand Up @@ -50,6 +52,21 @@ defmodule LinearCli.Api do
# Guard: only match when data is a map (the expected shape). A null top-level
# "data" means the entire operation failed; in that case the errors clause
# below provides the more informative result.
#
# The more-specific partial-success clause (both keys present) runs first so
# the field-level errors are logged before the data is returned. The general
# data-only clause follows as a fallback.
defp handle_response(
{:ok, %Req.Response{status: 200, body: %{"data" => data, "errors" => [_ | _] = errors}}}
)
when is_map(data) do
Logger.warning(
"Linear API partial-success: #{length(errors)} field error(s) discarded, data returned"
)

{:ok, data}
end

defp handle_response({:ok, %Req.Response{status: 200, body: %{"data" => data}}})
when is_map(data) do
{:ok, data}
Expand Down
57 changes: 57 additions & 0 deletions app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,63 @@ defmodule LinearCli.CLI do
halt.(88)
end

# LinearCli.Api.call/2's {:error, {:http_error, status, body}} for 401/403 -
# bad or expired API key. Give a targeted message instead of dumping the raw
# error structure. ManualRead else clauses normalize the 3-tuple to 2-tuple
# {:http_error, status} so Ash (via Splode) stores it in value: [{:http_error, status}].
defp handle_error(
%Ash.Error.Unknown{errors: [%{value: [{:http_error, status}]} | _]},
debug,
halt
)
when status in [401, 403] do
IO.puts(:stderr, "Linear API authentication failed (HTTP #{status}).")
IO.puts(:stderr, "Check that LINEAR_API_KEY is valid.")
IO.puts(:stderr, "** Authentication error, cannot continue **")
maybe_print_backtrace(debug)
halt.(77)
end

# LinearCli.Api.call/2's {:error, {:http_error, status, body}} for any other
# non-200 status (rate-limit 429, server errors 5xx, etc.).
defp handle_error(
%Ash.Error.Unknown{errors: [%{value: [{:http_error, status}]} | _]},
debug,
halt
) do
IO.puts(:stderr, "Linear API returned HTTP #{status}.")
IO.puts(:stderr, "** API Error, Cannot Continue **")
maybe_print_backtrace(debug)
halt.(88)
end

# LinearCli.Api.call/2's {:error, {:transport_error, exception}} - DNS failure,
# timeout, connection refused, etc. Ash wraps as %{value: [{:transport_error, ...}]}.
defp handle_error(
%Ash.Error.Unknown{errors: [%{value: [{:transport_error, _exception}]} | _]},
debug,
halt
) do
IO.puts(:stderr, "Could not reach the Linear API.")
IO.puts(:stderr, "** Network error, cannot continue **")
maybe_print_backtrace(debug)
halt.(69)
end

# LinearCli.Api.call/2's {:error, {:unexpected_response, body}} - a 200 with
# neither "data" nor "errors", or a caller that received an unexpected data shape.
# More specific than the catch-all so users get a targeted message.
defp handle_error(
%Ash.Error.Unknown{errors: [%{value: [{:unexpected_response, _body}]} | _]},
debug,
halt
) do
IO.puts(:stderr, "Linear API returned an unexpected response.")
IO.puts(:stderr, "** API Error, Cannot Continue **")
maybe_print_backtrace(debug)
halt.(88)
end

# Ported from CLI::Caller#call's catch-all `rescue StandardError` clause.
defp handle_error(error, debug, halt) do
IO.puts(:stderr, "What the heck is this? #{Exception.format_banner(:error, error)}")
Expand Down
38 changes: 28 additions & 10 deletions app/lib/linear_cli/linear/label.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,18 @@ defmodule LinearCli.Linear.Label.Read.ByNames do
def read(query, _ecto_query, _opts, _context) do
names = query.arguments.names

with {:ok, %{"issueLabels" => %{"edges" => edges}}} <-
Api.call(@document, %{"names" => names}) do
{:ok, Enum.map(edges, &Label.from_map(&1["node"]))}
case Api.call(@document, %{"names" => names}) do
{:ok, %{"issueLabels" => %{"edges" => edges}}} ->
{:ok, Enum.map(edges, &Label.from_map(&1["node"]))}

{:ok, other} ->
{:error, {:unexpected_response, other}}

{:error, {:http_error, status, _body}} ->
{:error, {:http_error, status}}

{:error, reason} ->
{:error, reason}
end
end
end
Expand Down Expand Up @@ -104,14 +113,23 @@ defmodule LinearCli.Linear.Label.Read.ByTeam do
def read(query, _ecto_query, _opts, _context) do
team_id = query.arguments.team_id

with {:ok, %{"team" => %{"labels" => %{"nodes" => nodes}}}} <-
Api.call(@document, %{"teamId" => team_id}) do
labels =
nodes
|> Enum.reject(&(&1["isGroup"] || &1["parent"]))
|> Enum.map(&Label.from_map/1)
case Api.call(@document, %{"teamId" => team_id}) do
{:ok, %{"team" => %{"labels" => %{"nodes" => nodes}}}} ->
labels =
nodes
|> Enum.reject(&(&1["isGroup"] || &1["parent"]))
|> Enum.map(&Label.from_map/1)

{:ok, labels}

{:ok, other} ->
{:error, {:unexpected_response, other}}

{:error, {:http_error, status, _body}} ->
{:error, {:http_error, status}}

{:ok, labels}
{:error, reason} ->
{:error, reason}
end
end
end
24 changes: 22 additions & 2 deletions app/lib/linear_cli/linear/paginate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,35 @@ defmodule LinearCli.Linear.Paginate do
end

defp do_all(document, field_name, variables_fun, decode_fun, after_cursor, max, acc) do
with {:ok, data} <- Api.call(document, variables_fun.(after_cursor)) do
%{"edges" => edges, "pageInfo" => page_info} = Map.fetch!(data, field_name)
with {:ok, data} <- Api.call(document, variables_fun.(after_cursor)),
{:ok, %{"edges" => edges, "pageInfo" => page_info}} <-
fetch_connection(data, field_name) do
acc = acc ++ Enum.map(edges, &decode_fun.(&1["node"]))

if length(acc) >= max or !page_info["hasNextPage"] do
{:ok, Enum.take(acc, max)}
else
do_all(document, field_name, variables_fun, decode_fun, page_info["endCursor"], max, acc)
end
else
{:error, {:http_error, status, _body}} -> {:error, {:http_error, status}}
{:error, reason} -> {:error, reason}
end
end

# Safely extracts the named connection from the response data. Returns
# {:error, {:unexpected_response, ...}} instead of crashing with KeyError
# when the field is absent or not the expected connection shape.
defp fetch_connection(data, field_name) do
case data do
%{^field_name => %{"edges" => _, "pageInfo" => _} = connection} ->
{:ok, connection}

%{^field_name => other} ->
{:error, {:unexpected_response, other}}

_ ->
{:error, {:unexpected_response, data}}
end
end
end
56 changes: 42 additions & 14 deletions app/lib/linear_cli/linear/project.ex
Original file line number Diff line number Diff line change
Expand Up @@ -231,23 +231,42 @@ defmodule LinearCli.Linear.Project.Read.ByTeam do
do: %{"teamId" => team_id, "after" => after_cursor},
else: %{"teamId" => team_id}

with {:ok, %{"team" => %{"projects" => projects}}} <- Api.call(@document, variables) do
acc = acc ++ Enum.map(projects["nodes"] || [], &Project.from_map/1)
case Api.call(@document, variables) do
{:ok, %{"team" => %{"projects" => projects}}} ->
acc = acc ++ Enum.map(projects["nodes"] || [], &Project.from_map/1)

case projects["pageInfo"] do
%{"hasNextPage" => true, "endCursor" => cursor} when is_binary(cursor) ->
page(team_id, cursor, acc)
case projects["pageInfo"] do
%{"hasNextPage" => true, "endCursor" => cursor} when is_binary(cursor) ->
page(team_id, cursor, acc)

_ ->
{:ok, acc}
end
_ ->
{:ok, acc}
end

{:ok, other} ->
{:error, {:unexpected_response, other}}

{:error, {:http_error, status, _body}} ->
{:error, {:http_error, status}}

{:error, reason} ->
{:error, reason}
end
end

defp fetch(document, variables) do
with {:ok, %{"team" => %{"projects" => %{"nodes" => nodes}}}} <-
Api.call(document, variables) do
{:ok, Enum.map(nodes, &Project.from_map/1)}
case Api.call(document, variables) do
{:ok, %{"team" => %{"projects" => %{"nodes" => nodes}}}} ->
{:ok, Enum.map(nodes, &Project.from_map/1)}

{:ok, other} ->
{:error, {:unexpected_response, other}}

{:error, {:http_error, status, _body}} ->
{:error, {:http_error, status}}

{:error, reason} ->
{:error, reason}
end
end

Expand Down Expand Up @@ -295,9 +314,18 @@ defmodule LinearCli.Linear.Project.Read.ByName do
def read(query, _ecto_query, _opts, _context) do
name = query.arguments.name

with {:ok, %{"projects" => %{"nodes" => nodes}}} <-
Api.call(document(), %{"name" => name}) do
{:ok, Enum.map(nodes, &Project.from_map/1)}
case Api.call(document(), %{"name" => name}) do
{:ok, %{"projects" => %{"nodes" => nodes}}} ->
{:ok, Enum.map(nodes, &Project.from_map/1)}

{:ok, other} ->
{:error, {:unexpected_response, other}}

{:error, {:http_error, status, _body}} ->
{:error, {:http_error, status}}

{:error, reason} ->
{:error, reason}
end
end

Expand Down
20 changes: 15 additions & 5 deletions app/lib/linear_cli/linear/team.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,21 @@ defmodule LinearCli.Linear.Team.Read.Find do
def read(query, _ecto_query, _opts, _context) do
id = query.arguments.id

with {:ok, %{"team" => team_map}} <- Api.call(document(), %{"id" => id}) do
case team_map do
nil -> {:ok, []}
_ -> {:ok, [Team.from_map(team_map)]}
end
case Api.call(document(), %{"id" => id}) do
{:ok, %{"team" => nil}} ->
{:ok, []}

{:ok, %{"team" => team_map}} ->
{:ok, [Team.from_map(team_map)]}

{:ok, other} ->
{:error, {:unexpected_response, other}}

{:error, {:http_error, status, _body}} ->
{:error, {:http_error, status}}

{:error, reason} ->
{:error, reason}
end
end

Expand Down
14 changes: 12 additions & 2 deletions app/lib/linear_cli/linear/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,18 @@ defmodule LinearCli.Linear.User.Read.Me do
def read(_query, _ecto_query, _opts, _context) do
document = "{ viewer { #{User.fields_with_teams()} } }"

with {:ok, %{"viewer" => viewer}} <- Api.call(document) do
{:ok, [User.from_map(viewer)]}
case Api.call(document) do
{:ok, %{"viewer" => viewer}} when is_map(viewer) ->
{:ok, [User.from_map(viewer)]}

{:ok, other} ->
{:error, {:unexpected_response, other}}

{:error, {:http_error, status, _body}} ->
{:error, {:http_error, status}}

{:error, reason} ->
{:error, reason}
end
end
end
Expand Down
15 changes: 12 additions & 3 deletions app/lib/linear_cli/linear/workflow_state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,18 @@ defmodule LinearCli.Linear.WorkflowState.Read.ByTeam do
def read(query, _ecto_query, _opts, _context) do
team_id = query.arguments.team_id

with {:ok, %{"team" => %{"states" => %{"nodes" => nodes}}}} <-
Api.call(@document, %{"teamId" => team_id}) do
{:ok, Enum.map(nodes, &WorkflowState.from_map/1)}
case Api.call(@document, %{"teamId" => team_id}) do
{:ok, %{"team" => %{"states" => %{"nodes" => nodes}}}} ->
{:ok, Enum.map(nodes, &WorkflowState.from_map/1)}

{:ok, other} ->
{:error, {:unexpected_response, other}}

{:error, {:http_error, status, _body}} ->
{:error, {:http_error, status}}

{:error, reason} ->
{:error, reason}
end
end
end
Loading