diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..241bbf5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2026 Tj (bougyman) Vanderpoel + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. + diff --git a/Readme.adoc b/Readme.adoc new file mode 100644 index 0000000..0b6508c --- /dev/null +++ b/Readme.adoc @@ -0,0 +1,236 @@ += Linear Command line interface (Elixir) +:toc: right +:toclevels: 3 +:sectanchors: +:icons: font +:tip-caption: 💡 +:note-caption: 📝 +:experimental: +:conventional-commits: https://www.conventionalcommits.org/en/v1.0.0/[Conventional Commits] + +A command line interface to https://linear.app. + +This is an Elixir/OTP port of https://github.com/rubyists/linear-cli[linear-cli] (the +original Ruby CLI) - same commands, same wrapper scripts, but a single standalone +binary (via https://github.com/burrito-elixir/burrito[Burrito]) instead of a Ruby +gem/runtime, and concurrent fan-out (via `Task.async_stream`) for the operations +that fetch or update several issues at once. + +== Installation + +=== I don't want to install + +You can run the CLI from the container image without installing anything. + +[source,sh] +---- +$ podman run --rm -e LINEAR_CLI_DAEMON= -e LINEAR_API_KEY="$LINEAR_API_KEY" ghcr.io/rubyists/linear-cli-ex:v0 lcls <1> +$ docker run --rm -e LINEAR_CLI_DAEMON= -e LINEAR_API_KEY="$LINEAR_API_KEY" ghcr.io/rubyists/linear-cli-ex:v0 lcls <2> +---- +<1> Podman usage +<2> Docker usage - `:v0` tracks the latest 0.x release; pin `:v0.1.2` (or whichever version) for a fixed image + +IMPORTANT: The image sets `LINEAR_CLI_DAEMON=true` by default so its own +entrypoint can also run as the long-lived Oban scheduling daemon. Override it +to an empty value (as above) for one-off interactive CLI usage - otherwise +`lc`/the wrapper scripts never see your command at all. + +=== Download a release binary (Most should use this) + +Grab the binary for your platform from the +https://github.com/rubyists/linear-cli-ex/releases[latest release] - macOS +(Apple Silicon), Linux (x86_64), and Windows (x86_64) are all built as +standalone executables with no Erlang/Elixir install required. + +[source,sh] +---- +$ curl -sLo lc https://github.com/rubyists/linear-cli-ex/releases/latest/download/lc_macos_aarch64 +$ chmod +x lc +$ sudo mv lc /usr/local/bin/lc +---- + +NOTE: A Homebrew tap is planned but not yet available. + +=== install.sh (builds from source, for machines without Homebrew) + +[source,sh] +---- +$ git clone https://github.com/rubyists/linear-cli-ex.git +$ cd linear-cli-ex +$ ./install.sh +---- + +Builds a native release via https://mise.jdx.dev[mise] and installs `lc` plus +the `bin/` wrapper scripts onto a directory already on your `$PATH`. Run +`./uninstall.sh` to remove exactly what it installed. + +=== From Source (You are obviously a developer) + +[source,sh] +---- +$ git clone https://github.com/rubyists/linear-cli-ex.git +$ cd linear-cli-ex/app +$ mise install +$ mise exec -- mix deps.get +$ mise exec -- env MIX_ENV=prod mix release lc +$ ./burrito_out/lc_ --version +---- + +== Usage + +=== Configuration + +You must set the `LINEAR_API_KEY` environment variable to your Linear API key. +You can find your API key in your https://linear.app/settings/api[Linear Settings]. + +=== Commands + +==== Help + +You can get help/usage for any command or subcommand by using the `--help` flag. + +[source,sh] +---- +$ lc --help +$ lc [COMMAND] --help +$ lc [COMMAND] [SUBCOMMAND] --help +---- + +==== Who Am I? + +[source,sh] +---- +$ lc whoami +$ lc whoami --teams <1> +---- +<1> Include the teams you are a member of + +==== List Issues + +`lcls` is a wrapper script provided to list issues. It's an alias for `lc issue list`. + +[source,sh] +---- +$ lcls +$ lcls --full +$ lcls -f CRY-1 +---- + +==== Assign one or more issues to yourself (take em!) + +[source,sh] +---- +$ lc issue take CRY-1234 +$ lc issue take CRY-456 CRY-789 +---- + +==== Create an issue + +[source,sh] +---- +$ lc issue create --title "My new issue" --description "This is a new issue" --labels Bug,Feature --team CRY +$ lc issue create -t "My new issue" -T CRY -l Improvement,Feature +---- + +NOTE: If you don't provide a title, team, labels, or description, you will be prompted to enter them. + +TIP: When creating an issue, you can use the `--dev` (or `--develop`) option to immediately start development on the issue. + +==== Develop an issue + +Switches to the branch for the issue, creating the branch if it doesn't exist. + +[source,sh] +---- +$ lc issue develop CRY-1234 +---- + +===== Create a Pull Request (using the semantic PR title) + +Requires the `gh` CLI to be installed and configured. + +[source,sh] +---- +$ lc issue pr CRY-1234 +---- + +==== Update an issue + +All of the update options can work on multiple issues at a time. + +===== Add a comment to one or more issues + +[source,sh] +---- +$ lc issue update --comment "Here is a comment" CRY-1234 <1> +$ lc issue update --close --reason "I do not like you" CRY-14 CRY-15 <2> +$ lc issue update --cancel --trash --reason "I have no idea why you are here" CRY-16 CRY-17 <3> +$ lc issue update --comment - CRY-14 CRY-15 <4> +$ lcomment CRY-1234 CRY-3 <5> +---- +<1> Comments on the issue with the provided text +<2> Closes multiple issues without prompting (reason is added as a comment) +<3> Cancels multiple issues without prompting and moves them to the trash (reason is added as a comment) +<4> Opens your editor for the comment (use `-` to prompt) +<5> Always prompts for a comment (`lcomment` is a wrapper for `lc issue update --comment -`) + +===== Close one or many issues + +[source,sh] +---- +$ lc issue update --close --reason "These were closable" CRY-1234 CRY-2 +---- + +=== Wrapper scripts + +The `bin/` wrapper scripts (ported verbatim from `linear-cli`'s own `exe/scripts/`) +make the common commands shorter to type. They just `exec lc ...`, so they work +anywhere `lc` is on your `$PATH`. + +[source,sh] +---- +$ lcls +$ lcreate --description "This is a new issue" --labels Bug,Feature --team CRY +$ lclose --reason "This issue sucks" CRY-1234 CRY-456 +$ lcomment CRY-1234 +$ lproj list --mine +---- + +== Development + +The project uses ExUnit and `mix format`. Run tests with: + +[source,sh] +---- +$ cd app +$ mise exec -- mix test +$ mise exec -- mix format --check-formatted +---- + +=== Build a release binary + +[source,sh] +---- +$ cd app +$ mise exec -- env MIX_ENV=prod BURRITO_TARGET= mix release lc +---- + +`` is one of `macos_aarch64`, `linux_x86_64`, or `windows_x86_64`. + +=== Build the container + +[source,sh] +---- +$ ./ci/build_image.sh +---- + +=== Conventional Commits (semantic commit messages) + +This project follows the {conventional-commits} specification. + +To contribute, please follow that commit message format, or your pull request +may be rejected. + +== License + +WTFPL diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 7f023c7..145d34b 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -307,7 +307,13 @@ defmodule LinearCli.CLI do full: [short: "-f", long: "--full", help: "Show full issue details"] ], options: [ - team: [short: "-t", long: "--team", help: "Show issues for only this team"] + team: [short: "-t", long: "--team", help: "Show issues for only this team"], + project: [ + short: "-p", + long: "--project", + help: + "Show issues for only this project. Can be name, URL, ID, or - to select from a list" + ] ] ], create: [ diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 00710ef..b593ff4 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -4,7 +4,7 @@ defmodule LinearCli.CLI.Commands do result. Ported from vendor/ruby-linear-cli/lib/linear/commands/**. """ - alias LinearCli.CLI.{Display, IssueHelpers, Prompt} + alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt} alias LinearCli.{Git, Linear} @doc "Ported from commands/whoami.rb." @@ -51,21 +51,38 @@ defmodule LinearCli.CLI.Commands do @doc """ Ported from commands/issue/list.rb + operations/issue/list.rb. - `--project`/`-p` (which needs the interactive project picker from - `CLI::Projects#project_for`) isn't wired up yet - deferred to the phase - that builds `Owl`-based prompts. + `--project`/`-p` is resolved the same way Ruby's `CLI::Projects#project_for` + does - against every project in the workspace (`Project.all`, not + team-scoped), prompting interactively when the search is ambiguous or + omitted-but-requested (`-p -`). Only resolved at all when `--project` was + actually given - unlike `issue create`/`issue update`, a bare `issue list` + applies no project filter and never prompts. """ def issue_list(%{flags: flags, options: options, unknown: ids}) do - input = %{ - ids: ids, - mine: !flags.no_mine, - unassigned: flags.unassigned, - team_key: options.team - } - - with {:ok, issues} <- Linear.issues(input) do - Display.show(issues, %{output: options.output, full: flags.full}) - :ok + with {:ok, project_id} <- resolve_project_id(options.project) do + input = %{ + ids: ids, + mine: !flags.no_mine, + unassigned: flags.unassigned, + team_key: options.team, + project_id: project_id + } + + with {:ok, issues} <- Linear.issues(input) do + Display.show(issues, %{output: options.output, full: flags.full}) + :ok + end + end + end + + defp resolve_project_id(nil), do: {:ok, nil} + + defp resolve_project_id(search) do + with {:ok, projects} <- Linear.projects() do + case Projects.project_for(projects, search) do + nil -> {:ok, nil} + project -> {:ok, project.id} + 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 9f3a22f..5f97c8f 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -130,6 +130,79 @@ defmodule LinearCli.CLI.IssueCommandsTest do ) end + # Workspace-wide (not team-scoped) projects query - the shape + # `LinearCli.Linear.Project.Read.All`/`Linear.projects/0` actually use, + # distinct from `team_projects/1`'s team-scoped `nodes` shape above. + defp all_projects(projects) do + %{ + "data" => %{ + "projects" => %{ + "edges" => Enum.map(projects, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => false} + } + } + } + end + + defp issues_response(issues) do + %{ + "data" => %{ + "issues" => %{ + "edges" => Enum.map(issues, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => false} + } + } + } + end + + describe "issue list (Ruby: commands/issue/list.rb + operations/issue/list.rb)" do + test "--project resolves against every workspace project and filters the issue query by it" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "projects(first: $first") -> + Req.Test.json(conn, all_projects([project_map("p1", "Manhattan Rollout")])) + + String.contains?(query, "issues(filter") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--project", "Manhattan Rollout"]) + end) + + assert output =~ "CRY-1" + assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p1"}}}} + end + + test "bare issue list applies no project filter and never queries projects at all" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + if String.contains?(query, "projects(") do + raise "issue list must not query projects when --project wasn't given" + end + + Req.Test.json(conn, issues_response([issue_map()])) + end) + + output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) + assert output =~ "CRY-1" + end + end + describe "issue create (Ruby: commands/issue/create.rb)" do test "resolves every field, declines to take it, and displays the created issue" do stub_responses([