diff --git a/Readme.adoc b/Readme.adoc index 8c91788..bce3297 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -243,6 +243,9 @@ $ lcls --labels Bug --include-labels <9> <8> Filter to issues carrying "Bug" or "Feature" (case-insensitive OR); all labels on each match are shown <9> Explicit `--include-labels` is redundant when `--labels` is also present; both work together +In JSON mode, a project filter must match one project exactly. Partial project +matches are rejected on stderr instead of opening the text-mode selection menu. + ==== View an Issue Show full details for a single issue (title, description, and all comments): @@ -260,6 +263,9 @@ $ lc issue view CRY-1234 -w <4> <3> Open the issue in your browser instead of printing it <4> Short form of `--web` +NOTE: JSON output cannot be combined with `--web`, because opening a browser +does not produce an issue value on stdout. + ==== Assign one or more issues to yourself (take em!) [source,sh] @@ -286,6 +292,9 @@ $ lc issue assign CRY-1234 <4> <3> Short form of `--status` <4> Prompts for the assignee interactively +NOTE: In JSON mode, `--assignee` is required. An omitted assignee is an +interactive choice in text mode and is rejected on stderr in JSON mode. + ==== Unassign one or more issues This command clears the assignee from each listed issue. It accepts explicit @@ -344,6 +353,11 @@ NOTE: `--yes` creates and self-assigns an issue without prompting. Add TIP: When creating an issue, you can use the `--dev` (or `--develop`) option to immediately start development on the issue. +NOTE: JSON issue creation requires `--yes`, which supplies all required +fields without prompts. JSON mode does not support `--dev`; use the separate +`issue develop` command after creation. A project supplied with `--yes` must +match exactly; partial project searches are rejected before issue creation. + ==== Develop an issue Switches to the branch for the issue, creating the branch if it doesn't exist. @@ -362,6 +376,10 @@ Requires the `gh` CLI to be installed and configured. $ lc issue pr CRY-1234 ---- +NOTE: JSON `issue pr` requires both `--title` and `--description`. Without +those options the text command asks questions and JSON mode rejects the +invocation before checking out the branch. + ==== Update an issue All of the update options can work on multiple issues at a time. @@ -411,6 +429,12 @@ the team has more than one matching state. $ lc issue update --close --status Done --reason "These were closable" CRY-1234 CRY-2 ---- +For JSON issue updates, close and cancel operations require both `--status` +and `--reason`. Project moves also require an exact project match, and the +`-` editor sentinel is not accepted for JSON comment, description, or reason +inputs. These checks happen before the first issue mutation; text mode keeps +its prompts. + ==== Move an issue to a different workflow state Changes the workflow state of one or more issues. Prompts interactively when @@ -428,6 +452,12 @@ $ lc issue status CRY-1234 -s Done -m "Wrapped up" <4> <3> Move multiple issues at once — updates run concurrently <4> Add a comment alongside the state change +NOTE: JSON `issue status` requires `--status`. The interactive state menu is +available in text mode only. + +For `issue comment`, JSON mode requires `--comment` or `--body-file`; the +editor prompt used when neither is supplied remains a text-mode behavior. + ==== Move issues to a project Two modes: move specific issues by ID, or bulk-move an entire project's backlog. @@ -491,6 +521,11 @@ $ lc profile delete manhattan An explicit `--team`/`--project` on the command line always overrides the active profile. +Profile and favorite mutations return one JSON result when `--output json` is +used. Project favorites and project updates require an exact project name or +ID, and require `--team` or an active profile when more than one team is +available. Text mode keeps its existing selection prompts. + Issue commands also accept a bare number (`lc issue develop 1234`) in place of a full team-prefixed identifier - it's resolved via the active profile's team, then favorited teams, then a prompt across every team you diff --git a/app/lib/linear_cli/cli/commands/issues/create.ex b/app/lib/linear_cli/cli/commands/issues/create.ex index cdea0fa..398a6e1 100644 --- a/app/lib/linear_cli/cli/commands/issues/create.ex +++ b/app/lib/linear_cli/cli/commands/issues/create.ex @@ -28,7 +28,10 @@ defmodule LinearCli.CLI.Commands.Issues.Create do def issue_create(result, opts \\ []) def issue_create(%{options: options, flags: flags}, opts) do - with :ok <- validate_no_take_develop(flags), + command_opts = Keyword.put(opts, :output, Map.get(options, :output, "text")) + + with :ok <- validate_json_create(options, flags), + :ok <- validate_no_take_develop(flags), :ok <- validate_body_file_exclusion(options, :description, "--description"), {:ok, description} <- resolve_body_from_file(options, :description), create_opts = [ @@ -41,7 +44,7 @@ defmodule LinearCli.CLI.Commands.Issues.Create do yes: flags.yes ], {:ok, issue} <- Creation.make_da_issue!(create_opts), - :ok <- maybe_take(issue, flags, opts) do + :ok <- maybe_take(issue, flags, command_opts) do Display.show(issue, %{output: options.output}) if flags.develop, do: run_develop(issue.id, opts), else: :ok end @@ -52,6 +55,23 @@ defmodule LinearCli.CLI.Commands.Issues.Create do defp validate_no_take_develop(_flags), do: :ok + defp validate_json_create(options, flags) do + if Map.get(options, :output, "text") != "json" do + :ok + else + cond do + not Map.get(flags, :yes, false) -> + {:error, {:smells_bad, "JSON output requires --yes for issue create"}} + + Map.get(flags, :develop, false) -> + {:error, {:smells_bad, "JSON output cannot be used with --dev"}} + + true -> + :ok + end + end + end + defp maybe_take(_issue, %{no_take: true}, _opts), do: :ok defp maybe_take(issue, %{yes: true}, opts) do diff --git a/app/lib/linear_cli/cli/commands/issues/development.ex b/app/lib/linear_cli/cli/commands/issues/development.ex index 6aa1746..4b0341d 100644 --- a/app/lib/linear_cli/cli/commands/issues/development.ex +++ b/app/lib/linear_cli/cli/commands/issues/development.ex @@ -5,7 +5,7 @@ defmodule LinearCli.CLI.Commands.Issues.Development do pr.rb, and take.rb. """ - alias LinearCli.CLI.{Display, Prompt} + alias LinearCli.CLI.{Display, Output} alias LinearCli.CLI.Issue.{Assignment, Identifiers, PullRequest} alias LinearCli.Git @@ -26,8 +26,14 @@ defmodule LinearCli.CLI.Commands.Issues.Development do @spec issue_develop(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} def issue_develop(result, opts \\ []) - def issue_develop(%{args: %{issue_id: issue_id}} = result, opts), - do: run_develop(issue_id, maybe_put(opts, :output, result_output(result))) + def issue_develop(%{args: %{issue_id: issue_id}} = result, opts) do + opts = maybe_put(opts, :output, result_output(result)) + + with :ok <- run_develop(issue_id, opts) do + Output.success("issue_develop", %{"issue" => issue_id}, opts) + :ok + end + end @doc """ Ported from commands/issue/pr.rb: resolves/self-assigns `issue_id`, checks @@ -46,15 +52,19 @@ defmodule LinearCli.CLI.Commands.Issues.Development do def issue_pr(%{args: %{issue_id: issue_id}, options: options}, opts) do opts = maybe_put(opts, :output, Map.get(options, :output, "text")) - with {:ok, issue} <- Assignment.gimme_da_issue!(issue_id, opts), + with :ok <- validate_json_pr(options), + {:ok, issue} <- Assignment.gimme_da_issue!(issue_id, opts), {:ok, _branch} <- Git.checkout_branch(issue.branch_name, opts) do - Prompt.ok("Checked out branch #{issue.branch_name}") + Output.status(:ok, "Checked out branch #{issue.branch_name}", opts) pr_opts = - [title: options.title, description: options.description] + [title: options.title, description: options.description, output: opts[:output]] |> maybe_put(:runner, opts[:runner]) - PullRequest.issue_pr(issue, pr_opts) + with :ok <- PullRequest.issue_pr(issue, pr_opts) do + Output.success("issue_pr", %{"issue" => issue.identifier}, opts) + :ok + end end end @@ -82,7 +92,9 @@ defmodule LinearCli.CLI.Commands.Issues.Development do |> maybe_put(:output, Map.get(options, :output, "text")) with {:ok, updates} <- take_issues(issue_ids, opts) do - Display.show(updates, %{output: Map.get(options, :output, "text")}) + output = Map.get(options, :output, "text") + value = if output == "json", do: one_or_many(updates), else: updates + Display.show(value, %{output: output}) :ok end end @@ -90,7 +102,7 @@ defmodule LinearCli.CLI.Commands.Issues.Development do defp run_develop(issue_id, opts) do with {:ok, issue} <- Assignment.gimme_da_issue!(issue_id, opts), {:ok, _branch} <- Git.checkout_branch(issue.branch_name, opts) do - Prompt.ok("Checked out branch #{issue.branch_name}") + Output.status(:ok, "Checked out branch #{issue.branch_name}", opts) finish_pull_or_push(issue.branch_name, opts) end end @@ -102,13 +114,18 @@ defmodule LinearCli.CLI.Commands.Issues.Development do defp finish_pull_or_push(branch_name, opts) do case Git.pull_or_push_new_branch!(branch_name, opts) do {:ok, {:pulled, _output}} -> - Prompt.ok("Ready to develop!") + Output.status(:ok, "Ready to develop!", opts) :ok {:ok, {:pushed_new_branch, _branch_name}} -> - Prompt.warn("Upstream branch not found, pushing local #{branch_name} to origin") - Prompt.ok("Set upstream to origin/#{branch_name}") - Prompt.ok("Ready to develop!") + Output.status( + :warn, + "Upstream branch not found, pushing local #{branch_name} to origin", + opts + ) + + Output.status(:ok, "Set upstream to origin/#{branch_name}", opts) + Output.status(:ok, "Ready to develop!", opts) :ok {:error, reason} -> @@ -122,6 +139,23 @@ defmodule LinearCli.CLI.Commands.Issues.Development do defp result_output(%{options: options}), do: Map.get(options, :output, "text") defp result_output(_result), do: "text" + defp validate_json_pr(options) do + if Map.get(options, :output, "text") != "json" do + :ok + else + cond do + is_nil(Map.get(options, :title)) -> + {:error, {:smells_bad, "JSON output requires --title for issue pr"}} + + is_nil(Map.get(options, :description)) -> + {:error, {:smells_bad, "JSON output requires --description for issue pr"}} + + true -> + :ok + end + end + end + defp maybe_put_status(opts, nil), do: opts defp maybe_put_status(opts, status), do: Keyword.put(opts, :status, status) @@ -134,7 +168,7 @@ defmodule LinearCli.CLI.Commands.Issues.Development do {:cont, {:ok, [issue | acc]}} {:error, %Ash.Error.Unknown{errors: [%{value: [{:not_found, id}]} | _]}} -> - Prompt.warn("No issue found with id #{id}") + Output.status(:warn, "No issue found with id #{id}", opts) {:cont, {:ok, acc}} {:error, reason} -> @@ -155,4 +189,7 @@ defmodule LinearCli.CLI.Commands.Issues.Development do {:ok, issue_ids} end end + + defp one_or_many([one]), do: one + defp one_or_many(many), do: many end diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index 0ceff2e..cbc5d03 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -6,7 +6,7 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do """ alias LinearCli.CLI.Commands.Issues.Filter - alias LinearCli.CLI.Display + alias LinearCli.CLI.{Display, Output, Projects} alias LinearCli.CLI.Issue.{Actions, Identifiers} alias LinearCli.CLI.Prompt alias LinearCli.CLI.WhatFor @@ -33,13 +33,15 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do def issue_update(%{unknown: issue_ids, options: options, flags: flags}) do output = Map.get(options, :output, "text") - with :ok <- validate_issue_ids(issue_ids), + with :ok <- validate_json_update(options, flags), + :ok <- validate_issue_ids(issue_ids), :ok <- validate_body_file_exclusion(options, :description, "--description"), {:ok, description} <- resolve_body_from_file(options, :description), {:ok, priority} <- parse_priority(Map.get(options, :priority)), {:ok, expanded_ids} <- Identifiers.expand_issue_ids(issue_ids, output: output), {:ok, issues} <- - Linear.issues(%{ids: expanded_ids}) do + Linear.issues(%{ids: expanded_ids}), + :ok <- preflight_update_projects(issues, options) do update_opts = [ comment: options.comment, description: description, @@ -49,15 +51,23 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do reason: options.reason, status: Map.get(options, :status), trash: flags.trash, - priority: priority + priority: priority, + output: output ] - Enum.reduce_while(issues, :ok, fn issue, :ok -> - case Actions.update_issue(issue, update_opts) do - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end) + case apply_issue_updates(issues, update_opts) do + :ok -> + Output.success( + "issue_update", + %{"issues" => Enum.map(issues, & &1.identifier)}, + options + ) + + :ok + + error -> + error + end end end @@ -87,7 +97,8 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do def issue_comment(%{unknown: issue_ids, options: options}) do output = Map.get(options, :output, "text") - with :ok <- validate_issue_ids(issue_ids), + with :ok <- validate_json_comment(options), + :ok <- validate_issue_ids(issue_ids), :ok <- validate_body_file_exclusion(options, :comment, "--comment"), {:ok, comment_text} <- resolve_body_from_file(options, :comment), {:ok, expanded_ids} <- Identifiers.expand_issue_ids(issue_ids, output: output), @@ -121,12 +132,14 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do def issue_status(%{unknown: issue_ids, options: options}) do output = Map.get(options, :output, "text") - with :ok <- validate_issue_ids(issue_ids), + with :ok <- validate_json_status(options), + :ok <- validate_issue_ids(issue_ids), {:ok, expanded_ids} <- Identifiers.expand_issue_ids(issue_ids, output: output), {:ok, issues} <- Linear.issues(%{ids: expanded_ids}), {:ok, planned_updates} <- plan_status_updates(issues, options.status), - {:ok, completed_updates} <- apply_status_updates(planned_updates, options.comment) do + {:ok, completed_updates} <- + apply_status_updates(planned_updates, options.comment, options) do show_status_updates(completed_updates, output) end end @@ -165,7 +178,7 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), {:ok, members} <- Linear.team_members(issue.team.id), :ok <- guard_has_members(members, issue), - {:ok, target_member} <- resolve_target_member(members, options.assignee), + {:ok, target_member} <- resolve_target_member(members, options.assignee, options), {:ok, state_id} <- resolve_optional_status(issue, Map.get(options, :status)), {:ok, updated} <- Linear.assign_issue(issue, target_member.id, %{state_id: state_id}) do Display.show(updated, %{output: output}) @@ -423,13 +436,13 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do |> reverse_status_updates() end - defp apply_status_updates([], _comment), do: {:ok, []} + defp apply_status_updates([], _comment, _options), do: {:ok, []} - defp apply_status_updates(planned_updates, comment) do + defp apply_status_updates(planned_updates, comment, options) do planned_updates |> Task.async_stream( fn {issue, target_state} -> - apply_status_update(issue, target_state, comment) + apply_status_update(issue, target_state, comment, options) end, max_concurrency: min(length(planned_updates), @max_concurrent_issue_updates), ordered: true, @@ -448,8 +461,8 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do |> reverse_status_updates() end - defp apply_status_update(issue, target_state, comment) do - with :ok <- maybe_add_status_comment(issue, comment), + defp apply_status_update(issue, target_state, comment, options) do + with :ok <- maybe_add_status_comment(issue, comment, options), {:ok, updated} <- Linear.set_issue_status(issue, target_state.id) do {:ok, {updated, target_state}} end @@ -471,6 +484,100 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do :ok end + defp validate_json_comment(options) do + cond do + not json_output?(options) -> + :ok + + is_nil(Map.get(options, :comment)) and is_nil(Map.get(options, :body_file)) -> + {:error, {:smells_bad, "JSON output requires --comment or --body-file"}} + + Map.get(options, :comment) == "-" -> + {:error, {:smells_bad, "JSON output requires --comment text, not editor input"}} + + true -> + :ok + end + end + + defp validate_json_status(options) do + cond do + not json_output?(options) -> + :ok + + is_nil(Map.get(options, :status)) -> + {:error, {:smells_bad, "JSON output requires --status"}} + + Map.get(options, :comment) == "-" -> + {:error, {:smells_bad, "JSON output requires --comment text, not editor input"}} + + true -> + :ok + end + end + + defp validate_json_update(options, flags) do + if json_output?(options), do: validate_json_update_input(options, flags), else: :ok + end + + defp validate_json_update_input(options, flags) do + cond do + close_or_cancel?(flags) and Map.get(options, :reason) in [nil, "-"] -> + {:error, {:smells_bad, "JSON output requires --reason for close or cancel"}} + + close_or_cancel?(flags) and is_nil(Map.get(options, :status)) -> + {:error, {:smells_bad, "JSON output requires --status for close or cancel"}} + + Map.get(options, :comment) == "-" -> + {:error, {:smells_bad, "JSON output requires --comment text, not editor input"}} + + Map.get(options, :description) == "-" -> + {:error, {:smells_bad, "JSON output requires --description text, not editor input"}} + + true -> + :ok + end + end + + defp close_or_cancel?(flags), + do: Map.get(flags, :close, false) or Map.get(flags, :cancel, false) + + defp apply_issue_updates(issues, update_opts) do + Enum.reduce_while(issues, :ok, fn issue, :ok -> + case Actions.update_issue(issue, update_opts) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp preflight_update_projects(_issues, %{output: output}) when output != "json", do: :ok + + defp preflight_update_projects(issues, options) do + case Map.get(options, :project) do + nil -> + :ok + + search -> + Enum.reduce_while(issues, :ok, fn issue, :ok -> + with {:ok, projects} <- + Linear.projects_by_team(issue.team.id, %{search: search}), + project when not is_nil(project) <- Projects.project_for_strict(projects, search) do + {:cont, :ok} + else + nil -> + {:halt, + {:error, + {:smells_bad, + "JSON output requires an exact project match for #{inspect(search)}"}}} + + {:error, reason} -> + {:halt, {:error, reason}} + end + end) + end + end + defp resolve_target_state(states, nil) do choices = Enum.sort_by(states, & &1.position) |> Enum.map(&{&1.name, &1}) {:ok, Prompt.select("Choose a status", choices)} @@ -503,10 +610,10 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do {:error, {:smells_bad, "Ambiguous status #{inspect(name)}: matches #{ambiguous}"}} end - defp maybe_add_status_comment(_issue, nil), do: :ok + defp maybe_add_status_comment(_issue, nil, _options), do: :ok - defp maybe_add_status_comment(issue, comment) do - case Actions.issue_comment(issue, comment) do + defp maybe_add_status_comment(issue, comment, options) do + case Actions.issue_comment(issue, comment, options) do {:ok, _} -> :ok {:error, reason} -> {:error, reason} end @@ -528,12 +635,16 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do defp guard_has_members(_members, _issue), do: :ok - defp resolve_target_member(members, nil) do - choices = Enum.sort_by(members, & &1.name) |> Enum.map(&{&1.name, &1}) - {:ok, Prompt.select("Choose an assignee", choices)} + defp resolve_target_member(members, nil, options) do + if json_output?(options) do + {:error, {:smells_bad, "JSON output requires --assignee"}} + else + choices = Enum.sort_by(members, & &1.name) |> Enum.map(&{&1.name, &1}) + {:ok, Prompt.select("Choose an assignee", choices)} + end end - defp resolve_target_member(members, name) do + defp resolve_target_member(members, name, _options) do normalized = String.downcase(name) members diff --git a/app/lib/linear_cli/cli/commands/issues/read.ex b/app/lib/linear_cli/cli/commands/issues/read.ex index 89c0c08..ce5f084 100644 --- a/app/lib/linear_cli/cli/commands/issues/read.ex +++ b/app/lib/linear_cli/cli/commands/issues/read.ex @@ -27,7 +27,10 @@ defmodule LinearCli.CLI.Commands.Issues.Read do """ def issue_list(%{flags: flags, options: options, unknown: ids}) do with {:ok, expanded_ids} <- Identifiers.expand_issue_ids(ids, output: options.output), - {:ok, input} <- Filter.build_input(flags, options, expanded_ids) do + {:ok, input} <- + Filter.build_input(flags, options, expanded_ids, + project_resolution: project_resolution(options) + ) do with {:ok, issues} <- Linear.issues(input) do Display.show(issues, %{ output: options.output, @@ -54,12 +57,15 @@ defmodule LinearCli.CLI.Commands.Issues.Read do def issue_view(%{args: %{issue_id: issue_id}, flags: flags, options: options}, opts) do graph? = Map.get(flags, :graph, false) - web? = flags.web + web? = Map.get(flags, :web, false) cond do graph? && web? -> {:error, {:smells_bad, "--graph and --web cannot be used together"}} + web? && options.output == "json" -> + {:error, {:smells_bad, "JSON output cannot be used with --web"}} + graph? -> with {:ok, expanded_id} <- Identifiers.expand_issue_id(issue_id, output: options.output), {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), @@ -80,4 +86,7 @@ defmodule LinearCli.CLI.Commands.Issues.Read do end end end + + defp project_resolution(%{output: "json"}), do: :strict_non_interactive + defp project_resolution(_options), do: :permissive end diff --git a/app/lib/linear_cli/cli/commands/profiles.ex b/app/lib/linear_cli/cli/commands/profiles.ex index 4a5cf40..67f0ad6 100644 --- a/app/lib/linear_cli/cli/commands/profiles.ex +++ b/app/lib/linear_cli/cli/commands/profiles.ex @@ -4,7 +4,7 @@ defmodule LinearCli.CLI.Commands.Profiles do New in this port - Ruby has no equivalent. """ - alias LinearCli.CLI.{Display, Prompt} + alias LinearCli.CLI.{Display, Output, Prompt} alias LinearCli.Profiles @doc """ @@ -34,10 +34,15 @@ defmodule LinearCli.CLI.Commands.Profiles do 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 + def profile_use(%{args: %{name: name}, options: options}) do case Profiles.activate(name) do :ok -> - Prompt.ok("Switched to profile #{name}") + if Output.json?(options) do + Output.success("profile_use", %{"profile" => name}, options) + else + Prompt.ok("Switched to profile #{name}") + end + :ok {:error, :not_found} -> @@ -48,25 +53,44 @@ defmodule LinearCli.CLI.Commands.Profiles do @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}) + nil -> + if Output.json?(options) do + Output.status(:warn, "No active profile", options) + Display.show(nil, %{output: "json"}) + else + Prompt.warn("No active profile") + end + + 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 + def profile_clear(%{options: options}) do Profiles.clear() - Prompt.ok("Cleared active profile") + + if Output.json?(options) do + Output.success("profile_clear", %{}, options) + else + Prompt.ok("Cleared active profile") + end + :ok end @doc "New in this port - Ruby has no equivalent. Deletes a saved profile." - def profile_delete(%{args: %{name: name}}) do + def profile_delete(%{args: %{name: name}, options: options}) do case Profiles.delete(name) do :ok -> - Prompt.ok("Deleted profile #{name}") + if Output.json?(options) do + Output.success("profile_delete", %{"profile" => name}, options) + else + Prompt.ok("Deleted profile #{name}") + end + :ok {:error, :not_found} -> diff --git a/app/lib/linear_cli/cli/commands/projects.ex b/app/lib/linear_cli/cli/commands/projects.ex index 8d7db3a..6152b22 100644 --- a/app/lib/linear_cli/cli/commands/projects.ex +++ b/app/lib/linear_cli/cli/commands/projects.ex @@ -4,7 +4,7 @@ defmodule LinearCli.CLI.Commands.Projects do Ported from vendor/ruby-linear-cli/lib/linear/commands/project/. """ - alias LinearCli.CLI.{Display, Projects, Prompt, WhatFor} + alias LinearCli.CLI.{Display, Output, Projects, Prompt, WhatFor} alias LinearCli.{Favorites, Linear, Profiles} @doc "Ported from commands/project/list.rb. Ruby's `--mine` defaults false." @@ -26,30 +26,38 @@ defmodule LinearCli.CLI.Commands.Projects do `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 + with {:ok, team} <- resolve_team(options), + {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), + {:ok, project} <- resolve_project(projects, search, options) do Favorites.add("project", project.id) - Prompt.ok("Favorited project #{project.name}") + + if Output.json?(options) do + Output.success("project_favorite", %{"project" => project.id}, options) + else + Prompt.ok("Favorited project #{project.name}") + end + :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 + with {:ok, team} <- resolve_team(options), + {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), + {:ok, project} <- resolve_project(projects, search, options) do Favorites.remove("project", project.id) - Prompt.ok("Un-favorited project #{project.name}") + + if Output.json?(options) do + Output.success("project_unfavorite", %{"project" => project.id}, options) + else + Prompt.ok("Un-favorited project #{project.name}") + end + :ok else - nil -> {:error, {:smells_bad, "No project found matching #{search}"}} {:error, reason} -> {:error, reason} end end @@ -63,20 +71,62 @@ defmodule LinearCli.CLI.Commands.Projects do 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), + with {:ok, team} <- resolve_team(options), + {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), + {:ok, project} <- resolve_project(projects, search, options), {: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 resolve_team(options) do + key = options.team || Profiles.default_team() + + if Output.json?(options) do + strict_team(key) + else + {:ok, WhatFor.team_for(key)} + end + end + + defp strict_team(nil) do + case Linear.my_teams() do + {:ok, [team]} -> {:ok, team} + {:ok, []} -> {:error, {:smells_bad, "JSON output requires --team or an active profile"}} + {:ok, _teams} -> {:error, {:smells_bad, "JSON output requires --team or an active profile"}} + {:error, reason} -> {:error, reason} + end + end + + defp strict_team(key) do + case Linear.find_team(key) do + {:ok, team} -> {:ok, team} + {:error, reason} -> {:error, reason} + end + end + + defp resolve_project(projects, search, options) do + if Output.json?(options) do + case Projects.project_for_strict(projects, search) do + nil -> + {:error, + {:smells_bad, "JSON output requires an exact project match for #{inspect(search)}"}} + + project -> + {:ok, project} + end + else + case Projects.project_for(projects, search) do + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + project -> {:ok, project} + end + 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) diff --git a/app/lib/linear_cli/cli/commands/teams.ex b/app/lib/linear_cli/cli/commands/teams.ex index c1063ff..fe6cf30 100644 --- a/app/lib/linear_cli/cli/commands/teams.ex +++ b/app/lib/linear_cli/cli/commands/teams.ex @@ -4,7 +4,7 @@ defmodule LinearCli.CLI.Commands.Teams do Ported from vendor/ruby-linear-cli/lib/linear/commands/team/. """ - alias LinearCli.CLI.{Display, Prompt} + alias LinearCli.CLI.{Display, Output, Prompt} alias LinearCli.{Favorites, Linear} @doc "Ported from commands/team/list.rb. Ruby's `--mine` defaults true." @@ -25,19 +25,31 @@ defmodule LinearCli.CLI.Commands.Teams do (`LinearCli.Favorites`) - once any team is favorited, `team list` defaults to showing just favorites (`--all` overrides). """ - def team_favorite(%{args: %{team: key}}) do + def team_favorite(%{args: %{team: key}, options: options}) do with {:ok, team} <- Linear.find_team(key) do Favorites.add("team", team.key) - Prompt.ok("Favorited team #{team.key}") + + if Output.json?(options) do + Output.success("team_favorite", %{"team" => team.key}, options) + else + Prompt.ok("Favorited team #{team.key}") + end + :ok end end @doc "New in this port - Ruby has no equivalent. Un-favorites a team." - def team_unfavorite(%{args: %{team: key}}) do + def team_unfavorite(%{args: %{team: key}, options: options}) do with {:ok, team} <- Linear.find_team(key) do Favorites.remove("team", team.key) - Prompt.ok("Un-favorited team #{team.key}") + + if Output.json?(options) do + Output.success("team_unfavorite", %{"team" => team.key}, options) + else + Prompt.ok("Un-favorited team #{team.key}") + end + :ok end end diff --git a/app/lib/linear_cli/cli/issue/actions.ex b/app/lib/linear_cli/cli/issue/actions.ex index 7832de1..f969a95 100644 --- a/app/lib/linear_cli/cli/issue/actions.ex +++ b/app/lib/linear_cli/cli/issue/actions.ex @@ -36,7 +36,7 @@ defmodule LinearCli.CLI.Issue.Actions do """ alias LinearCli.CLI.Issue.{PullRequest, WorkflowStates} - alias LinearCli.CLI.{Projects, Prompt, WhatFor} + alias LinearCli.CLI.{Output, Projects, Prompt, WhatFor} alias LinearCli.Linear @doc """ @@ -48,12 +48,14 @@ defmodule LinearCli.CLI.Issue.Actions do """ @spec issue_comment(%Linear.Issue{}, String.t() | nil) :: {:ok, %Linear.Comment{}} | {:error, term()} - def issue_comment(issue, comment) do + def issue_comment(issue, comment), do: issue_comment(issue, comment, []) + + def issue_comment(issue, comment, opts) do body = WhatFor.comment_for(issue, comment) case Linear.add_comment(issue.identifier, body) do {:ok, created} -> - Prompt.ok("Comment added to #{issue.identifier}") + Output.status(:ok, "Comment added to #{issue.identifier}", opts) {:ok, created} {:error, reason} -> @@ -76,16 +78,16 @@ defmodule LinearCli.CLI.Issue.Actions do @spec cancel_issue(%Linear.Issue{}, keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} def cancel_issue(issue, opts \\ []) do if issue.state && issue.state.type in ["cancelled", "canceled"] do - Prompt.ok("#{issue.identifier} is already #{issue.state.name}") + Output.status(:ok, "#{issue.identifier} is already #{issue.state.name}", opts) {:ok, issue} else reason = WhatFor.reason_for(opts[:reason], four: "cancelling #{issue.identifier} - #{issue.title}") - with {:ok, _comment} <- issue_comment(issue, reason), + with {:ok, _comment} <- issue_comment(issue, reason, opts), {:ok, cancel_state} <- WorkflowStates.cancelled_state_for(issue, opts[:status]), {:ok, updated} <- Linear.close_issue(issue, cancel_state.id, %{trash: !!opts[:trash]}) do - Prompt.ok("#{issue.identifier} was cancelled") + Output.status(:ok, "#{issue.identifier} was cancelled", opts) {:ok, updated} end end @@ -111,7 +113,7 @@ defmodule LinearCli.CLI.Issue.Actions do done = if cancelled, do: "cancelled", else: "closed" if issue.state && issue.state.type in target_types do - Prompt.ok("#{issue.identifier} is already #{issue.state.name}") + Output.status(:ok, "#{issue.identifier} is already #{issue.state.name}", opts) {:ok, issue} else doing = if cancelled, do: "cancelling", else: "closing" @@ -119,11 +121,11 @@ defmodule LinearCli.CLI.Issue.Actions do reason = WhatFor.reason_for(opts[:reason], four: "#{doing} *#{issue.identifier} - #{issue.title}*") - with {:ok, _comment} <- issue_comment(issue, reason), + with {:ok, _comment} <- issue_comment(issue, reason, opts), {:ok, workflow_state} <- state_for(cancelled, issue, opts[:status]), {:ok, updated} <- Linear.close_issue(issue, workflow_state.id, %{trash: !!opts[:trash]}) do - Prompt.ok("#{issue.identifier} was #{done}") + Output.status(:ok, "#{issue.identifier} was #{done}", opts) {:ok, updated} end end @@ -168,11 +170,35 @@ defmodule LinearCli.CLI.Issue.Actions do """ @spec attach_project(%Linear.Issue{}, String.t() | nil) :: {:ok, %Linear.Issue{}} | {:error, term()} - def attach_project(issue, project_search) do + def attach_project(issue, project_search, opts \\ []) do with {:ok, projects} <- Linear.projects_by_team(issue.team.id, %{search: project_search}) do - project = Projects.project_for(projects, project_search) - move_issue(issue, project) + project = + if Output.json?(opts) do + Projects.project_for_strict(projects, project_search) + else + Projects.project_for(projects, project_search) + end + + if project do + case Linear.attach_issue_to_project(issue, project.id) do + {:ok, updated} -> + Output.status(:ok, "#{issue.identifier} was moved to #{project.name}", opts) + {:ok, updated} + + {:error, reason} -> + {:error, reason} + end + else + message = + if Output.json?(opts) do + "JSON output requires an exact project match for #{inspect(project_search)}" + else + "No project found matching #{project_search}" + end + + {:error, {:smells_bad, message}} + end end end @@ -183,12 +209,12 @@ defmodule LinearCli.CLI.Issue.Actions do """ @spec update_description(%Linear.Issue{}, String.t() | nil) :: {:ok, %Linear.Issue{}} | {:error, term()} - def update_description(issue, description_input) do + def update_description(issue, description_input, opts \\ []) do description = WhatFor.description_for(description_input) case Linear.update_issue_description(issue, description) do {:ok, updated} -> - Prompt.ok("#{issue.identifier} description updated") + Output.status(:ok, "#{issue.identifier} description updated", opts) {:ok, updated} {:error, reason} -> @@ -204,10 +230,10 @@ defmodule LinearCli.CLI.Issue.Actions do """ @spec set_priority(%Linear.Issue{}, non_neg_integer()) :: {:ok, %Linear.Issue{}} | {:error, term()} - def set_priority(issue, priority_value) do + def set_priority(issue, priority_value, opts \\ []) do case Linear.set_issue_priority(issue, priority_value) do {:ok, updated} -> - Prompt.ok("#{issue.identifier} priority updated") + Output.status(:ok, "#{issue.identifier} priority updated", opts) {:ok, updated} {:error, reason} -> @@ -237,15 +263,15 @@ defmodule LinearCli.CLI.Issue.Actions do """ @spec update_issue(%Linear.Issue{}, keyword()) :: :ok | {:error, term()} def update_issue(issue, opts \\ []) do - with :ok <- maybe_comment(issue, opts[:comment]) do + with :ok <- maybe_comment(issue, opts[:comment], opts) do dispatch_update(issue, opts) end end - defp maybe_comment(_issue, nil), do: :ok + defp maybe_comment(_issue, nil, _opts), do: :ok - defp maybe_comment(issue, comment) do - case issue_comment(issue, comment) do + defp maybe_comment(issue, comment, opts) do + case issue_comment(issue, comment, opts) do {:ok, _comment} -> :ok {:error, reason} -> {:error, reason} end @@ -256,17 +282,17 @@ defmodule LinearCli.CLI.Issue.Actions do opts[:close] -> normalize(close_issue(issue, opts)) opts[:cancel] -> normalize(cancel_issue(issue, opts)) opts[:pr] -> PullRequest.issue_pr(issue, opts) - opts[:project] -> normalize(attach_project(issue, opts[:project])) - opts[:description] -> normalize(update_description(issue, opts[:description])) - not is_nil(opts[:priority]) -> normalize(set_priority(issue, opts[:priority])) + opts[:project] -> normalize(attach_project(issue, opts[:project], opts)) + opts[:description] -> normalize(update_description(issue, opts[:description], opts)) + not is_nil(opts[:priority]) -> normalize(set_priority(issue, opts[:priority], opts)) opts[:comment] -> :ok - true -> no_action_taken() + true -> no_action_taken(opts) end end - defp no_action_taken do - Prompt.warn("No action taken, no options specified") - Prompt.ok("Issue was not updated") + defp no_action_taken(opts) do + Output.status(:warn, "No action taken, no options specified", opts) + Output.status(:ok, "Issue was not updated", opts) :ok end diff --git a/app/lib/linear_cli/cli/issue/assignment.ex b/app/lib/linear_cli/cli/issue/assignment.ex index 2fd29e8..00b69d7 100644 --- a/app/lib/linear_cli/cli/issue/assignment.ex +++ b/app/lib/linear_cli/cli/issue/assignment.ex @@ -21,7 +21,7 @@ defmodule LinearCli.CLI.Issue.Assignment do """ alias LinearCli.CLI.Issue.{Identifiers, WorkflowStates} - alias LinearCli.CLI.Prompt + alias LinearCli.CLI.Output alias LinearCli.Linear @doc """ @@ -48,7 +48,7 @@ defmodule LinearCli.CLI.Issue.Assignment do {:ok, me} <- resolve_me(opts), {:ok, [issue]} <- Linear.issues(%{ids: [issue_id]}), {:ok, state_id} <- resolve_status_for_issue(issue, status_opt) do - assign_or_confirm(issue, me, issue_id, state_id) + assign_or_confirm(issue, me, issue_id, state_id, opts) end end @@ -71,13 +71,16 @@ defmodule LinearCli.CLI.Issue.Assignment do end end - defp assign_or_confirm(%{assignee: %{id: id}} = issue, %{id: id}, issue_id, nil) do - Prompt.say("You are already assigned #{issue_id}") + defp assign_or_confirm(%{assignee: %{id: id}} = issue, %{id: id}, issue_id, nil, opts) do + # The assignment lookup can be part of a JSON command. Keep this status + # line visible, but send it to stderr so it cannot corrupt the value that + # the caller writes to stdout. + Output.status(:say, "You are already assigned #{issue_id}", opts) {:ok, issue} end - defp assign_or_confirm(issue, me, issue_id, state_id) do - Prompt.say("Assigning issue #{issue_id} to ya") + defp assign_or_confirm(issue, me, issue_id, state_id, opts) do + Output.status(:say, "Assigning issue #{issue_id} to ya", opts) Linear.assign_issue(issue, me.id, %{state_id: state_id}) end diff --git a/app/lib/linear_cli/cli/issue/creation.ex b/app/lib/linear_cli/cli/issue/creation.ex index fd23295..85dc8cc 100644 --- a/app/lib/linear_cli/cli/issue/creation.ex +++ b/app/lib/linear_cli/cli/issue/creation.ex @@ -77,23 +77,32 @@ defmodule LinearCli.CLI.Issue.Creation do project_search = opts[:project] || Profiles.default_project() with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: project_search}) do - project = - if project_search, - do: Projects.project_for_strict(projects, project_search), - else: nil + with {:ok, project} <- resolve_project(projects, project_search) do + label_ids = Enum.map(labels, & &1.id) - label_ids = Enum.map(labels, & &1.id) + params = + %{label_ids: label_ids} + |> maybe_put_project_id(project) + |> maybe_put_priority(opts[:priority]) - params = - %{label_ids: label_ids} - |> maybe_put_project_id(project) - |> maybe_put_priority(opts[:priority]) - - Linear.create_issue(title, description, team.id, params) + Linear.create_issue(title, description, team.id, params) + end end end end + defp resolve_project(_projects, nil), do: {:ok, nil} + + defp resolve_project(projects, search) do + case Projects.project_for_strict(projects, search) do + nil -> + {:error, {:smells_bad, "--yes requires an exact project match for #{inspect(search)}"}} + + project -> + {:ok, project} + end + end + defp require_field(nil, flag), do: {:error, {:smells_bad, "#{flag} is required with --yes"}} diff --git a/app/lib/linear_cli/cli/issue/pull_request.ex b/app/lib/linear_cli/cli/issue/pull_request.ex index 1ce0cf5..f06fbb6 100644 --- a/app/lib/linear_cli/cli/issue/pull_request.ex +++ b/app/lib/linear_cli/cli/issue/pull_request.ex @@ -27,7 +27,7 @@ defmodule LinearCli.CLI.Issue.PullRequest do `LinearCli.Git`'s injectable `cwd:`. """ - alias LinearCli.CLI.{Prompt, WhatFor} + alias LinearCli.CLI.{Output, WhatFor} alias LinearCli.Linear @doc """ @@ -74,7 +74,7 @@ defmodule LinearCli.CLI.Issue.PullRequest do body = opts[:description] || WhatFor.pr_description_for(issue) runner = Keyword.get(opts, :runner, &default_gh_runner/2) - Prompt.warn(create_pr!(title, body, runner)) + Output.status(:warn, create_pr!(title, body, runner), opts) :ok end end diff --git a/app/lib/linear_cli/cli/output.ex b/app/lib/linear_cli/cli/output.ex new file mode 100644 index 0000000..ffe16ad --- /dev/null +++ b/app/lib/linear_cli/cli/output.ex @@ -0,0 +1,38 @@ +defmodule LinearCli.CLI.Output do + @moduledoc """ + Small command-level helpers for keeping JSON output on stdout valid. + + Commands still use `LinearCli.CLI.Display` for their values. This module only + decides whether a value is JSON output and sends human-readable status lines + to stderr when JSON mode is active. + """ + + alias LinearCli.CLI.{Display, Prompt} + + @doc "Returns whether the parsed command options request JSON output." + def json?(options) when is_map(options), do: Map.get(options, :output, "text") == "json" + def json?(options) when is_list(options), do: Keyword.get(options, :output, "text") == "json" + + @doc "Prints a successful command result only in JSON mode." + def success(action, fields, options) when is_map(fields) do + if json?(options) do + Display.show( + Map.merge(%{"action" => to_string(action), "status" => "ok"}, fields), + %{output: "json"} + ) + end + + :ok + end + + @doc "Prints a status line to stderr in JSON mode and stdout otherwise." + def status(kind, message, options) when kind in [:ok, :say, :warn] do + device = if json?(options), do: :stderr, else: :stdio + + case kind do + :ok -> Prompt.ok(message, device) + :say -> Prompt.say(message, device) + :warn -> Prompt.warn(message, device) + end + end +end diff --git a/app/lib/linear_cli/cli/prompt.ex b/app/lib/linear_cli/cli/prompt.ex index 0b60540..68bf3c3 100644 --- a/app/lib/linear_cli/cli/prompt.ex +++ b/app/lib/linear_cli/cli/prompt.ex @@ -77,8 +77,8 @@ defmodule LinearCli.CLI.Prompt do Ported from `TTY::Prompt#ok`. """ - @spec ok(Owl.Data.t()) :: :ok - def ok(message), do: Owl.IO.puts(Owl.Data.tag(message, :green)) + @spec ok(Owl.Data.t(), IO.device()) :: :ok + def ok(message, device \\ :stdio), do: Owl.IO.puts(Owl.Data.tag(message, :green), device) @doc """ Prints `message` as a warning line, in yellow. @@ -93,8 +93,8 @@ defmodule LinearCli.CLI.Prompt do Ported from `TTY::Prompt#say`. """ - @spec say(Owl.Data.t()) :: :ok - def say(message), do: Owl.IO.puts(message) + @spec say(Owl.Data.t(), IO.device()) :: :ok + def say(message, device \\ :stdio), do: Owl.IO.puts(message, device) @doc """ Prompts for a free-text line, returning `opts[:default]` (`nil` unless diff --git a/app/test/linear_cli/cli/commands/issues/create_test.exs b/app/test/linear_cli/cli/commands/issues/create_test.exs index 10f384f..a66c281 100644 --- a/app/test/linear_cli/cli/commands/issues/create_test.exs +++ b/app/test/linear_cli/cli/commands/issues/create_test.exs @@ -7,6 +7,61 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do alias LinearCli.Linear.User describe "issue create (Ruby: commands/issue/create.rb)" do + test "JSON mode rejects interactive creation unless --yes is given" do + output = + capture_io(fn -> + assert {:error, {:smells_bad, "JSON output requires --yes for issue create"}} = + Create.issue_create(%{ + options: %{output: "json", title: nil, description: nil}, + flags: %{yes: false, develop: false, no_take: false} + }) + end) + + assert output == "" + end + + test "JSON --yes rejects a partial project before issue creation" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")])) + + String.contains?(query, "issueCreate") -> + raise "partial JSON project input must be rejected before issue creation" + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert {:error, {:smells_bad, message}} = + Create.issue_create(%{ + options: %{ + output: "json", + title: "T", + description: "D", + team: "ENG", + labels: [], + project: "Manhattan", + priority: nil + }, + flags: %{yes: true, develop: false, no_take: true} + }) + + assert message =~ "exact project match" + end) + + assert output == "" + end + test "resolves every field, declines to take it, and displays the created issue" do stub_responses([ {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, diff --git a/app/test/linear_cli/cli/commands/issues/development_test.exs b/app/test/linear_cli/cli/commands/issues/development_test.exs index 0fc99da..1bf7552 100644 --- a/app/test/linear_cli/cli/commands/issues/development_test.exs +++ b/app/test/linear_cli/cli/commands/issues/development_test.exs @@ -7,6 +7,29 @@ defmodule LinearCli.CLI.Commands.Issues.DevelopmentTest do alias LinearCli.Linear.User describe "issue develop (Ruby: commands/issue/develop.rb)" do + test "--output json emits one value while keeping git status off stdout" do + repo = git_repo!() + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + stub_responses([ + {"issue(id: $id)", + %{"data" => %{"issue" => issue_map(%{"branchName" => "main", "assignee" => me_map()})}}} + ]) + + output = + capture_io(fn -> + assert :ok = + Development.issue_develop( + %{args: %{issue_id: "CRY-1"}, options: %{output: "json"}}, + cwd: repo, + me: me + ) + end) + + assert %{"action" => "issue_develop", "issue" => "CRY-1", "status" => "ok"} = + Jason.decode!(output) + end + test "resolves/self-assigns the issue, checks out its branch, and pulls" do repo = git_repo!() me = %User{id: "u1", name: "Ada", email: "ada@x.com"} @@ -58,6 +81,19 @@ defmodule LinearCli.CLI.Commands.Issues.DevelopmentTest do end describe "issue pr (Ruby: commands/issue/pr.rb)" do + test "JSON mode rejects missing PR fields before checkout" do + output = + capture_io(fn -> + assert {:error, {:smells_bad, "JSON output requires --title for issue pr"}} = + Development.issue_pr(%{ + args: %{issue_id: "CRY-1"}, + options: %{output: "json", title: nil, description: "body"} + }) + end) + + assert output == "" + end + test "checks out the issue's branch (no pull/push) and opens a PR via the injectable runner" do repo = git_repo!() me = %User{id: "u1", name: "Ada", email: "ada@x.com"} @@ -120,5 +156,26 @@ defmodule LinearCli.CLI.Commands.Issues.DevelopmentTest do assert output =~ "No issue found with id nope" assert output =~ "CRY-1" end + + test "--output json emits one value without assignment status on stdout" do + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + stub_responses([ + {"issue(id: $id)", %{"data" => %{"issue" => issue_map(%{"assignee" => nil})}}}, + {"issueUpdate", + %{"data" => %{"issueUpdate" => %{"issue" => issue_map(%{"assignee" => me_map()})}}}} + ]) + + output = + capture_io(fn -> + assert :ok = + Development.issue_take( + %{unknown: ["CRY-1"], options: %{output: "json", status: nil}}, + me: me + ) + end) + + assert %{"identifier" => "CRY-1"} = Jason.decode!(output) + end end end diff --git a/app/test/linear_cli/cli/commands/issues/mutations_test.exs b/app/test/linear_cli/cli/commands/issues/mutations_test.exs index 1e6d880..411bcb8 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -1577,6 +1577,19 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end describe "issue status" do + test "JSON mode rejects the omitted status before any lookup" do + output = + capture_io(fn -> + assert {:error, {:smells_bad, "JSON output requires --status"}} = + Mutations.issue_status(%{ + unknown: ["CRY-1"], + options: %{output: "json", status: nil, comment: nil} + }) + end) + + assert output == "" + end + defp issue_with_state(state_id, state_name) do issue_map(%{"state" => %{"id" => state_id, "name" => state_name, "type" => "started"}}) end @@ -2109,6 +2122,67 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end describe "issue update (Ruby: commands/issue/update.rb)" do + test "JSON close rejects missing reason before loading issues" do + output = + capture_io(fn -> + assert {:error, {:smells_bad, "JSON output requires --reason for close or cancel"}} = + Mutations.issue_update(%{ + unknown: ["CRY-1"], + flags: %{close: true, cancel: false}, + options: %{output: "json", reason: nil, status: "Done"} + }) + end) + + assert output == "" + end + + test "JSON partial project input is rejected before a preceding comment mutation" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")])) + + String.contains?(query, "commentCreate") -> + raise "comment mutation must not run" + + String.contains?(query, "issueUpdate") -> + raise "issue mutation must not run" + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert {:error, {:smells_bad, message}} = + Mutations.issue_update(%{ + unknown: ["CRY-1"], + flags: %{close: false, cancel: false, trash: false}, + options: %{ + output: "json", + comment: "audit", + description: nil, + body_file: nil, + priority: nil, + project: "Manhattan", + reason: nil, + status: nil + } + }) + + assert message =~ "exact project match" + end) + + assert output == "" + end + test "--close --status selects a completed state without prompting" do test_pid = self() @@ -2528,7 +2602,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert_received {:updated, "CRY-2"} end - test "--output json still updates priority and confirms via stdout" do + test "--output json still updates priority and emits one JSON value" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) query = Jason.decode!(body)["query"] @@ -2559,7 +2633,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do ]) end) - assert output =~ "CRY-1 priority updated" + assert %{"action" => "issue_update", "status" => "ok"} = Jason.decode!(output) end test "--priority combined with --comment posts comment first then updates priority" do @@ -2840,6 +2914,38 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert output =~ "assigned to Alice" end + test "JSON mode rejects an omitted assignee before mutation" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "issueUpdate") -> + raise "an omitted JSON assignee must stop before mutation" + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert {:error, {:smells_bad, "JSON output requires --assignee"}} = + Mutations.issue_assign(%{ + args: %{issue_id: "CRY-1"}, + options: %{output: "json", assignee: nil, status: nil} + }) + end) + + assert output == "" + end + test "--output json emits structured output" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) @@ -3566,6 +3672,32 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end describe "issue comment" do + test "JSON mode rejects an omitted comment before loading issues" do + output = + capture_io(fn -> + assert {:error, {:smells_bad, "JSON output requires --comment or --body-file"}} = + Mutations.issue_comment(%{ + unknown: ["CRY-1"], + options: %{output: "json", comment: nil, body_file: nil} + }) + end) + + assert output == "" + end + + test "JSON mode rejects the editor sentinel before loading issues" do + output = + capture_io(fn -> + assert {:error, {:smells_bad, "JSON output requires --comment text, not editor input"}} = + Mutations.issue_comment(%{ + unknown: ["CRY-1"], + options: %{output: "json", comment: "-", body_file: nil} + }) + end) + + assert output == "" + end + defp stub_lookup_and(pairs) do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) diff --git a/app/test/linear_cli/cli/commands/issues/read_test.exs b/app/test/linear_cli/cli/commands/issues/read_test.exs index 47045d8..512d021 100644 --- a/app/test/linear_cli/cli/commands/issues/read_test.exs +++ b/app/test/linear_cli/cli/commands/issues/read_test.exs @@ -142,6 +142,45 @@ defmodule LinearCli.CLI.Commands.Issues.ReadTest do assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p2"}}}} end + test "--output json rejects a partial --project before writing issue output" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "projects(first: $first") -> + Req.Test.json(conn, all_projects([project_map("p1", "Roadmap First")])) + + String.contains?(query, "issues(filter") -> + raise "partial JSON project input must be rejected before the issue query" + + true -> + raise "no stub matched query: #{query}" + end + end) + + {:ok, stderr} = StringIO.open("") + + stdout = + capture_io(fn -> + LinearCli.CLI.main( + ["issue", "list", "--output", "json", "--project", "Roadmap"], + halt, + stderr: stderr + ) + end) + + {_input, stderr_text} = StringIO.contents(stderr) + StringIO.close(stderr) + + assert stdout == "" + assert stderr_text =~ "exact project match" + assert_received {:halted, 22} + end + test "--project with --team resolves against team-scoped projects only" do test_pid = self() @@ -1183,37 +1222,20 @@ defmodule LinearCli.CLI.Commands.Issues.ReadTest do 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) - + test "--web with --output json is rejected before opening the browser" do output = capture_io(fn -> - assert :ok = + assert {:error, {:smells_bad, "JSON output cannot be used with --web"}} = Read.issue_view( %{ args: %{issue_id: "CRY-1"}, flags: %{web: true}, options: %{output: "json"} }, - opener: fn url -> - send(test_pid, {:opened, url}) - :ok - end + opener: fn _url -> flunk("the browser must not be opened") end ) end) - assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} assert output == "" end end diff --git a/app/test/linear_cli/cli/favorites_commands_test.exs b/app/test/linear_cli/cli/favorites_commands_test.exs index 1b32d44..0814346 100644 --- a/app/test/linear_cli/cli/favorites_commands_test.exs +++ b/app/test/linear_cli/cli/favorites_commands_test.exs @@ -109,6 +109,17 @@ defmodule LinearCli.CLI.FavoritesCommandsTest do end describe "team favorite/unfavorite" do + test "JSON favorite emits one value without a confirmation line" do + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["team", "favorite", "ENG", "--output", "json"]) + end) + + assert %{"action" => "team_favorite", "status" => "ok", "team" => "ENG"} = + Jason.decode!(output) + end + test "favorites a team by key" do assert capture_io(fn -> assert :ok = LinearCli.CLI.main(["team", "favorite", "ENG"]) end) =~ "Favorited team ENG" @@ -127,6 +138,76 @@ defmodule LinearCli.CLI.FavoritesCommandsTest do end describe "project favorite/unfavorite" do + test "JSON partial project input is rejected before favorite mutation" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + {:ok, stderr} = StringIO.open("") + + stdout = + capture_io(fn -> + LinearCli.CLI.main( + ["project", "favorite", "Platform", "--output", "json"], + halt, + stderr: stderr + ) + end) + + {_input, stderr_text} = StringIO.contents(stderr) + StringIO.close(stderr) + + assert stdout == "" + assert stderr_text =~ "exact project match" + assert_received {:halted, 22} + assert LinearCli.Favorites.list("project") == [] + end + + test "JSON partial project input is rejected before project update mutation" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + {:ok, stderr} = StringIO.open("") + + stdout = + capture_io(fn -> + LinearCli.CLI.main( + [ + "project", + "update", + "Platform", + "--team", + "ENG", + "--body", + "Update", + "--health", + "onTrack", + "--output", + "json" + ], + halt, + stderr: stderr + ) + end) + + {_input, stderr_text} = StringIO.contents(stderr) + StringIO.close(stderr) + + assert stdout == "" + assert stderr_text =~ "exact project match" + assert_received {:halted, 22} + end + + test "JSON favorite resolves exact project and emits one value" do + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["project", "favorite", "Manhattan", "--output", "json"]) + end) + + assert %{"action" => "project_favorite", "status" => "ok", "project" => "p1"} = + Jason.decode!(output) + end + test "resolves the project by name and favorites its id" do assert capture_io(fn -> assert :ok = LinearCli.CLI.main(["project", "favorite", "Manhattan"]) diff --git a/app/test/linear_cli/cli/profiles_commands_test.exs b/app/test/linear_cli/cli/profiles_commands_test.exs new file mode 100644 index 0000000..c9ff7e9 --- /dev/null +++ b/app/test/linear_cli/cli/profiles_commands_test.exs @@ -0,0 +1,52 @@ +defmodule LinearCli.CLI.ProfilesCommandsTest do + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + alias LinearCli.Profiles + + setup do + path = Application.fetch_env!(:linear_cli, :profiles_db_path) + File.rm(path) + :ok + end + + test "JSON profile use and clear each emit one value" do + {:ok, _profile} = Profiles.create("work", team: "ENG") + + use_output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["profile", "use", "work", "--output", "json"]) + end) + + assert %{"action" => "profile_use", "profile" => "work", "status" => "ok"} = + Jason.decode!(use_output) + + clear_output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["profile", "clear", "--output", "json"]) + end) + + assert %{"action" => "profile_clear", "status" => "ok"} = Jason.decode!(clear_output) + end + + test "JSON profile delete emits one value" do + {:ok, _profile} = Profiles.create("work") + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["profile", "delete", "work", "--output", "json"]) + end) + + assert %{"action" => "profile_delete", "profile" => "work", "status" => "ok"} = + Jason.decode!(output) + end + + test "JSON profile show returns null when there is no active profile" do + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["profile", "show", "--output", "json"]) + end) + + assert Jason.decode!(output) == nil + end +end diff --git a/documents/phase-12-json-output-decision.adoc b/documents/phase-12-json-output-decision.adoc index 4863d1a..3376449 100644 --- a/documents/phase-12-json-output-decision.adoc +++ b/documents/phase-12-json-output-decision.adoc @@ -47,3 +47,45 @@ Text-mode expansion, full identifiers, and UUID identifiers are unchanged. This addendum applies to every command that uses the shared bare-number path. It does not change partial project or assignee resolution. + +== EXT-74 addendum: remaining interactive commands + +The same stdout rule applies to the remaining command-level prompt paths. JSON +issue lists, updates, favorites, and project updates use exact project +resolution. JSON issue assignment requires `--assignee`; JSON status changes +require `--status`; JSON comments require `--comment` or `--body-file`; and +JSON issue creation requires `--yes`. JSON issue PR creation requires an +explicit title and description, and `issue view --web` is rejected because it +has no JSON value to print. These checks happen before any mutation that could +otherwise precede a prompt. + +Successful profile, favorite, development, and update commands write one +JSON value. Informational status lines that remain useful are sent to stderr. +Text-mode prompts and messages remain unchanged. This is a caller-level +decision; it does not redesign the shared display layer. + +=== Prompt inventory + +The audited prompt inputs and their JSON behavior are: + +* `issue list --project`, `project favorite`, `project unfavorite`, + `project update`, and `issue update --project`: partial or `-` project + searches are rejected; text mode still selects a project. +* `project favorite`, `project unfavorite`, and `project update`: an omitted + team is accepted only when an active profile or a single workspace team + resolves it; otherwise the command is rejected instead of asking for a team. +* `issue assign`: an omitted `--assignee` is rejected; text mode still opens + the assignee menu. +* `issue status`: an omitted `--status` or editor-style `--comment -` is + rejected; text mode still opens the state menu and editor. +* `issue comment`: an omitted body or editor-style `--comment -` is rejected; + `--body-file` remains available for text and stdin input. +* `issue create`: JSON requires `--yes`, which prevents title, description, + team, label, and project prompts; `--dev` is rejected in JSON mode. +* `issue pr`: missing `--title` or `--description` is rejected before issue + assignment, checkout, or the `gh` command. +* `issue update --close`/`--cancel`: `--reason` and `--status` are required, + and editor sentinels are rejected before any comment or issue mutation. +* `issue view --web` is rejected with JSON because browser output has no JSON + value. `issue move` and `issue unassign` remain covered by EXT-70, and bare + number team resolution remains covered by its own follow-up.