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
36 changes: 35 additions & 1 deletion app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ defmodule LinearCli.CLI do

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

Expand Down Expand Up @@ -245,6 +245,9 @@ defmodule LinearCli.CLI do
defp dispatch([:issue, :relation, :list], result, halt),
do: run(&Commands.issue_relation_list/1, result, halt)

defp dispatch([:issue, :relation, :add], result, halt),
do: run(&Commands.issue_relation_add/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 @@ -933,6 +936,37 @@ defmodule LinearCli.CLI do
required: true
]
]
],
add: [
name: "add",
about: """
Add a relationship from ISSUE to one or more RELATED_ISSUEs (alias: a).

Direction table:
blocks — ISSUE blocks each RELATED_ISSUE
blocked-by — ISSUE is blocked by each RELATED_ISSUE
related — ISSUE is related to each RELATED_ISSUE
duplicate — ISSUE is a duplicate of each RELATED_ISSUE

Adding an already-existing identical relation is a no-op.
""",
allow_unknown_args: true,
options: [
type: [
short: "-t",
long: "--type",
help: "Relationship type: blocks, blocked-by, related, duplicate",
required: true,
parser: fn
v when v in ["blocks", "blocked-by", "related", "duplicate"] ->
{:ok, v}

v ->
{:error,
"must be one of: blocks, blocked-by, related, duplicate (got #{inspect(v)})"}
end
]
]
]
]
]
Expand Down
154 changes: 154 additions & 0 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,160 @@ defmodule LinearCli.CLI.Commands do
end
end

@doc """
Adds a relationship from `ISSUE` to one or more `RELATED_ISSUE`s.

The first element of `unknown` is the subject issue; the remaining
elements are the related issues. `--type` controls direction:

* `blocks` — subject blocks each related issue (wire: `blocks`, subject → related)
* `blocked-by` — subject is blocked by each related issue (wire: `blocks`, reversed: related → subject)
* `related` — subject is related to each related issue
* `duplicate` — subject is a duplicate of each related issue

Each target is processed independently; partial failures do not roll back
successful mutations. All results are printed before returning; a non-zero
exit identifies the overall failure count if any target failed.
"""
@spec issue_relation_add(Optimus.ParseResult.t()) :: :ok | {:error, term()}
def issue_relation_add(%{unknown: []}),
do: {:error, {:smells_bad, "ISSUE and at least one RELATED_ISSUE are required"}}

def issue_relation_add(%{unknown: [_subject]}),
do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}}

def issue_relation_add(%{unknown: [subject_id | related_ids], options: options}) do
expanded_subject = IssueHelpers.expand_issue_id(subject_id)
user_type = options.type

results =
Enum.map(related_ids, fn related_id ->
expanded_related = IssueHelpers.expand_issue_id(related_id)
add_single_relation(expanded_subject, expanded_related, user_type)
end)

print_relation_add_results(results, options.output)

failed_count =
Enum.count(results, fn r -> match?({:failed, _, _}, r) or match?({:self_link, _}, r) end)

if failed_count > 0 do
{:error, {:smells_bad, "#{failed_count} relation(s) failed to be created"}}
else
:ok
end
end

defp add_single_relation(subject_id, related_id, _user_type) when subject_id == related_id do
{:self_link, subject_id}
end

defp add_single_relation(subject_id, related_id, user_type) do
{wire_issue_id, wire_related_id, wire_type, direction} =
if user_type == "blocked-by" do
{related_id, subject_id, "blocks", :inbound}
else
{subject_id, related_id, user_type, :outbound}
end

case Linear.create_issue_relation(wire_issue_id, wire_related_id, wire_type) do
{:ok, relation} ->
{:created, related_id, %{relation | direction: direction}}

{:error, %Ash.Error.Unknown{errors: [%{value: [{:duplicate_relation, _}]} | _]}} ->
{:exists, related_id}

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

defp print_relation_add_results(results, output) do
if output == "json" do
results
|> Enum.map(&relation_add_result_to_plain/1)
|> Jason.encode!(pretty: true)
|> IO.puts()
else
Enum.each(results, &print_relation_add_result_text/1)
end
end

defp relation_add_result_to_plain({:created, related_id, relation}) do
%{
"target" => related_id,
"status" => "created",
"relation" => Display.relation_to_plain(relation)
}
end

defp relation_add_result_to_plain({:exists, related_id}) do
%{"target" => related_id, "status" => "exists"}
end

defp relation_add_result_to_plain({:self_link, id}) do
%{
"target" => id,
"status" => "error",
"message" => "self-link: an issue cannot be related to itself"
}
end

defp relation_add_result_to_plain({:failed, related_id, reason}) do
msg = reason |> relation_add_error_message() |> truncate_message(200)
%{"target" => related_id, "status" => "error", "message" => msg}
end

