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
42 changes: 41 additions & 1 deletion app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,33 @@ defmodule LinearCli.CLI do
"profile" => %{"l" => "list", "ls" => "list"}
}

@nested_subcommand_aliases %{
"issue" => %{
"relation" => %{"l" => "list", "ls" => "list"}
}
}

@doc false
def normalize_subcommand_aliases([first | rest]) do
canonical_first = Map.get(@command_aliases, first, first)

case {Map.fetch(@subcommand_aliases, canonical_first), rest} do
{{:ok, sub_aliases}, [second | more]} ->
[canonical_first, Map.get(sub_aliases, second, second) | more]
canonical_second = Map.get(sub_aliases, second, second)

case {Map.fetch(@nested_subcommand_aliases, canonical_first), more} do
{{:ok, nested}, [third | rest2]} ->
case Map.fetch(nested, canonical_second) do
{:ok, third_aliases} ->
[canonical_first, canonical_second, Map.get(third_aliases, third, third) | rest2]

:error ->
[canonical_first, canonical_second | more]
end

_ ->
[canonical_first, canonical_second | more]
end

_ ->
[canonical_first | rest]
Expand Down Expand Up @@ -222,6 +242,9 @@ defmodule LinearCli.CLI do
defp dispatch([:issue, :status], result, halt), do: run(&Commands.issue_status/1, result, halt)
defp dispatch([:issue, :update], result, halt), do: run(&Commands.issue_update/1, result, halt)

defp dispatch([:issue, :relation, :list], result, halt),
do: run(&Commands.issue_relation_list/1, result, halt)

# A valid subcommand path that stops short of a leaf (e.g. `lc project`
# with nothing after it) - Optimus itself doesn't require reaching a leaf,
# it just returns an empty ParseResult, so without this clause it would
Expand Down Expand Up @@ -895,6 +918,23 @@ defmodule LinearCli.CLI do
],
reason: [long: "--reason", help: "Reason for closing the issue. - open an editor"]
]
],
relation: [
name: "relation",
about: "Manage issue relationships (blocks, blocked-by, related, duplicate)",
subcommands: [
list: [
name: "list",
about: "List relationships for an issue (alias: ls)",
args: [
issue_id: [
value_name: "ISSUE",
help: "The issue to list relationships for (e.g. EXT-1)",
required: true
]
]
]
]
]
]
]
Expand Down
18 changes: 18 additions & 0 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,24 @@ defmodule LinearCli.CLI.Commands do
end
end

@doc """
Lists the relationships for a single issue — both outbound (issues this one
blocks/is-related-to/is-duplicate-of) and inbound (issues that block this
one, etc.).

Calls `Linear.issue_relations/1` which fetches both `relations` and
`inverseRelations` from Linear and tags each with a direction.
"""
@spec issue_relation_list(Optimus.ParseResult.t()) :: :ok | {:error, term()}
def issue_relation_list(%{args: %{issue_id: issue_id}, options: options}) do
expanded_id = IssueHelpers.expand_issue_id(issue_id)

with {:ok, relations} <- Linear.issue_relations(expanded_id) do
Display.show(relations, %{output: options.output, relations: true})
:ok
end
end

defp resolve_optional_status(_issue, nil), do: {:ok, nil}

