diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index e542802..4145e2a 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -8,6 +8,8 @@ defmodule LinearCli.CLI do """ alias LinearCli.CLI.Commands + alias LinearCli.CLI.Commands.{Profiles, Projects, Teams} + alias LinearCli.CLI.Commands.System, as: SystemCmds @workflow_state_types ~w(triage backlog unstarted started completed canceled duplicate) @@ -196,39 +198,39 @@ defmodule LinearCli.CLI do end end - defp dispatch([:whoami], result, halt), do: run(&Commands.whoami/1, result, halt) - defp dispatch([:version], result, halt), do: run(&Commands.version/1, result, halt) - defp dispatch([:team, :list], result, halt), do: run(&Commands.team_list/1, result, halt) + defp dispatch([:whoami], result, halt), do: run(&SystemCmds.whoami/1, result, halt) + defp dispatch([:version], result, halt), do: run(&SystemCmds.version/1, result, halt) + defp dispatch([:team, :list], result, halt), do: run(&Teams.team_list/1, result, halt) defp dispatch([:team, :favorite], result, halt), - do: run(&Commands.team_favorite/1, result, halt) + do: run(&Teams.team_favorite/1, result, halt) defp dispatch([:team, :unfavorite], result, halt), - do: run(&Commands.team_unfavorite/1, result, halt) + do: run(&Teams.team_unfavorite/1, result, halt) - defp dispatch([:project, :list], result, halt), do: run(&Commands.project_list/1, result, halt) + defp dispatch([:project, :list], result, halt), do: run(&Projects.project_list/1, result, halt) defp dispatch([:project, :favorite], result, halt), - do: run(&Commands.project_favorite/1, result, halt) + do: run(&Projects.project_favorite/1, result, halt) defp dispatch([:project, :unfavorite], result, halt), - do: run(&Commands.project_unfavorite/1, result, halt) + do: run(&Projects.project_unfavorite/1, result, halt) defp dispatch([:project, :update], result, halt), - do: run(&Commands.project_update/1, result, halt) + do: run(&Projects.project_update/1, result, halt) defp dispatch([:profile, :create], result, halt), - do: run(&Commands.profile_create/1, result, halt) + do: run(&Profiles.profile_create/1, result, halt) - defp dispatch([:profile, :list], result, halt), do: run(&Commands.profile_list/1, result, halt) - defp dispatch([:profile, :use], result, halt), do: run(&Commands.profile_use/1, result, halt) - defp dispatch([:profile, :show], result, halt), do: run(&Commands.profile_show/1, result, halt) + defp dispatch([:profile, :list], result, halt), do: run(&Profiles.profile_list/1, result, halt) + defp dispatch([:profile, :use], result, halt), do: run(&Profiles.profile_use/1, result, halt) + defp dispatch([:profile, :show], result, halt), do: run(&Profiles.profile_show/1, result, halt) defp dispatch([:profile, :delete], result, halt), - do: run(&Commands.profile_delete/1, result, halt) + do: run(&Profiles.profile_delete/1, result, halt) defp dispatch([:profile, :clear], result, halt), - do: run(&Commands.profile_clear/1, result, halt) + do: run(&Profiles.profile_clear/1, result, halt) defp dispatch([:issue, :list], result, halt), do: run(&Commands.issue_list/1, result, halt) defp dispatch([:issue, :view], result, halt), do: run(&Commands.issue_view/1, result, halt) diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index def6e41..a191714 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -1,254 +1,20 @@ defmodule LinearCli.CLI.Commands do @moduledoc """ - The logic behind each subcommand: fetch via `LinearCli.Linear`, display the - result. Ported from vendor/ruby-linear-cli/lib/linear/commands/**. + Issue command implementations: fetch via `LinearCli.Linear`, display the + result. Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/**. + + Non-issue command families live in their own focused modules: + `LinearCli.CLI.Commands.System`, `LinearCli.CLI.Commands.Teams`, + `LinearCli.CLI.Commands.Projects`, and `LinearCli.CLI.Commands.Profiles`. """ alias LinearCli.Browser alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt, WhatFor} alias LinearCli.CLI.Issue.{Actions, Assignment, Creation, Identifiers} - alias LinearCli.{Favorites, Git, Linear, Profiles} + alias LinearCli.{Git, Linear, Profiles} @max_concurrent_issue_updates 20 - @doc "Ported from commands/whoami.rb." - def whoami(%{flags: flags, options: options}) do - with {:ok, user} <- Linear.me() do - Display.show(user, %{output: options.output, teams: flags.teams}) - :ok - end - end - - @doc """ - Ported from commands/version.rb, extended to respect the global - `--output json` option like every other command does - previously - ignored it and always printed plain text. The hidden Markdown renders make - this command a complete release smoke test for the MDEx and Syntect NIFs, - Marcli's syntax-highlighting integration, and the application boot path. - """ - def version(%{options: options}) do - verify_markdown_runtime!() - version = to_string(Application.spec(:linear_cli, :vsn)) - - if options.output == "json" do - IO.puts(Jason.encode!(%{version: version})) - else - IO.puts(version) - end - - :ok - end - - defp verify_markdown_runtime! do - theme = Marcli.Theme.default() - elixir = Marcli.render("```elixir\ndef smoke, do: :ok\n```") - ruby = Marcli.render("```ruby\ndef smoke; :ok; end\n```") - - elixir_keyword = theme.syntax.keyword_declaration <> "def" <> theme.reset - ruby_keyword = theme.syntax.keyword_type <> "def" <> theme.reset - - unless String.contains?(elixir, elixir_keyword) and String.contains?(ruby, ruby_keyword) do - raise "Markdown syntax-highlighting runtime is unavailable" - end - - :ok - end - - @doc "Ported from commands/team/list.rb. Ruby's `--mine` defaults true." - def team_list(%{flags: flags, options: options}) do - result = if flags.no_mine, do: Linear.teams(), else: Linear.my_teams() - - with {:ok, teams} <- result do - Display.show(filter_favorites(teams, flags.all, "team", & &1.key), %{ - output: options.output - }) - - :ok - end - end - - @doc "Ported from commands/project/list.rb. Ruby's `--mine` defaults false." - def project_list(%{flags: flags, options: options}) do - with {:ok, projects} <- projects_for(flags, options) do - Display.show(filter_favorites(projects, flags.all, "project", & &1.id), %{ - output: options.output - }) - - :ok - end - end - - defp projects_for(_flags, %{team: team_key}) when is_binary(team_key) do - with {:ok, team} <- Linear.find_team(team_key) do - Linear.projects_by_team(team.id) - end - end - - defp projects_for(%{mine: true}, _options), do: Linear.my_projects() - defp projects_for(_flags, _options), do: Linear.projects() - - @doc """ - New in this port - Ruby has no equivalent. Favorites a team - (`LinearCli.Favorites`) - once any team is favorited, `team list` - defaults to showing just favorites (`--all` overrides). - """ - def team_favorite(%{args: %{team: key}}) do - with {:ok, team} <- Linear.find_team(key) do - Favorites.add("team", team.key) - Prompt.ok("Favorited team #{team.key}") - :ok - end - end - - @doc "New in this port - Ruby has no equivalent. Un-favorites a team." - def team_unfavorite(%{args: %{team: key}}) do - with {:ok, team} <- Linear.find_team(key) do - Favorites.remove("team", team.key) - Prompt.ok("Un-favorited team #{team.key}") - :ok - end - end - - @doc """ - New in this port - Ruby has no equivalent. Favorites a project - (`LinearCli.Favorites`), resolved against the active team's projects, - prompting if ambiguous. Team is resolved via `--team`, the active - profile, or an interactive prompt. Once any project is favorited, - `project list` defaults to showing just favorites (`--all` overrides). - """ - def project_favorite(%{args: %{project: search}, options: options}) do - team = WhatFor.team_for(options.team || Profiles.default_team()) - - with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), - project when not is_nil(project) <- Projects.project_for(projects, search) do - Favorites.add("project", project.id) - Prompt.ok("Favorited project #{project.name}") - :ok - else - nil -> {:error, {:smells_bad, "No project found matching #{search}"}} - {:error, reason} -> {:error, reason} - end - end - - @doc "New in this port - Ruby has no equivalent. Un-favorites a project." - def project_unfavorite(%{args: %{project: search}, options: options}) do - team = WhatFor.team_for(options.team || Profiles.default_team()) - - with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), - project when not is_nil(project) <- Projects.project_for(projects, search) do - Favorites.remove("project", project.id) - Prompt.ok("Un-favorited project #{project.name}") - :ok - else - nil -> {:error, {:smells_bad, "No project found matching #{search}"}} - {:error, reason} -> {:error, reason} - end - end - - # Once any favorite of `kind` exists, narrows `records` down to just - # those (matched via `key_fun`) - invisible to anyone who's never - # favorited anything, since an empty favorites list leaves `records` - # untouched. `all?` (the new `--all` flag) always shows everything, - # bypassing the favorites lookup entirely. - defp filter_favorites(records, true, _kind, _key_fun), do: records - - defp filter_favorites(records, _all?, kind, key_fun) do - case Favorites.list(kind) do - [] -> records - favorite_values -> Enum.filter(records, &(key_fun.(&1) in favorite_values)) - end - end - - @doc """ - New in this port - Ruby has no equivalent. Posts a status update - (Linear's own "Project Update" feature - a journal-style status post, - not an edit to the project's own fields) via the projectUpdateCreate - mutation. `PROJECT` is resolved against the active team's projects, - prompting if ambiguous. Team is resolved via `--team`, the active - profile, or an interactive prompt. - """ - def project_update(%{args: %{project: search}, options: options}) do - team = WhatFor.team_for(options.team || Profiles.default_team()) - - with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), - project when not is_nil(project) <- Projects.project_for(projects, search), - {:ok, update} <- - Linear.post_project_update(project.id, options.body, %{health: options.health}) do - Display.show(update, %{output: options.output}) - :ok - else - nil -> {:error, {:smells_bad, "No project found matching #{search}"}} - {:error, reason} -> {:error, reason} - end - end - - @doc """ - New in this port - Ruby has no equivalent. Saves a new named - team/project bundle (`LinearCli.Profiles.create/2`) that `profile use` - can later switch to. - """ - def profile_create(%{args: %{name: name}, options: options}) do - case Profiles.create(name, team: options.team, project: options.project) do - {:ok, profile} -> - Display.show(profile, %{output: options.output}) - :ok - - {:error, reason} -> - {:error, reason} - end - end - - @doc "New in this port - Ruby has no equivalent. Lists every saved profile." - def profile_list(%{options: options}) do - Display.show(Profiles.list(), %{output: options.output}) - :ok - end - - @doc """ - New in this port - Ruby has no equivalent. Switches the active profile - - its team/project become the defaults `issue create`/`issue list` fall - back to when `--team`/`--project` are omitted. - """ - def profile_use(%{args: %{name: name}}) do - case Profiles.activate(name) do - :ok -> - Prompt.ok("Switched to profile #{name}") - :ok - - {:error, :not_found} -> - {:error, {:smells_bad, "No profile named #{name}"}} - end - end - - @doc "New in this port - Ruby has no equivalent. Shows the active profile, if any." - def profile_show(%{options: options}) do - case Profiles.active() do - nil -> Prompt.warn("No active profile") - profile -> Display.show(profile, %{output: options.output}) - end - - :ok - end - - @doc "New in this port - Ruby has no equivalent. Deactivates the active profile without deleting it." - def profile_clear(_result) do - Profiles.clear() - Prompt.ok("Cleared active profile") - :ok - end - - @doc "New in this port - Ruby has no equivalent. Deletes a saved profile." - def profile_delete(%{args: %{name: name}}) do - case Profiles.delete(name) do - :ok -> - Prompt.ok("Deleted profile #{name}") - :ok - - {:error, :not_found} -> - {:error, {:smells_bad, "No profile named #{name}"}} - end - end - @doc """ Ported from commands/issue/list.rb + operations/issue/list.rb. diff --git a/app/lib/linear_cli/cli/commands/profiles.ex b/app/lib/linear_cli/cli/commands/profiles.ex new file mode 100644 index 0000000..4a5cf40 --- /dev/null +++ b/app/lib/linear_cli/cli/commands/profiles.ex @@ -0,0 +1,76 @@ +defmodule LinearCli.CLI.Commands.Profiles do + @moduledoc """ + Profile commands: create, list, use, show, clear, and delete. + New in this port - Ruby has no equivalent. + """ + + alias LinearCli.CLI.{Display, Prompt} + alias LinearCli.Profiles + + @doc """ + New in this port - Ruby has no equivalent. Saves a new named + team/project bundle (`LinearCli.Profiles.create/2`) that `profile use` + can later switch to. + """ + def profile_create(%{args: %{name: name}, options: options}) do + case Profiles.create(name, team: options.team, project: options.project) do + {:ok, profile} -> + Display.show(profile, %{output: options.output}) + :ok + + {:error, reason} -> + {:error, reason} + end + end + + @doc "New in this port - Ruby has no equivalent. Lists every saved profile." + def profile_list(%{options: options}) do + Display.show(Profiles.list(), %{output: options.output}) + :ok + end + + @doc """ + New in this port - Ruby has no equivalent. Switches the active profile - + its team/project become the defaults `issue create`/`issue list` fall + back to when `--team`/`--project` are omitted. + """ + def profile_use(%{args: %{name: name}}) do + case Profiles.activate(name) do + :ok -> + Prompt.ok("Switched to profile #{name}") + :ok + + {:error, :not_found} -> + {:error, {:smells_bad, "No profile named #{name}"}} + end + end + + @doc "New in this port - Ruby has no equivalent. Shows the active profile, if any." + def profile_show(%{options: options}) do + case Profiles.active() do + nil -> Prompt.warn("No active profile") + profile -> Display.show(profile, %{output: options.output}) + end + + :ok + end + + @doc "New in this port - Ruby has no equivalent. Deactivates the active profile without deleting it." + def profile_clear(_result) do + Profiles.clear() + Prompt.ok("Cleared active profile") + :ok + end + + @doc "New in this port - Ruby has no equivalent. Deletes a saved profile." + def profile_delete(%{args: %{name: name}}) do + case Profiles.delete(name) do + :ok -> + Prompt.ok("Deleted profile #{name}") + :ok + + {:error, :not_found} -> + {:error, {:smells_bad, "No profile named #{name}"}} + end + end +end diff --git a/app/lib/linear_cli/cli/commands/projects.ex b/app/lib/linear_cli/cli/commands/projects.ex new file mode 100644 index 0000000..8d7db3a --- /dev/null +++ b/app/lib/linear_cli/cli/commands/projects.ex @@ -0,0 +1,102 @@ +defmodule LinearCli.CLI.Commands.Projects do + @moduledoc """ + Project commands: list, favorite, unfavorite, and update. + Ported from vendor/ruby-linear-cli/lib/linear/commands/project/. + """ + + alias LinearCli.CLI.{Display, Projects, Prompt, WhatFor} + alias LinearCli.{Favorites, Linear, Profiles} + + @doc "Ported from commands/project/list.rb. Ruby's `--mine` defaults false." + def project_list(%{flags: flags, options: options}) do + with {:ok, projects} <- projects_for(flags, options) do + Display.show(filter_favorites(projects, flags.all, "project", & &1.id), %{ + output: options.output + }) + + :ok + end + end + + @doc """ + New in this port - Ruby has no equivalent. Favorites a project + (`LinearCli.Favorites`), resolved against the active team's projects, + prompting if ambiguous. Team is resolved via `--team`, the active + profile, or an interactive prompt. Once any project is favorited, + `project list` defaults to showing just favorites (`--all` overrides). + """ + def project_favorite(%{args: %{project: search}, options: options}) do + team = WhatFor.team_for(options.team || Profiles.default_team()) + + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), + project when not is_nil(project) <- Projects.project_for(projects, search) do + Favorites.add("project", project.id) + Prompt.ok("Favorited project #{project.name}") + :ok + else + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + {:error, reason} -> {:error, reason} + end + end + + @doc "New in this port - Ruby has no equivalent. Un-favorites a project." + def project_unfavorite(%{args: %{project: search}, options: options}) do + team = WhatFor.team_for(options.team || Profiles.default_team()) + + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), + project when not is_nil(project) <- Projects.project_for(projects, search) do + Favorites.remove("project", project.id) + Prompt.ok("Un-favorited project #{project.name}") + :ok + else + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + New in this port - Ruby has no equivalent. Posts a status update + (Linear's own "Project Update" feature - a journal-style status post, + not an edit to the project's own fields) via the projectUpdateCreate + mutation. `PROJECT` is resolved against the active team's projects, + prompting if ambiguous. Team is resolved via `--team`, the active + profile, or an interactive prompt. + """ + def project_update(%{args: %{project: search}, options: options}) do + team = WhatFor.team_for(options.team || Profiles.default_team()) + + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), + project when not is_nil(project) <- Projects.project_for(projects, search), + {:ok, update} <- + Linear.post_project_update(project.id, options.body, %{health: options.health}) do + Display.show(update, %{output: options.output}) + :ok + else + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + {:error, reason} -> {:error, reason} + end + end + + defp projects_for(_flags, %{team: team_key}) when is_binary(team_key) do + with {:ok, team} <- Linear.find_team(team_key) do + Linear.projects_by_team(team.id) + end + end + + defp projects_for(%{mine: true}, _options), do: Linear.my_projects() + defp projects_for(_flags, _options), do: Linear.projects() + + # Once any favorite of `kind` exists, narrows `records` down to just + # those (matched via `key_fun`) - invisible to anyone who's never + # favorited anything, since an empty favorites list leaves `records` + # untouched. `all?` (the new `--all` flag) always shows everything, + # bypassing the favorites lookup entirely. + defp filter_favorites(records, true, _kind, _key_fun), do: records + + defp filter_favorites(records, _all?, kind, key_fun) do + case Favorites.list(kind) do + [] -> records + favorite_values -> Enum.filter(records, &(key_fun.(&1) in favorite_values)) + end + end +end diff --git a/app/lib/linear_cli/cli/commands/system.ex b/app/lib/linear_cli/cli/commands/system.ex new file mode 100644 index 0000000..406e963 --- /dev/null +++ b/app/lib/linear_cli/cli/commands/system.ex @@ -0,0 +1,52 @@ +defmodule LinearCli.CLI.Commands.System do + @moduledoc """ + Top-level system commands: whoami and version. + Ported from vendor/ruby-linear-cli/lib/linear/commands/. + """ + + alias LinearCli.CLI.Display + alias LinearCli.Linear + + @doc "Ported from commands/whoami.rb." + def whoami(%{flags: flags, options: options}) do + with {:ok, user} <- Linear.me() do + Display.show(user, %{output: options.output, teams: flags.teams}) + :ok + end + end + + @doc """ + Ported from commands/version.rb, extended to respect the global + `--output json` option like every other command does - previously + ignored it and always printed plain text. The hidden Markdown renders make + this command a complete release smoke test for the MDEx and Syntect NIFs, + Marcli's syntax-highlighting integration, and the application boot path. + """ + def version(%{options: options}) do + verify_markdown_runtime!() + version = to_string(Application.spec(:linear_cli, :vsn)) + + if options.output == "json" do + IO.puts(Jason.encode!(%{version: version})) + else + IO.puts(version) + end + + :ok + end + + defp verify_markdown_runtime! do + theme = Marcli.Theme.default() + elixir = Marcli.render("```elixir\ndef smoke, do: :ok\n```") + ruby = Marcli.render("```ruby\ndef smoke; :ok; end\n```") + + elixir_keyword = theme.syntax.keyword_declaration <> "def" <> theme.reset + ruby_keyword = theme.syntax.keyword_type <> "def" <> theme.reset + + unless String.contains?(elixir, elixir_keyword) and String.contains?(ruby, ruby_keyword) do + raise "Markdown syntax-highlighting runtime is unavailable" + end + + :ok + end +end diff --git a/app/lib/linear_cli/cli/commands/teams.ex b/app/lib/linear_cli/cli/commands/teams.ex new file mode 100644 index 0000000..c1063ff --- /dev/null +++ b/app/lib/linear_cli/cli/commands/teams.ex @@ -0,0 +1,58 @@ +defmodule LinearCli.CLI.Commands.Teams do + @moduledoc """ + Team commands: list, favorite, and unfavorite. + Ported from vendor/ruby-linear-cli/lib/linear/commands/team/. + """ + + alias LinearCli.CLI.{Display, Prompt} + alias LinearCli.{Favorites, Linear} + + @doc "Ported from commands/team/list.rb. Ruby's `--mine` defaults true." + def team_list(%{flags: flags, options: options}) do + result = if flags.no_mine, do: Linear.teams(), else: Linear.my_teams() + + with {:ok, teams} <- result do + Display.show(filter_favorites(teams, flags.all, "team", & &1.key), %{ + output: options.output + }) + + :ok + end + end + + @doc """ + New in this port - Ruby has no equivalent. Favorites a team + (`LinearCli.Favorites`) - once any team is favorited, `team list` + defaults to showing just favorites (`--all` overrides). + """ + def team_favorite(%{args: %{team: key}}) do + with {:ok, team} <- Linear.find_team(key) do + Favorites.add("team", team.key) + Prompt.ok("Favorited team #{team.key}") + :ok + end + end + + @doc "New in this port - Ruby has no equivalent. Un-favorites a team." + def team_unfavorite(%{args: %{team: key}}) do + with {:ok, team} <- Linear.find_team(key) do + Favorites.remove("team", team.key) + Prompt.ok("Un-favorited team #{team.key}") + :ok + end + end + + # Once any favorite of `kind` exists, narrows `records` down to just + # those (matched via `key_fun`) - invisible to anyone who's never + # favorited anything, since an empty favorites list leaves `records` + # untouched. `all?` (the new `--all` flag) always shows everything, + # bypassing the favorites lookup entirely. + defp filter_favorites(records, true, _kind, _key_fun), do: records + + defp filter_favorites(records, _all?, kind, key_fun) do + case Favorites.list(kind) do + [] -> records + favorite_values -> Enum.filter(records, &(key_fun.(&1) in favorite_values)) + end + end +end diff --git a/app/lib/linear_cli/favorites.ex b/app/lib/linear_cli/favorites.ex index 762b1c6..fc2e982 100644 --- a/app/lib/linear_cli/favorites.ex +++ b/app/lib/linear_cli/favorites.ex @@ -10,8 +10,8 @@ defmodule LinearCli.Favorites do `LinearCli.Profiles` itself. `list/1`, once non-empty for a given `kind`, is what - `LinearCli.CLI.Commands.team_list/1`/`project_list/1` filter their - results down to by default (a new `--all` flag opts back out). + `LinearCli.CLI.Commands.Teams.team_list/1`/`LinearCli.CLI.Commands.Projects.project_list/1` + filter their results down to by default (a new `--all` flag opts back out). New in this port - Ruby has no equivalent. """