defp print_relation_add_result_text({:created, _related_id, relation}) do
IO.puts(relation_add_created_text(relation))
end

defp print_relation_add_result_text({:exists, related_id}) do
Prompt.ok("#{related_id}: relation already exists (no change)")
end

defp print_relation_add_result_text({:self_link, id}) do
IO.puts(:stderr, "#{id}: self-link — an issue cannot be related to itself")
end

defp print_relation_add_result_text({:failed, related_id, reason}) do
msg = relation_add_error_message(reason)
IO.puts(:stderr, "#{related_id}: #{msg}")
end

defp relation_add_created_text(%{type: "blocks", issue: issue, related_issue: related}) do
"#{issue.identifier} now blocks #{related.identifier}"
end

defp relation_add_created_text(%{type: "related", issue: issue, related_issue: related}) do
"#{issue.identifier} is now related to #{related.identifier}"
end

defp relation_add_created_text(%{type: "duplicate", issue: issue, related_issue: related}) do
"#{issue.identifier} is now a duplicate of #{related.identifier}"
end

defp relation_add_created_text(%{type: type, issue: issue, related_issue: related}) do
"#{issue.identifier} is now a #{type} of #{related.identifier}"
end

defp relation_add_error_message(%Ash.Error.Unknown{
errors: [%{value: [{:graphql_errors, [%{"message" => msg} | _]}]} | _]
}),
do: "Linear API error: #{msg}"

defp relation_add_error_message(%Ash.Error.Unknown{
errors: [%Ash.Error.Unknown.UnknownError{error: "unknown error: :missing_api_key"} | _]
}),
do: "LINEAR_API_KEY is not set"

defp relation_add_error_message(_reason), do: "unexpected error"

defp truncate_message(msg, max) when byte_size(msg) > max,
do: String.slice(msg, 0, max) <> "…"

defp truncate_message(msg, _max), do: msg

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

defp resolve_optional_status(issue, name) do
Expand Down
3 changes: 3 additions & 0 deletions app/lib/linear_cli/cli/display.ex
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ defmodule LinearCli.CLI.Display do
" #{String.pad_trailing(identifier, 10)} #{title} [#{type}/#{id}]"
end

@doc "Returns a plain-map representation of an IssueRelation suitable for JSON encoding."
def relation_to_plain(%IssueRelation{} = relation), do: to_plain(relation)

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

defp to_plain(%_struct{} = record) do
Expand Down
1 change: 1 addition & 0 deletions app/lib/linear_cli/linear.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ defmodule LinearCli.Linear do

resource LinearCli.Linear.IssueRelation do
define :issue_relations, action: :list, args: [:issue_id]
define :create_issue_relation, action: :create, args: [:issue_id, :related_issue_id, :type]
end
end
end
50 changes: 50 additions & 0 deletions app/lib/linear_cli/linear/issue_relation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ defmodule LinearCli.Linear.IssueRelation do
argument :issue_id, :string, allow_nil?: false
manual LinearCli.Linear.IssueRelation.Read.List
end

create :create do
argument :issue_id, :string, allow_nil?: false
argument :related_issue_id, :string, allow_nil?: false
argument :type, :string, allow_nil?: false
manual LinearCli.Linear.IssueRelation.Create
end
end

attributes do
Expand Down Expand Up @@ -57,6 +64,49 @@ defmodule LinearCli.Linear.IssueRelation do
end
end

defmodule LinearCli.Linear.IssueRelation.Create do
@moduledoc false
use Ash.Resource.ManualCreate

alias LinearCli.Api
alias LinearCli.Linear.IssueRelation

def create(changeset, _opts, _context) do
args = changeset.arguments

case Api.call(document(), %{
"issueId" => args.issue_id,
"relatedIssueId" => args.related_issue_id,
"type" => args.type
}) do
{:ok, %{"issueRelationCreate" => %{"issueRelation" => rel_map}}} when is_map(rel_map) ->
{:ok, IssueRelation.from_map(rel_map, :outbound)}

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

{:error, {:graphql_errors, [%{"message" => message} | _] = errors}} ->
if duplicate_error?(message) do
{:error, {:duplicate_relation, message}}
else
{:error, {:graphql_errors, errors}}
end

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

defp duplicate_error?(message) do
message = String.downcase(message)
String.contains?(message, "already exists") or String.contains?(message, "duplicate")
end

defp document do
"mutation($issueId: String!, $relatedIssueId: String!, $type: IssueRelationType!) { issueRelationCreate(input: { issueId: $issueId, relatedIssueId: $relatedIssueId, type: $type }) { issueRelation { #{IssueRelation.relation_fields()} } success } }"
end
end

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