defp resolve_optional_status(issue, name) do
Expand Down
75 changes: 72 additions & 3 deletions app/lib/linear_cli/cli/display.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule LinearCli.CLI.Display do
"""

alias LinearCli.CLI.Pager
alias LinearCli.Linear.{Comment, Issue, Project, ProjectUpdate, Team, User}
alias LinearCli.Linear.{Comment, Issue, IssueRelation, Project, ProjectUpdate, Team, User}
alias LinearCli.Profiles.Profile

@ash_internal_fields ~w(__meta__ __metadata__ __order__ __lateral_join_source__ aggregates calculations)a
Expand All @@ -25,11 +25,21 @@ defmodule LinearCli.CLI.Display do
if Map.get(opts, :output, "text") == "json" do
subject |> to_plain() |> Jason.encode!(pretty: true) |> IO.puts()
else
text = subject |> List.wrap() |> Enum.map_join("\n", &format(&1, opts))
text = format_text(subject, opts)
Pager.maybe_page(text, opts)
end
end

defp format_text([%IssueRelation{} | _] = relations, _opts), do: relations_block(relations)

defp format_text(subject, opts) do
subject |> List.wrap() |> Enum.map_join("\n", &format(&1, opts))
end

defp format(%IssueRelation{} = relation, _opts) do
relation_line(relation)
end

defp format(%Team{} = team, _opts) do
"#{String.pad_trailing(team.key || "", 6)} #{team.name}"
end
Expand Down Expand Up @@ -99,7 +109,13 @@ defmodule LinearCli.CLI.Display do
description = render_markdown(issue.description)
comments = Enum.map_join(issue.comments, "\n", &comment_block/1)

[header, sep, labels, description, comments]
all_relations =
List.wrap(Map.get(issue, :relations, [])) ++
List.wrap(Map.get(issue, :inverse_relations, []))

relations_text = if all_relations != [], do: relations_block(all_relations), else: ""

[header, sep, labels, description, comments, relations_text]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n")
end
Expand All @@ -116,6 +132,59 @@ defmodule LinearCli.CLI.Display do
defp render_markdown(""), do: render_markdown("# No description for this issue")
defp render_markdown(text), do: Marcli.render(text)

@direction_labels %{
blocks: {"Blocks", :outbound, "blocks"},
blocked_by: {"Blocked by", :inbound, "blocks"},
related: {"Related to", nil, "related"},
duplicate: {"Duplicate of", nil, "duplicate"},
similar: {"Similar to", nil, "similar"}
}

defp relations_block(relations) do
grouped = Enum.group_by(relations, &relation_section_key/1)

section_order = [:blocks, :blocked_by, :related, :duplicate, :similar]

section_order
|> Enum.flat_map(fn key ->
case Map.get(grouped, key) do
nil ->
[]

rels ->
{label, _dir, _type} = @direction_labels[key]
lines = Enum.map(rels, &relation_line/1)
["#{label}:" | lines]
end
end)
|> Enum.join("\n")
end

defp relation_section_key(%IssueRelation{direction: :outbound, type: "blocks"}), do: :blocks
defp relation_section_key(%IssueRelation{direction: :inbound, type: "blocks"}), do: :blocked_by
defp relation_section_key(%IssueRelation{type: "related"}), do: :related
defp relation_section_key(%IssueRelation{type: "duplicate"}), do: :duplicate
defp relation_section_key(%IssueRelation{type: "similar"}), do: :similar
defp relation_section_key(%IssueRelation{}), do: :related

defp relation_line(%IssueRelation{
direction: direction,
type: type,
issue: src,
related_issue: rel,
id: id
}) do
other =
case direction do
:outbound -> rel
:inbound -> src
end

identifier = (other && other.identifier) || "?"
title = (other && other.title) || ""
" #{String.pad_trailing(identifier, 10)} #{title} [#{type}/#{id}]"
end

defp to_plain(list) when is_list(list), do: Enum.map(list, &to_plain/1)

defp to_plain(%_struct{} = record) do
Expand Down
4 changes: 4 additions & 0 deletions app/lib/linear_cli/linear.ex
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,9 @@ defmodule LinearCli.Linear do
resource LinearCli.Linear.ProjectUpdate do
define :post_project_update, action: :create, args: [:project_id, :body]
end

resource LinearCli.Linear.IssueRelation do
define :issue_relations, action: :list, args: [:issue_id]
end
end
end
19 changes: 18 additions & 1 deletion app/lib/linear_cli/linear/issue.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ defmodule LinearCli.Linear.Issue do
attribute :team, :term, public?: true
attribute :comments, {:array, :term}, public?: true, default: []
attribute :labels, {:array, :term}, public?: true, default: []
attribute :relations, {:array, :term}, public?: true, default: []
attribute :inverse_relations, {:array, :term}, public?: true, default: []
end

@issue_fields "id identifier title branchName description url createdAt updatedAt"
Expand All @@ -95,12 +97,17 @@ defmodule LinearCli.Linear.Issue do

@doc "GraphQL field selection for a fully detailed issue, incl. comments (Ruby: Issue.full_fragment)."
def full_fields do
rel = LinearCli.Linear.IssueRelation.relation_fields()
relation_connection = "edges { node { #{rel} } cursor } pageInfo { hasNextPage endCursor }"

"#{@issue_fields} " <>
"state { #{@state_fields} } " <>
"assignee { #{LinearCli.Linear.User.fields_with_teams()} } " <>
"team { #{LinearCli.Linear.Team.full_fields()} } " <>
"comments { nodes { #{LinearCli.Linear.Comment.base_fields()} } } " <>
"labels { nodes { #{LinearCli.Linear.Label.base_fields()} } }"
"labels { nodes { #{LinearCli.Linear.Label.base_fields()} } } " <>
"relations(first: 50) { #{relation_connection} } " <>
"inverseRelations(first: 50) { #{relation_connection} }"
end

@doc false
Expand All @@ -124,6 +131,16 @@ defmodule LinearCli.Linear.Issue do
Enum.map(
get_in(map, ["labels", "nodes"]) || [],
&LinearCli.Linear.Label.from_map/1
),
relations:
Enum.map(
get_in(map, ["relations", "edges"]) || [],
&LinearCli.Linear.IssueRelation.from_map(&1["node"], :outbound)
),
inverse_relations:
Enum.map(
get_in(map, ["inverseRelations", "edges"]) || [],
&LinearCli.Linear.IssueRelation.from_map(&1["node"], :inbound)
)
)
end
Expand Down
135 changes: 135 additions & 0 deletions app/lib/linear_cli/linear/issue_relation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
defmodule LinearCli.Linear.IssueRelation do
@moduledoc """
A directional relation between two Linear issues (blocks, blocked-by, related,
duplicate, similar).

