diff --git a/Readme.adoc b/Readme.adoc index 8789129..0aa77cc 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -224,9 +224,13 @@ Show full details for a single issue (title, description, and all comments): $ lc issue view CRY-1234 $ lc i v CRY-1234 <1> $ lc issue view CRY-1234 --output json <2> +$ lc issue view CRY-1234 --web <3> +$ lc issue view CRY-1234 -w <4> ---- <1> Short alias: `i` for `issue`, `v` for `view` <2> Output as JSON +<3> Open the issue in your browser instead of printing it +<4> Short form of `--web` ==== Assign one or more issues to yourself (take em!) diff --git a/app/lib/linear_cli/browser.ex b/app/lib/linear_cli/browser.ex new file mode 100644 index 0000000..90b373d --- /dev/null +++ b/app/lib/linear_cli/browser.ex @@ -0,0 +1,28 @@ +defmodule LinearCli.Browser do + @moduledoc false + + @doc """ + Opens `url` in the system's default browser. + + Accepts an injectable `opener` in `opts` (a `url -> :ok | {:error, term()}` + function) for test doubles — real callers omit it. + """ + def open_url(url, opts \\ []) do + opener = Keyword.get(opts, :opener, &default_opener/1) + opener.(url) + end + + defp default_opener(url) do + {cmd, args} = + case :os.type() do + {:unix, :darwin} -> {"open", [url]} + {:win32, _} -> {"cmd", ["/c", "start", url]} + _ -> {"xdg-open", [url]} + end + + case System.cmd(cmd, args, stderr_to_stdout: true) do + {_, 0} -> :ok + {output, code} -> {:error, {:browser_open_failed, code, output}} + end + end +end diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index d8e7ec9..0290e77 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -691,6 +691,9 @@ defmodule LinearCli.CLI do about: "Show full details for a single issue", args: [ issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true] + ], + flags: [ + web: [short: "-w", long: "--web", help: "Open the issue in your browser"] ] ], assign: [ diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index d481fb4..ce6856b 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -4,6 +4,7 @@ defmodule LinearCli.CLI.Commands do result. Ported from vendor/ruby-linear-cli/lib/linear/commands/**. """ + alias LinearCli.Browser alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt, WhatFor} alias LinearCli.{Favorites, Git, Linear, Profiles} @@ -291,13 +292,23 @@ defmodule LinearCli.CLI.Commands do Mirrors `gh issue view`: a dedicated verb for the single-issue display case, making it discoverable without knowing about `list`'s `--full` flag. + + With `-w`/`--web`, opens the issue URL in the default browser instead of + printing it. The `opts` keyword arg accepts an injectable `opener` for tests. """ - def issue_view(%{args: %{issue_id: issue_id}, options: options}) do + @spec issue_view(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} + def issue_view(result, opts \\ []) + + def issue_view(%{args: %{issue_id: issue_id}, flags: flags, options: options}, opts) do expanded_id = IssueHelpers.expand_issue_id(issue_id) with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do - Display.show(issue, %{output: options.output, full: true}) - :ok + if flags.web do + Browser.open_url(issue.url, opts) + else + Display.show(issue, %{output: options.output, full: true}) + :ok + end end end diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index f371091..84ed535 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -68,13 +68,14 @@ defmodule LinearCli.Linear.Issue do attribute :title, :string, public?: true attribute :branch_name, :string, public?: true attribute :description, :string, public?: true + attribute :url, :string, public?: true attribute :assignee, :term, public?: true attribute :state, :term, public?: true attribute :team, :term, public?: true attribute :comments, {:array, :term}, public?: true, default: [] end - @issue_fields "id identifier title branchName description createdAt updatedAt" + @issue_fields "id identifier title branchName description url createdAt updatedAt" @state_fields "id name type" @doc "GraphQL field selection for an issue plus its assignee/team (Ruby: Issue.base_fragment)." @@ -102,6 +103,7 @@ defmodule LinearCli.Linear.Issue do title: map["title"], branch_name: map["branchName"], description: map["description"], + url: map["url"], assignee: map["assignee"] && LinearCli.Linear.User.from_map(map["assignee"]), state: map["state"] && LinearCli.Linear.WorkflowState.from_map(map["state"]), team: map["team"] && LinearCli.Linear.Team.from_map(map["team"]), diff --git a/app/test/linear_cli/browser_test.exs b/app/test/linear_cli/browser_test.exs new file mode 100644 index 0000000..0b39203 --- /dev/null +++ b/app/test/linear_cli/browser_test.exs @@ -0,0 +1,28 @@ +defmodule LinearCli.BrowserTest do + use ExUnit.Case, async: true + + alias LinearCli.Browser + + describe "open_url/2" do + test "calls the injected opener with the URL" do + test_pid = self() + + assert :ok = + Browser.open_url("https://linear.app/the-rubyists/issue/EXT-20", + opener: fn url -> + send(test_pid, {:opened, url}) + :ok + end + ) + + assert_received {:opened, "https://linear.app/the-rubyists/issue/EXT-20"} + end + + test "returns the opener's error result unchanged" do + assert {:error, :fake_error} = + Browser.open_url("https://example.com", + opener: fn _url -> {:error, :fake_error} end + ) + end + end +end diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index c6f7d41..80d9b3f 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -570,6 +570,106 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "CRY-1" assert output =~ "Fix the thing" end + + test "--web opens the issue URL in the browser and prints nothing" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => _} = Jason.decode!(body) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) + } + }) + end) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_view( + %{ + args: %{issue_id: "CRY-1"}, + flags: %{web: true}, + options: %{output: "text"} + }, + opener: fn url -> + send(test_pid, {:opened, url}) + :ok + end + ) + end) + + assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} + assert output == "" + end + + test "-w short flag opens the browser via the full CLI dispatch path" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => _} = Jason.decode!(body) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) + } + }) + end) + + capture_io(fn -> + assert :ok = + Commands.issue_view( + %{ + args: %{issue_id: "CRY-1"}, + flags: %{web: true}, + options: %{output: "text"} + }, + opener: fn url -> + send(test_pid, {:opened, url}) + :ok + end + ) + end) + + assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} + end + + test "--web with --output json opens browser and prints nothing" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => _} = Jason.decode!(body) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) + } + }) + end) + + output = + capture_io(fn -> + assert :ok = + Commands.issue_view( + %{ + args: %{issue_id: "CRY-1"}, + flags: %{web: true}, + options: %{output: "json"} + }, + opener: fn url -> + send(test_pid, {:opened, url}) + :ok + end + ) + end) + + assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} + assert output == "" + end end describe "issue create (Ruby: commands/issue/create.rb)" do