No data layer — all actions call `LinearCli.Api` directly. `direction`
records whether this relation appeared in `Issue.relations` (`:outbound`) or
`Issue.inverseRelations` (`:inbound`), allowing the display layer to render
"Blocks" vs "Blocked by" without the caller having to reconstruct direction
from endpoint order.
"""

use Ash.Resource, domain: LinearCli.Linear

actions do
read :list do
argument :issue_id, :string, allow_nil?: false
manual LinearCli.Linear.IssueRelation.Read.List
end
end

attributes do
attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true
attribute :type, :string, public?: true
attribute :direction, :atom, public?: true
attribute :issue, :term, public?: true
attribute :related_issue, :term, public?: true
end

@endpoint_fields "id identifier title url"

@doc "GraphQL field selection for each issue endpoint inside a relation node."
def endpoint_fields, do: @endpoint_fields

@doc "GraphQL field selection for a full relation node."
def relation_fields do
"id type " <>
"issue { #{endpoint_fields()} } " <>
"relatedIssue { #{endpoint_fields()} }"
end

@doc false
def from_map(map, direction) when is_atom(direction) do
struct!(__MODULE__,
id: map["id"],
type: map["type"],
direction: direction,
issue: endpoint_from_map(map["issue"]),
related_issue: endpoint_from_map(map["relatedIssue"])
)
end

defp endpoint_from_map(nil), do: nil

defp endpoint_from_map(map) do
%{id: map["id"], identifier: map["identifier"], title: map["title"], url: map["url"]}
end
end

defmodule LinearCli.Linear.IssueRelation.Read.List do
@moduledoc false
use Ash.Resource.ManualRead

alias LinearCli.Api
alias LinearCli.Linear.IssueRelation

# Fetches both outbound (`relations`) and inbound (`inverseRelations`)
# concurrently, paginating each independently up to 100 records per direction.
def read(query, _ecto_query, _opts, _context) do
issue_id = query.arguments.issue_id

[outbound_task, inbound_task] = [
Task.async(fn -> fetch_all(issue_id, "relations", :outbound) end),
Task.async(fn -> fetch_all(issue_id, "inverseRelations", :inbound) end)
]

[outbound_result, inbound_result] = Task.await_many([outbound_task, inbound_task], 30_000)

with {:ok, outbound} <- outbound_result,
{:ok, inbound} <- inbound_result do
{:ok, outbound ++ inbound}
end
end

defp fetch_all(issue_id, connection_field, direction) do
fetch_page(issue_id, connection_field, direction, nil, [])
end

defp fetch_page(issue_id, connection_field, direction, after_cursor, acc) do
vars = %{"issueId" => issue_id, "first" => 50, "after" => after_cursor}

case Api.call(document(connection_field), vars) do
{:ok, %{"issue" => nil}} ->
{:error, {:not_found, issue_id}}

{:ok, data} ->
case get_in(data, ["issue", connection_field]) do
%{"edges" => edges, "pageInfo" => page_info} ->
decoded = Enum.map(edges, &IssueRelation.from_map(&1["node"], direction))
acc = acc ++ decoded

if page_info["hasNextPage"] and length(acc) < 100 do
fetch_page(issue_id, connection_field, direction, page_info["endCursor"], acc)
else
{:ok, acc}
end

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

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

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

# A function, not a module attribute: IssueRelation.relation_fields/0 is a
# cross-module call; evaluated at call time so compile-time ordering doesn't
# matter.
defp document(connection_field) do
"""
query($issueId: String!, $first: Int!, $after: String) {
issue(id: $issueId) {
#{connection_field}(first: $first, after: $after) {
edges { node { #{IssueRelation.relation_fields()} } cursor }
pageInfo { hasNextPage endCursor }
}
}
}
"""
end
end
Loading