From 3feef4df3aa9c541b8952294b13fdf5784ca4a37 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 09:18:08 -0400 Subject: [PATCH 1/5] refactor(cli): split ordinary issue command families Extract Read, Create, Development, and Mutations from the monolithic CLI.Commands into focused modules under CLI.Commands.Issues.*. Update CLI dispatch, profile_defaults_test, and split issue_commands_test.exs into per-family test files backed by a shared IssueCommandsHelpers support module. CLI.Commands retains only Move and Relations (EXT-54). Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli.ex | 21 +- app/lib/linear_cli/cli/commands.ex | 639 +-- .../linear_cli/cli/commands/issues/create.ex | 121 + .../cli/commands/issues/development.ex | 138 + .../cli/commands/issues/mutations.ex | 356 ++ .../linear_cli/cli/commands/issues/read.ex | 107 + app/mix.exs | 4 + .../cli/commands/issues/create_test.exs | 536 +++ .../cli/commands/issues/development_test.exs | 124 + .../cli/commands/issues/mutations_test.exs | 1551 +++++++ .../cli/commands/issues/read_test.exs | 984 +++++ .../linear_cli/cli/issue_commands_test.exs | 3640 +---------------- .../linear_cli/cli/profile_defaults_test.exs | 21 +- app/test/support/issue_commands_helpers.ex | 155 + 14 files changed, 4109 insertions(+), 4288 deletions(-) create mode 100644 app/lib/linear_cli/cli/commands/issues/create.ex create mode 100644 app/lib/linear_cli/cli/commands/issues/development.ex create mode 100644 app/lib/linear_cli/cli/commands/issues/mutations.ex create mode 100644 app/lib/linear_cli/cli/commands/issues/read.ex create mode 100644 app/test/linear_cli/cli/commands/issues/create_test.exs create mode 100644 app/test/linear_cli/cli/commands/issues/development_test.exs create mode 100644 app/test/linear_cli/cli/commands/issues/mutations_test.exs create mode 100644 app/test/linear_cli/cli/commands/issues/read_test.exs create mode 100644 app/test/support/issue_commands_helpers.ex diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index be7d12f..4591730 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -8,6 +8,7 @@ defmodule LinearCli.CLI do """ alias LinearCli.CLI.Commands + alias LinearCli.CLI.Commands.Issues.{Create, Development, Mutations, Read} alias LinearCli.CLI.Commands.{Profiles, Projects, Teams} alias LinearCli.CLI.Commands.System, as: SystemCmds @@ -232,23 +233,23 @@ defmodule LinearCli.CLI do defp dispatch([:profile, :clear], 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) - defp dispatch([:issue, :assign], result, halt), do: run(&Commands.issue_assign/1, result, halt) - defp dispatch([:issue, :create], result, halt), do: run(&Commands.issue_create/1, result, halt) + defp dispatch([:issue, :list], result, halt), do: run(&Read.issue_list/1, result, halt) + defp dispatch([:issue, :view], result, halt), do: run(&Read.issue_view/1, result, halt) + defp dispatch([:issue, :assign], result, halt), do: run(&Mutations.issue_assign/1, result, halt) + defp dispatch([:issue, :create], result, halt), do: run(&Create.issue_create/1, result, halt) defp dispatch([:issue, :develop], result, halt), - do: run(&Commands.issue_develop/1, result, halt) + do: run(&Development.issue_develop/1, result, halt) - defp dispatch([:issue, :pr], result, halt), do: run(&Commands.issue_pr/1, result, halt) + defp dispatch([:issue, :pr], result, halt), do: run(&Development.issue_pr/1, result, halt) defp dispatch([:issue, :move], result, halt), do: run(&Commands.issue_move/1, result, halt) defp dispatch([:issue, :comment], result, halt), - do: run(&Commands.issue_comment/1, result, halt) + do: run(&Mutations.issue_comment/1, result, halt) - defp dispatch([:issue, :take], result, halt), do: run(&Commands.issue_take/1, result, halt) - defp dispatch([:issue, :status], result, halt), do: run(&Commands.issue_status/1, result, halt) - defp dispatch([:issue, :update], result, halt), do: run(&Commands.issue_update/1, result, halt) + defp dispatch([:issue, :take], result, halt), do: run(&Development.issue_take/1, result, halt) + defp dispatch([:issue, :status], result, halt), do: run(&Mutations.issue_status/1, result, halt) + defp dispatch([:issue, :update], result, halt), do: run(&Mutations.issue_update/1, result, halt) defp dispatch([:issue, :relation, :list], result, halt), do: run(&Commands.issue_relation_list/1, result, halt) diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 67d1531..c971dae 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -1,436 +1,23 @@ defmodule LinearCli.CLI.Commands do @moduledoc """ - Issue command implementations: fetch via `LinearCli.Linear`, display the - result. Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/**. + Remaining issue command implementations pending extraction in EXT-54: move + and relation commands. 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: + All other 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`. + `LinearCli.CLI.Commands.Projects`, `LinearCli.CLI.Commands.Profiles`, + `LinearCli.CLI.Commands.Issues.Read`, `LinearCli.CLI.Commands.Issues.Create`, + `LinearCli.CLI.Commands.Issues.Development`, and + `LinearCli.CLI.Commands.Issues.Mutations`. """ - alias LinearCli.Browser alias LinearCli.CLI.{Display, Projects, Prompt, WhatFor} - alias LinearCli.CLI.Issue.{Actions, Assignment, Creation, Identifiers, PullRequest} - alias LinearCli.{Git, Linear, Profiles} + alias LinearCli.CLI.Issue.Identifiers + alias LinearCli.{Linear, Profiles} @max_concurrent_issue_updates 20 - @doc """ - Ported from commands/issue/list.rb + operations/issue/list.rb. - - `--project`/`-p` resolution is team-scoped when `--team` is given (or - the active profile supplies a team) - it searches that team's projects via - `projects_by_team`. Without a team context it falls back to all workspace - projects (`Project.all`). Prompts interactively when the search is - ambiguous or omitted-but-requested (`-p -`). Only resolved at all when - `--project` was actually given (or `LinearCli.Profiles.default_project/0` - supplies one) - unlike `issue create`/`issue update`, a bare `issue list` - with no active profile applies no project filter and never prompts. - `--team`/`--project` passed explicitly always win over the active profile. - """ - def issue_list(%{flags: flags, options: options, unknown: ids}) do - no_profile = Map.get(flags, :no_profile, false) - team_key = options.team || unless no_profile, do: Profiles.default_team() - - project_source = - options.project || unless no_profile, do: Profiles.default_project() - - with {:ok, project_id} <- resolve_project_id(project_source, team_key) do - label_filter = Map.get(options, :labels) || [] - include_labels = Map.get(flags, :include_labels, false) || label_filter != [] - - input = %{ - ids: Enum.map(ids, &Identifiers.expand_issue_id/1), - mine: !flags.no_mine, - unassigned: flags.unassigned, - team_key: team_key, - project_id: project_id, - all: Map.get(flags, :all, false), - state: Map.get(options, :state) || [], - status: Map.get(options, :status) || [], - labels: label_filter, - include_labels: include_labels - } - - with {:ok, issues} <- Linear.issues(input) do - Display.show(issues, %{ - output: options.output, - full: flags.full, - labels: include_labels - }) - - :ok - end - end - end - - @doc """ - Shows full details for a single issue - equivalent to `issue list --full ISSUE_ID`. - - Mirrors `gh issue view`: a dedicated verb for the single-issue display case, - making it discoverable without knowing about `list`'s `--full` flag. - - With `-w`/`--web`, opens the issue URL in the default browser instead of - printing it. The `opts` keyword arg accepts an injectable `opener` for tests. - """ - @spec issue_view(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} - def issue_view(result, opts \\ []) - - def issue_view(%{args: %{issue_id: issue_id}, flags: flags, options: options}, opts) do - expanded_id = Identifiers.expand_issue_id(issue_id) - - with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do - if flags.web do - Browser.open_url(issue.url, opts) - else - Display.show(issue, %{output: options.output, full: true}) - :ok - end - end - end - - defp resolve_project_id(nil, _team_key), do: {:ok, nil} - - defp resolve_project_id(search, team_key) when is_binary(team_key) do - with {:ok, team} <- Linear.find_team(team_key), - {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}) do - case Projects.project_for(projects, search) do - nil -> {:ok, nil} - project -> {:ok, project.id} - end - end - end - - defp resolve_project_id(search, _team_key) do - with {:ok, projects} <- Linear.projects() do - case Projects.project_for(projects, search) do - nil -> {:ok, nil} - project -> {:ok, project.id} - end - end - end - - @doc """ - Ported from commands/issue/create.rb: resolves every field - (`LinearCli.CLI.Issue.Creation.make_da_issue!/1`), optionally self-assigns it - (`prompt.yes?('Do you want to take this issue?')`, unless `--no-take` was - given), displays it, then, if `--dev` was given, chains straight into the - same flow as `issue_develop/2` - (Ruby: `Rubyists::Linear::CLI::Issue::Develop.new.call(issue_id: issue.id, - **options)`). - - `opts` isn't part of Ruby's `call(**options)` arity - it exists purely to - inject test doubles into whatever this command chains into: `:me` - (`Assignment.gimme_da_issue!/2`, both for the self-assign prompt and, if - `--dev` fires, `run_develop/2`'s own re-fetch), `:cwd` - (`LinearCli.Git.checkout_branch/2`/`pull_or_push_new_branch!/2`, only - reached with `--dev`). Real callers (`LinearCli.CLI.main/2`) omit it. - """ - @spec issue_create(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} - def issue_create(result, opts \\ []) - - def issue_create(%{options: options, flags: flags}, opts) do - with :ok <- validate_no_take_develop(flags), - :ok <- validate_body_file_exclusion(options, :description, "--description"), - {:ok, description} <- resolve_body_from_file(options, :description), - create_opts = [ - title: options.title, - description: description, - team: options.team, - labels: options.labels, - project: options.project, - yes: flags.yes - ], - {:ok, issue} <- Creation.make_da_issue!(create_opts), - :ok <- maybe_take(issue, flags, opts) do - Display.show(issue, %{output: options.output}) - if flags.develop, do: run_develop(issue.id, opts), else: :ok - end - end - - defp validate_no_take_develop(%{no_take: true, develop: true}), - do: {:error, {:smells_bad, "--no-take cannot be used with --dev"}} - - defp validate_no_take_develop(_flags), do: :ok - - defp maybe_take(_issue, %{no_take: true}, _opts), do: :ok - - defp maybe_take(issue, %{yes: true}, opts) do - case Assignment.gimme_da_issue!(issue.id, opts) do - {:ok, _updated} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp maybe_take(issue, _flags, opts) do - if Prompt.yes?("Do you want to take this issue?") do - case Assignment.gimme_da_issue!(issue.id, opts) do - {:ok, _updated} -> :ok - {:error, reason} -> {:error, reason} - end - else - :ok - end - end - - @doc """ - Ported from commands/issue/develop.rb: resolves/self-assigns `issue_id` - (`LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2`), checks out its - `branch_name` (creating it first if it doesn't exist locally yet), then - pulls it (or, if there's no upstream tracking branch yet, pushes it to - `origin` and sets one up). - - `opts` (this port's addition, not part of Ruby's `call(issue_id:, - **options)`) forwards to `LinearCli.Git.checkout_branch/2`/ - `pull_or_push_new_branch!/2` (`:cwd`) and - `LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2` (`:me`) - pass overrides - in tests so this never shells out to real git or hits a real `viewer` query; - real callers omit it. - """ - @spec issue_develop(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} - def issue_develop(result, opts \\ []) - def issue_develop(%{args: %{issue_id: issue_id}}, opts), do: run_develop(issue_id, opts) - - 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}") - finish_pull_or_push(issue.branch_name, opts) - end - end - - # Ported from `SubCommands#pull_or_push_new_branch!`'s own prompt calls - # (`prompt.warn`/`prompt.ok`, printed around the push+set-upstream fallback - # only) plus `Issue::Develop#call`'s trailing `prompt.ok 'Ready to - # develop!'` (printed unconditionally, after either branch). - 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!") - :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!") - :ok - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Ported from commands/issue/pr.rb: resolves/self-assigns `issue_id`, checks - out its branch (creating it first if needed - no pull/push here, unlike - `issue_develop/2`), then opens a PR via - `LinearCli.CLI.Issue.PullRequest.issue_pr/2`. - - `opts` (this port's addition): `:cwd` (forwarded to - `LinearCli.Git.checkout_branch/2`), `:me` (forwarded to - `gimme_da_issue!/2`), `:runner` (forwarded to `issue_pr/2`, so this never - shells out to a real `gh` in tests). Real callers omit it. - """ - @spec issue_pr(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} - def issue_pr(result, opts \\ []) - - def issue_pr(%{args: %{issue_id: issue_id}, options: options}, 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}") - - pr_opts = - [title: options.title, description: options.description] - |> maybe_put(:runner, opts[:runner]) - - PullRequest.issue_pr(issue, pr_opts) - end - end - - defp maybe_put(list, _key, nil), do: list - defp maybe_put(list, key, value), do: Keyword.put(list, key, value) - - @doc """ - Ported from commands/issue/take.rb: self-assigns every issue id in - `unknown` (Ruby's `issue_ids:`, a variadic positional argument - Optimus - has no declared-arity equivalent to `type: :array` positional args, so, - like `issue_list/1`'s own `ids`, it's captured via the subcommand's - `allow_unknown_args: true` + the parse result's `unknown` list), skipping - (and warning about) any id that doesn't exist rather than aborting the - whole batch - matching Ruby's `rescue NotFoundError => e ... next` inside - its `filter_map`. - - `opts` (this port's addition) forwards to - `LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2` (`:me`); real callers - omit it. - """ - @spec issue_take(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} - def issue_take(result, opts \\ []) - - def issue_take(%{unknown: issue_ids, options: options}, opts) do - opts = maybe_put_status(opts, Map.get(options, :status)) - - with {:ok, updates} <- take_issues(issue_ids, opts) do - Display.show(updates, %{output: options.output}) - :ok - end - end - - defp maybe_put_status(opts, nil), do: opts - defp maybe_put_status(opts, status), do: Keyword.put(opts, :status, status) - - defp take_issues(issue_ids, opts) do - issue_ids - |> Enum.reduce_while({:ok, []}, fn issue_id, {:ok, acc} -> - case Assignment.gimme_da_issue!(issue_id, opts) do - {:ok, issue} -> - {:cont, {:ok, [issue | acc]}} - - {:error, %Ash.Error.Unknown{errors: [%{value: [{:not_found, id}]} | _]}} -> - Prompt.warn("No issue found with id #{id}") - {:cont, {:ok, acc}} - - {:error, reason} -> - {:halt, {:error, reason}} - end - end) - |> case do - {:ok, acc} -> {:ok, Enum.reverse(acc)} - error -> error - end - end - - @doc """ - Ported from commands/issue/update.rb: looks up every issue id in `unknown` - (see `issue_take/2`'s doc for why this is a variadic positional captured - via `unknown` rather than a declared Optimus arg) and dispatches - `LinearCli.CLI.Issue.Actions.update_issue/2` against each, per whichever - flags/options were given. - - Ports `raise SmellsBad, 'No issue IDs provided!' if issue_ids.empty?` as - `{:error, {:smells_bad, "No issue IDs provided!"}}` (mapped to exit 22 by - `LinearCli.CLI.handle_error/3`). Ruby's second guard - `raise SmellsBad, - '...' if options[:pr] && issue_ids.size > 1` - has no equivalent here: - the real `update.rb` never actually registers a `--pr` option/flag - (`options[:pr]` can never be truthy there either), so it's dead code in - the original and isn't ported. - """ - @spec issue_update(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_update(%{unknown: issue_ids, options: options, flags: flags}) do - with :ok <- validate_issue_ids(issue_ids), - :ok <- validate_body_file_exclusion(options, :description, "--description"), - {:ok, description} <- resolve_body_from_file(options, :description), - {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}) do - update_opts = [ - comment: options.comment, - description: description, - project: options.project, - cancel: flags.cancel, - close: flags.close, - reason: options.reason, - status: Map.get(options, :status), - trash: flags.trash - ] - - 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 - end - - @doc """ - Adds a comment to one or more issues (ISSUE_ID...). - - `--comment`/`-m` and `--body-file` are mutually exclusive. `--body-file` - reads the body from a file (`-` for stdin) - the way to supply a large - multi-line body without building it as a single shell argument, which - is what `--comment`, going through - `LinearCli.CLI.WhatFor.comment_for/2`'s prompt/editor resolution, does - not protect against. Without either option, `comment_for/2`'s existing - behavior applies (prompt, or open an editor for `-`). - - When multiple issue IDs are given, the same comment body is posted to - each concurrently. The interactive prompt (when neither `-m` nor - `--body-file` is given) uses the first issue's context. - - Calls `Linear.add_comment/2` directly rather than - `LinearCli.CLI.Issue.Actions.issue_comment/2` so the confirmation can be - suppressed under `--output json` - matching how `print_move_results/3` - suppresses its own confirmation for `issue move --output json`. - - New in this port - Ruby has no equivalent. - """ - @spec issue_comment(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_comment(%{unknown: issue_ids, options: options}) do - with :ok <- validate_issue_ids(issue_ids), - :ok <- validate_body_file_exclusion(options, :comment, "--comment"), - {:ok, comment_text} <- resolve_body_from_file(options, :comment), - {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), - body = WhatFor.comment_for(hd(issues), comment_text), - {:ok, pairs} <- add_comments_to_issues(issues, body) do - unless options.output == "json" do - Enum.each(pairs, fn {issue, _comment} -> - Prompt.ok("Comment added to #{issue.identifier}") - end) - end - - Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: options.output}) - :ok - end - end - - defp add_comments_to_issues(issues, body) do - issues - |> Task.async_stream( - fn issue -> - case Linear.add_comment(issue.identifier, body) do - {:ok, comment} -> {:ok, {issue, comment}} - {:error, reason} -> {:error, reason} - end - end, - max_concurrency: min(length(issues), @max_concurrent_issue_updates), - ordered: true, - timeout: 30_000 - ) - |> Enum.reduce_while({:ok, []}, fn - {:ok, {:ok, pair}}, {:ok, acc} -> {:cont, {:ok, [pair | acc]}} - {:ok, {:error, reason}}, _acc -> {:halt, {:error, reason}} - {:exit, reason}, _acc -> {:halt, {:error, {:task_exit, reason}}} - end) - |> then(fn - {:ok, results} -> {:ok, Enum.reverse(results)} - error -> error - end) - end - - defp validate_body_file_exclusion(options, text_key, flag_name) do - if not is_nil(Map.get(options, :body_file)) and not is_nil(Map.get(options, text_key)) do - {:error, {:smells_bad, "give #{flag_name} or --body-file, not both"}} - else - :ok - end - end - - defp resolve_body_from_file(options, text_key) do - case Map.get(options, :body_file) do - nil -> {:ok, Map.get(options, text_key)} - "-" -> {:ok, read_stdin()} - path -> File.read(path) - end - end - - defp read_stdin do - case IO.read(:stdio, :eof) do - :eof -> "" - data -> data - end - end - @doc """ Moves issues to a target project. @@ -642,133 +229,9 @@ defmodule LinearCli.CLI.Commands do :ok end - @doc """ - Changes the workflow state of one or more issues. Optimus captures the IDs in - `unknown`, since it has no variadic positional-argument type. - - With `--status`/`-s`, matches the given name against the issue's team's - workflow states (case-insensitive exact, then unique prefix). Without it, - prompts interactively via `LinearCli.CLI.Prompt.select/2`. - - With `--comment`/`-m`, adds a comment to each issue before transitioning it. - Mutations for separate issues run concurrently with a limit of 20 in flight. - """ - @spec issue_status(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_status(%{unknown: issue_ids, options: options}) do - with :ok <- validate_issue_ids(issue_ids), - {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), - {:ok, planned_updates} <- plan_status_updates(issues, options.status), - {:ok, completed_updates} <- apply_status_updates(planned_updates, options.comment) do - show_status_updates(completed_updates, options.output) - end - end - - defp plan_status_updates(issues, status) do - issues - |> Enum.reduce_while({:ok, []}, fn issue, {:ok, updates} -> - with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id), - {:ok, target_state} <- resolve_target_state(states, status) do - {:cont, {:ok, [{issue, target_state} | updates]}} - else - {:error, reason} -> {:halt, {:error, reason}} - end - end) - |> reverse_status_updates() - end - - defp apply_status_updates([], _comment), do: {:ok, []} - - defp apply_status_updates(planned_updates, comment) do - planned_updates - |> Task.async_stream( - fn {issue, target_state} -> - apply_status_update(issue, target_state, comment) - end, - max_concurrency: min(length(planned_updates), @max_concurrent_issue_updates), - ordered: true, - timeout: 30_000 - ) - |> Enum.reduce_while({:ok, []}, fn - {:ok, {:ok, update}}, {:ok, updates} -> - {:cont, {:ok, [update | updates]}} - - {:ok, {:error, reason}}, {:ok, _updates} -> - {:halt, {:error, reason}} - - {:exit, reason}, {:ok, _updates} -> - {:halt, {:error, {:task_exit, reason}}} - end) - |> reverse_status_updates() - end - - defp apply_status_update(issue, target_state, comment) do - with :ok <- maybe_add_status_comment(issue, comment), - {:ok, updated} <- Linear.set_issue_status(issue, target_state.id) do - {:ok, {updated, target_state}} - end - end - - defp reverse_status_updates({:ok, updates}), do: {:ok, Enum.reverse(updates)} - defp reverse_status_updates(error), do: error - - defp show_status_updates(completed_updates, output) do - updated_issues = Enum.map(completed_updates, &elem(&1, 0)) - Display.show(one_or_many(updated_issues), %{output: output}) - - if output != "json" do - Enum.each(completed_updates, fn {updated, target_state} -> - Prompt.ok("#{updated.identifier} status set to #{target_state.name}") - end) - end - - :ok - end - defp one_or_many([one]), do: one defp one_or_many(many), do: many - 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)} - end - - defp resolve_target_state(states, name) do - normalized_name = String.downcase(name) - - states - |> Enum.filter(&(String.downcase(&1.name) == normalized_name)) - |> use_prefix_matches_if_empty(states, normalized_name) - |> resolve_state_matches(states, name) - end - - defp use_prefix_matches_if_empty([], states, name) do - Enum.filter(states, &String.starts_with?(String.downcase(&1.name), name)) - end - - defp use_prefix_matches_if_empty(matches, _states, _name), do: matches - - defp resolve_state_matches([state], _states, _name), do: {:ok, state} - - defp resolve_state_matches([], states, name) do - available = Enum.map_join(states, ", ", & &1.name) - {:error, {:smells_bad, "Unknown status #{inspect(name)}. Available: #{available}"}} - end - - defp resolve_state_matches(matches, _states, name) do - ambiguous = Enum.map_join(matches, ", ", & &1.name) - {: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, comment) do - case Actions.issue_comment(issue, comment) do - {:ok, _} -> :ok - {:error, reason} -> {:error, reason} - end - end - @doc """ Lists the relationships for a single issue — both outbound (issues this one blocks/is-related-to/is-duplicate-of) and inbound (issues that block this @@ -1135,86 +598,4 @@ defmodule LinearCli.CLI.Commands do do: "LINEAR_API_KEY is not set" defp relation_remove_error_message(_reason), do: "unexpected error" - - defp resolve_optional_status(_issue, nil), do: {:ok, nil} - - defp resolve_optional_status(issue, name) do - with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id), - {:ok, state} <- resolve_target_state(states, name) do - {:ok, state.id} - end - end - - @doc """ - Assigns an issue to a team member. - - With `--assignee`/`-a`, matches the given name against the issue's team's - members (case-insensitive exact, then unique prefix). Without it, prompts - interactively via `LinearCli.CLI.Prompt.select/2`. - """ - @spec issue_assign(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_assign(%{args: %{issue_id: issue_id}, options: options}) do - expanded_id = Identifiers.expand_issue_id(issue_id) - - with {: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, 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: options.output}) - - if options.output != "json" do - msg = "#{updated.identifier} assigned to #{target_member.name}" - - msg = - if updated.state, - do: "#{msg} and set to #{updated.state.name}", - else: msg - - Prompt.ok(msg) - end - - :ok - end - end - - defp guard_has_members([], issue) do - {:error, - {:smells_bad, "No assignable members found for team #{issue.team.key || issue.team.id}"}} - end - - 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)} - end - - defp resolve_target_member(members, name) do - normalized = String.downcase(name) - - members - |> Enum.filter(&(String.downcase(&1.name) == normalized)) - |> use_prefix_member_matches_if_empty(members, normalized) - |> resolve_member_matches(members, name) - end - - defp use_prefix_member_matches_if_empty([], members, name) do - Enum.filter(members, &String.starts_with?(String.downcase(&1.name), name)) - end - - defp use_prefix_member_matches_if_empty(matches, _members, _name), do: matches - - defp resolve_member_matches([member], _members, _name), do: {:ok, member} - - defp resolve_member_matches([], members, name) do - available = Enum.map_join(Enum.sort_by(members, & &1.name), ", ", & &1.name) - {:error, {:smells_bad, "Unknown assignee #{inspect(name)}. Available: #{available}"}} - end - - defp resolve_member_matches(matches, _members, name) do - ambiguous = Enum.map_join(matches, ", ", & &1.name) - {:error, {:smells_bad, "Ambiguous assignee #{inspect(name)}: matches #{ambiguous}"}} - end end diff --git a/app/lib/linear_cli/cli/commands/issues/create.ex b/app/lib/linear_cli/cli/commands/issues/create.ex new file mode 100644 index 0000000..b0807f3 --- /dev/null +++ b/app/lib/linear_cli/cli/commands/issues/create.ex @@ -0,0 +1,121 @@ +defmodule LinearCli.CLI.Commands.Issues.Create do + @moduledoc """ + Issue create command. + Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/create.rb. + """ + + alias LinearCli.CLI.{Display, Prompt} + alias LinearCli.CLI.Issue.{Assignment, Creation} + alias LinearCli.Git + + @doc """ + Ported from commands/issue/create.rb: resolves every field + (`LinearCli.CLI.Issue.Creation.make_da_issue!/1`), optionally self-assigns it + (`prompt.yes?('Do you want to take this issue?')`, unless `--no-take` was + given), displays it, then, if `--dev` was given, chains straight into the + same flow as `issue_develop/2` + (Ruby: `Rubyists::Linear::CLI::Issue::Develop.new.call(issue_id: issue.id, + **options)`). + + `opts` isn't part of Ruby's `call(**options)` arity - it exists purely to + inject test doubles into whatever this command chains into: `:me` + (`Assignment.gimme_da_issue!/2`, both for the self-assign prompt and, if + `--dev` fires, `run_develop/2`'s own re-fetch), `:cwd` + (`LinearCli.Git.checkout_branch/2`/`pull_or_push_new_branch!/2`, only + reached with `--dev`). Real callers (`LinearCli.CLI.main/2`) omit it. + """ + @spec issue_create(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} + def issue_create(result, opts \\ []) + + def issue_create(%{options: options, flags: flags}, opts) do + with :ok <- validate_no_take_develop(flags), + :ok <- validate_body_file_exclusion(options, :description, "--description"), + {:ok, description} <- resolve_body_from_file(options, :description), + create_opts = [ + title: options.title, + description: description, + team: options.team, + labels: options.labels, + project: options.project, + yes: flags.yes + ], + {:ok, issue} <- Creation.make_da_issue!(create_opts), + :ok <- maybe_take(issue, flags, opts) do + Display.show(issue, %{output: options.output}) + if flags.develop, do: run_develop(issue.id, opts), else: :ok + end + end + + defp validate_no_take_develop(%{no_take: true, develop: true}), + do: {:error, {:smells_bad, "--no-take cannot be used with --dev"}} + + defp validate_no_take_develop(_flags), do: :ok + + defp maybe_take(_issue, %{no_take: true}, _opts), do: :ok + + defp maybe_take(issue, %{yes: true}, opts) do + case Assignment.gimme_da_issue!(issue.id, opts) do + {:ok, _updated} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp maybe_take(issue, _flags, opts) do + if Prompt.yes?("Do you want to take this issue?") do + case Assignment.gimme_da_issue!(issue.id, opts) do + {:ok, _updated} -> :ok + {:error, reason} -> {:error, reason} + end + else + :ok + end + end + + 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}") + finish_pull_or_push(issue.branch_name, opts) + end + end + + 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!") + :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!") + :ok + + {:error, reason} -> + {:error, reason} + end + end + + defp validate_body_file_exclusion(options, text_key, flag_name) do + if not is_nil(Map.get(options, :body_file)) and not is_nil(Map.get(options, text_key)) do + {:error, {:smells_bad, "give #{flag_name} or --body-file, not both"}} + else + :ok + end + end + + defp resolve_body_from_file(options, text_key) do + case Map.get(options, :body_file) do + nil -> {:ok, Map.get(options, text_key)} + "-" -> {:ok, read_stdin()} + path -> File.read(path) + end + end + + defp read_stdin do + case IO.read(:stdio, :eof) do + :eof -> "" + data -> data + end + end +end diff --git a/app/lib/linear_cli/cli/commands/issues/development.ex b/app/lib/linear_cli/cli/commands/issues/development.ex new file mode 100644 index 0000000..0468619 --- /dev/null +++ b/app/lib/linear_cli/cli/commands/issues/development.ex @@ -0,0 +1,138 @@ +defmodule LinearCli.CLI.Commands.Issues.Development do + @moduledoc """ + Issue development commands: develop, PR, and take. + Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/develop.rb, + pr.rb, and take.rb. + """ + + alias LinearCli.CLI.{Display, Prompt} + alias LinearCli.CLI.Issue.{Assignment, PullRequest} + alias LinearCli.Git + + @doc """ + Ported from commands/issue/develop.rb: resolves/self-assigns `issue_id` + (`LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2`), checks out its + `branch_name` (creating it first if it doesn't exist locally yet), then + pulls it (or, if there's no upstream tracking branch yet, pushes it to + `origin` and sets one up). + + `opts` (this port's addition, not part of Ruby's `call(issue_id:, + **options)`) forwards to `LinearCli.Git.checkout_branch/2`/ + `pull_or_push_new_branch!/2` (`:cwd`) and + `LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2` (`:me`) - pass overrides + in tests so this never shells out to real git or hits a real `viewer` query; + real callers omit it. + """ + @spec issue_develop(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} + def issue_develop(result, opts \\ []) + def issue_develop(%{args: %{issue_id: issue_id}}, opts), do: run_develop(issue_id, opts) + + @doc """ + Ported from commands/issue/pr.rb: resolves/self-assigns `issue_id`, checks + out its branch (creating it first if needed - no pull/push here, unlike + `issue_develop/2`), then opens a PR via + `LinearCli.CLI.Issue.PullRequest.issue_pr/2`. + + `opts` (this port's addition): `:cwd` (forwarded to + `LinearCli.Git.checkout_branch/2`), `:me` (forwarded to + `gimme_da_issue!/2`), `:runner` (forwarded to `issue_pr/2`, so this never + shells out to a real `gh` in tests). Real callers omit it. + """ + @spec issue_pr(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} + def issue_pr(result, opts \\ []) + + def issue_pr(%{args: %{issue_id: issue_id}, options: options}, 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}") + + pr_opts = + [title: options.title, description: options.description] + |> maybe_put(:runner, opts[:runner]) + + PullRequest.issue_pr(issue, pr_opts) + end + end + + @doc """ + Ported from commands/issue/take.rb: self-assigns every issue id in + `unknown` (Ruby's `issue_ids:`, a variadic positional argument - Optimus + has no declared-arity equivalent to `type: :array` positional args, so, + like `issue_list/1`'s own `ids`, it's captured via the subcommand's + `allow_unknown_args: true` + the parse result's `unknown` list), skipping + (and warning about) any id that doesn't exist rather than aborting the + whole batch - matching Ruby's `rescue NotFoundError => e ... next` inside + its `filter_map`. + + `opts` (this port's addition) forwards to + `LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2` (`:me`); real callers + omit it. + """ + @spec issue_take(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} + def issue_take(result, opts \\ []) + + def issue_take(%{unknown: issue_ids, options: options}, opts) do + opts = maybe_put_status(opts, Map.get(options, :status)) + + with {:ok, updates} <- take_issues(issue_ids, opts) do + Display.show(updates, %{output: options.output}) + :ok + end + end + + 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}") + finish_pull_or_push(issue.branch_name, opts) + end + end + + # Ported from `SubCommands#pull_or_push_new_branch!`'s own prompt calls + # (`prompt.warn`/`prompt.ok`, printed around the push+set-upstream fallback + # only) plus `Issue::Develop#call`'s trailing `prompt.ok 'Ready to + # develop!'` (printed unconditionally, after either branch). + 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!") + :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!") + :ok + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_put(list, _key, nil), do: list + defp maybe_put(list, key, value), do: Keyword.put(list, key, value) + + defp maybe_put_status(opts, nil), do: opts + defp maybe_put_status(opts, status), do: Keyword.put(opts, :status, status) + + defp take_issues(issue_ids, opts) do + issue_ids + |> Enum.reduce_while({:ok, []}, fn issue_id, {:ok, acc} -> + case Assignment.gimme_da_issue!(issue_id, opts) do + {:ok, issue} -> + {:cont, {:ok, [issue | acc]}} + + {:error, %Ash.Error.Unknown{errors: [%{value: [{:not_found, id}]} | _]}} -> + Prompt.warn("No issue found with id #{id}") + {:cont, {:ok, acc}} + + {:error, reason} -> + {:halt, {:error, reason}} + end + end) + |> case do + {:ok, acc} -> {:ok, Enum.reverse(acc)} + error -> error + end + end +end diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex new file mode 100644 index 0000000..a779080 --- /dev/null +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -0,0 +1,356 @@ +defmodule LinearCli.CLI.Commands.Issues.Mutations do + @moduledoc """ + Issue mutation commands: update, comment, status, and assign. + Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/update.rb, + comment.rb, status.rb, and assign.rb. + """ + + alias LinearCli.CLI.{Display, Prompt, WhatFor} + alias LinearCli.CLI.Issue.{Actions, Identifiers} + alias LinearCli.Linear + + @max_concurrent_issue_updates 20 + + @doc """ + Ported from commands/issue/update.rb: looks up every issue id in `unknown` + (see `issue_take/2`'s doc for why this is a variadic positional captured + via `unknown` rather than a declared Optimus arg) and dispatches + `LinearCli.CLI.Issue.Actions.update_issue/2` against each, per whichever + flags/options were given. + + Ports `raise SmellsBad, 'No issue IDs provided!' if issue_ids.empty?` as + `{:error, {:smells_bad, "No issue IDs provided!"}}` (mapped to exit 22 by + `LinearCli.CLI.handle_error/3`). Ruby's second guard - `raise SmellsBad, + '...' if options[:pr] && issue_ids.size > 1` - has no equivalent here: + the real `update.rb` never actually registers a `--pr` option/flag + (`options[:pr]` can never be truthy there either), so it's dead code in + the original and isn't ported. + """ + @spec issue_update(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_update(%{unknown: issue_ids, options: options, flags: flags}) do + with :ok <- validate_issue_ids(issue_ids), + :ok <- validate_body_file_exclusion(options, :description, "--description"), + {:ok, description} <- resolve_body_from_file(options, :description), + {:ok, issues} <- + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}) do + update_opts = [ + comment: options.comment, + description: description, + project: options.project, + cancel: flags.cancel, + close: flags.close, + reason: options.reason, + status: Map.get(options, :status), + trash: flags.trash + ] + + 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 + end + + @doc """ + Adds a comment to one or more issues (ISSUE_ID...). + + `--comment`/`-m` and `--body-file` are mutually exclusive. `--body-file` + reads the body from a file (`-` for stdin) - the way to supply a large + multi-line body without building it as a single shell argument, which + is what `--comment`, going through + `LinearCli.CLI.WhatFor.comment_for/2`'s prompt/editor resolution, does + not protect against. Without either option, `comment_for/2`'s existing + behavior applies (prompt, or open an editor for `-`). + + When multiple issue IDs are given, the same comment body is posted to + each concurrently. The interactive prompt (when neither `-m` nor + `--body-file` is given) uses the first issue's context. + + Calls `Linear.add_comment/2` directly rather than + `LinearCli.CLI.Issue.Actions.issue_comment/2` so the confirmation can be + suppressed under `--output json` - matching how `print_move_results/3` + suppresses its own confirmation for `issue move --output json`. + + New in this port - Ruby has no equivalent. + """ + @spec issue_comment(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_comment(%{unknown: issue_ids, options: options}) do + with :ok <- validate_issue_ids(issue_ids), + :ok <- validate_body_file_exclusion(options, :comment, "--comment"), + {:ok, comment_text} <- resolve_body_from_file(options, :comment), + {:ok, issues} <- + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), + body = WhatFor.comment_for(hd(issues), comment_text), + {:ok, pairs} <- add_comments_to_issues(issues, body) do + unless options.output == "json" do + Enum.each(pairs, fn {issue, _comment} -> + Prompt.ok("Comment added to #{issue.identifier}") + end) + end + + Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: options.output}) + :ok + end + end + + @doc """ + Changes the workflow state of one or more issues. Optimus captures the IDs in + `unknown`, since it has no variadic positional-argument type. + + With `--status`/`-s`, matches the given name against the issue's team's + workflow states (case-insensitive exact, then unique prefix). Without it, + prompts interactively via `LinearCli.CLI.Prompt.select/2`. + + With `--comment`/`-m`, adds a comment to each issue before transitioning it. + Mutations for separate issues run concurrently with a limit of 20 in flight. + """ + @spec issue_status(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_status(%{unknown: issue_ids, options: options}) do + with :ok <- validate_issue_ids(issue_ids), + {:ok, issues} <- + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), + {:ok, planned_updates} <- plan_status_updates(issues, options.status), + {:ok, completed_updates} <- apply_status_updates(planned_updates, options.comment) do + show_status_updates(completed_updates, options.output) + end + end + + @doc """ + Assigns an issue to a team member. + + With `--assignee`/`-a`, matches the given name against the issue's team's + members (case-insensitive exact, then unique prefix). Without it, prompts + interactively via `LinearCli.CLI.Prompt.select/2`. + """ + @spec issue_assign(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_assign(%{args: %{issue_id: issue_id}, options: options}) do + expanded_id = Identifiers.expand_issue_id(issue_id) + + with {: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, 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: options.output}) + + if options.output != "json" do + msg = "#{updated.identifier} assigned to #{target_member.name}" + + msg = + if updated.state, + do: "#{msg} and set to #{updated.state.name}", + else: msg + + Prompt.ok(msg) + end + + :ok + end + end + + defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}} + defp validate_issue_ids(_issue_ids), do: :ok + + defp validate_body_file_exclusion(options, text_key, flag_name) do + if not is_nil(Map.get(options, :body_file)) and not is_nil(Map.get(options, text_key)) do + {:error, {:smells_bad, "give #{flag_name} or --body-file, not both"}} + else + :ok + end + end + + defp resolve_body_from_file(options, text_key) do + case Map.get(options, :body_file) do + nil -> {:ok, Map.get(options, text_key)} + "-" -> {:ok, read_stdin()} + path -> File.read(path) + end + end + + defp read_stdin do + case IO.read(:stdio, :eof) do + :eof -> "" + data -> data + end + end + + defp add_comments_to_issues(issues, body) do + issues + |> Task.async_stream( + fn issue -> + case Linear.add_comment(issue.identifier, body) do + {:ok, comment} -> {:ok, {issue, comment}} + {:error, reason} -> {:error, reason} + end + end, + max_concurrency: min(length(issues), @max_concurrent_issue_updates), + ordered: true, + timeout: 30_000 + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, pair}}, {:ok, acc} -> {:cont, {:ok, [pair | acc]}} + {:ok, {:error, reason}}, _acc -> {:halt, {:error, reason}} + {:exit, reason}, _acc -> {:halt, {:error, {:task_exit, reason}}} + end) + |> then(fn + {:ok, results} -> {:ok, Enum.reverse(results)} + error -> error + end) + end + + defp one_or_many([one]), do: one + defp one_or_many(many), do: many + + defp plan_status_updates(issues, status) do + issues + |> Enum.reduce_while({:ok, []}, fn issue, {:ok, updates} -> + with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id), + {:ok, target_state} <- resolve_target_state(states, status) do + {:cont, {:ok, [{issue, target_state} | updates]}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> reverse_status_updates() + end + + defp apply_status_updates([], _comment), do: {:ok, []} + + defp apply_status_updates(planned_updates, comment) do + planned_updates + |> Task.async_stream( + fn {issue, target_state} -> + apply_status_update(issue, target_state, comment) + end, + max_concurrency: min(length(planned_updates), @max_concurrent_issue_updates), + ordered: true, + timeout: 30_000 + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, update}}, {:ok, updates} -> + {:cont, {:ok, [update | updates]}} + + {:ok, {:error, reason}}, {:ok, _updates} -> + {:halt, {:error, reason}} + + {:exit, reason}, {:ok, _updates} -> + {:halt, {:error, {:task_exit, reason}}} + end) + |> reverse_status_updates() + end + + defp apply_status_update(issue, target_state, comment) do + with :ok <- maybe_add_status_comment(issue, comment), + {:ok, updated} <- Linear.set_issue_status(issue, target_state.id) do + {:ok, {updated, target_state}} + end + end + + defp reverse_status_updates({:ok, updates}), do: {:ok, Enum.reverse(updates)} + defp reverse_status_updates(error), do: error + + defp show_status_updates(completed_updates, output) do + updated_issues = Enum.map(completed_updates, &elem(&1, 0)) + Display.show(one_or_many(updated_issues), %{output: output}) + + if output != "json" do + Enum.each(completed_updates, fn {updated, target_state} -> + Prompt.ok("#{updated.identifier} status set to #{target_state.name}") + end) + end + + :ok + 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)} + end + + defp resolve_target_state(states, name) do + normalized_name = String.downcase(name) + + states + |> Enum.filter(&(String.downcase(&1.name) == normalized_name)) + |> use_prefix_matches_if_empty(states, normalized_name) + |> resolve_state_matches(states, name) + end + + defp use_prefix_matches_if_empty([], states, name) do + Enum.filter(states, &String.starts_with?(String.downcase(&1.name), name)) + end + + defp use_prefix_matches_if_empty(matches, _states, _name), do: matches + + defp resolve_state_matches([state], _states, _name), do: {:ok, state} + + defp resolve_state_matches([], states, name) do + available = Enum.map_join(states, ", ", & &1.name) + {:error, {:smells_bad, "Unknown status #{inspect(name)}. Available: #{available}"}} + end + + defp resolve_state_matches(matches, _states, name) do + ambiguous = Enum.map_join(matches, ", ", & &1.name) + {: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, comment) do + case Actions.issue_comment(issue, comment) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp resolve_optional_status(_issue, nil), do: {:ok, nil} + + defp resolve_optional_status(issue, name) do + with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id), + {:ok, state} <- resolve_target_state(states, name) do + {:ok, state.id} + end + end + + defp guard_has_members([], issue) do + {:error, + {:smells_bad, "No assignable members found for team #{issue.team.key || issue.team.id}"}} + end + + 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)} + end + + defp resolve_target_member(members, name) do + normalized = String.downcase(name) + + members + |> Enum.filter(&(String.downcase(&1.name) == normalized)) + |> use_prefix_member_matches_if_empty(members, normalized) + |> resolve_member_matches(members, name) + end + + defp use_prefix_member_matches_if_empty([], members, name) do + Enum.filter(members, &String.starts_with?(String.downcase(&1.name), name)) + end + + defp use_prefix_member_matches_if_empty(matches, _members, _name), do: matches + + defp resolve_member_matches([member], _members, _name), do: {:ok, member} + + defp resolve_member_matches([], members, name) do + available = Enum.map_join(Enum.sort_by(members, & &1.name), ", ", & &1.name) + {:error, {:smells_bad, "Unknown assignee #{inspect(name)}. Available: #{available}"}} + end + + defp resolve_member_matches(matches, _members, name) do + ambiguous = Enum.map_join(matches, ", ", & &1.name) + {:error, {:smells_bad, "Ambiguous assignee #{inspect(name)}: matches #{ambiguous}"}} + end +end diff --git a/app/lib/linear_cli/cli/commands/issues/read.ex b/app/lib/linear_cli/cli/commands/issues/read.ex new file mode 100644 index 0000000..b68febe --- /dev/null +++ b/app/lib/linear_cli/cli/commands/issues/read.ex @@ -0,0 +1,107 @@ +defmodule LinearCli.CLI.Commands.Issues.Read do + @moduledoc """ + Issue read commands: list and view. + Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/list.rb and + commands/issue/view.rb. + """ + + alias LinearCli.Browser + alias LinearCli.CLI.{Display, Projects} + alias LinearCli.CLI.Issue.Identifiers + alias LinearCli.{Linear, Profiles} + + @doc """ + Ported from commands/issue/list.rb + operations/issue/list.rb. + + `--project`/`-p` resolution is team-scoped when `--team` is given (or + the active profile supplies a team) - it searches that team's projects via + `projects_by_team`. Without a team context it falls back to all workspace + projects (`Project.all`). Prompts interactively when the search is + ambiguous or omitted-but-requested (`-p -`). Only resolved at all when + `--project` was actually given (or `LinearCli.Profiles.default_project/0` + supplies one) - unlike `issue create`/`issue update`, a bare `issue list` + with no active profile applies no project filter and never prompts. + `--team`/`--project` passed explicitly always win over the active profile. + """ + def issue_list(%{flags: flags, options: options, unknown: ids}) do + no_profile = Map.get(flags, :no_profile, false) + team_key = options.team || unless no_profile, do: Profiles.default_team() + + project_source = + options.project || unless no_profile, do: Profiles.default_project() + + with {:ok, project_id} <- resolve_project_id(project_source, team_key) do + label_filter = Map.get(options, :labels) || [] + include_labels = Map.get(flags, :include_labels, false) || label_filter != [] + + input = %{ + ids: Enum.map(ids, &Identifiers.expand_issue_id/1), + mine: !flags.no_mine, + unassigned: flags.unassigned, + team_key: team_key, + project_id: project_id, + all: Map.get(flags, :all, false), + state: Map.get(options, :state) || [], + status: Map.get(options, :status) || [], + labels: label_filter, + include_labels: include_labels + } + + with {:ok, issues} <- Linear.issues(input) do + Display.show(issues, %{ + output: options.output, + full: flags.full, + labels: include_labels + }) + + :ok + end + end + end + + @doc """ + Shows full details for a single issue - equivalent to `issue list --full ISSUE_ID`. + + Mirrors `gh issue view`: a dedicated verb for the single-issue display case, + making it discoverable without knowing about `list`'s `--full` flag. + + With `-w`/`--web`, opens the issue URL in the default browser instead of + printing it. The `opts` keyword arg accepts an injectable `opener` for tests. + """ + @spec issue_view(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} + def issue_view(result, opts \\ []) + + def issue_view(%{args: %{issue_id: issue_id}, flags: flags, options: options}, opts) do + expanded_id = Identifiers.expand_issue_id(issue_id) + + with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do + if flags.web do + Browser.open_url(issue.url, opts) + else + Display.show(issue, %{output: options.output, full: true}) + :ok + end + end + end + + defp resolve_project_id(nil, _team_key), do: {:ok, nil} + + defp resolve_project_id(search, team_key) when is_binary(team_key) do + with {:ok, team} <- Linear.find_team(team_key), + {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}) do + case Projects.project_for(projects, search) do + nil -> {:ok, nil} + project -> {:ok, project.id} + end + end + end + + defp resolve_project_id(search, _team_key) do + with {:ok, projects} <- Linear.projects() do + case Projects.project_for(projects, search) do + nil -> {:ok, nil} + project -> {:ok, project.id} + end + end + end +end diff --git a/app/mix.exs b/app/mix.exs index 8f2a29d..d74d8ac 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -12,6 +12,7 @@ defmodule LinearCli.MixProject do elixir: "~> 1.20", start_permanent: Mix.env() == :prod, elixirc_options: [warnings_as_errors: Mix.env() == :test], + elixirc_paths: elixirc_paths(Mix.env()), deps: deps(), consolidate_protocols: Mix.env() != :dev, usage_rules: usage_rules(), @@ -19,6 +20,9 @@ defmodule LinearCli.MixProject do ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + # Burrito-wrapped release, both the interactive CLI and (with # LINEAR_CLI_DAEMON=true) the daemon - one binary, not two build # artifacts. Targets and their host-compatibility verified against diff --git a/app/test/linear_cli/cli/commands/issues/create_test.exs b/app/test/linear_cli/cli/commands/issues/create_test.exs new file mode 100644 index 0000000..6d06f3d --- /dev/null +++ b/app/test/linear_cli/cli/commands/issues/create_test.exs @@ -0,0 +1,536 @@ +defmodule LinearCli.CLI.Commands.Issues.CreateTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers + + alias LinearCli.CLI.Commands.Issues.Create + alias LinearCli.Linear.User + + 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([ + {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, + {"issueLabels", label_response(["urgent"])}, + {"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])}, + {"issueCreate", + %{ + "data" => %{ + "issueCreate" => %{ + "issue" => + issue_map(%{ + "id" => "i2", + "identifier" => "CRY-2", + "title" => "New thing", + "branchName" => "cry-2-new-thing", + "description" => "Some description" + }) + } + } + }} + ]) + + output = + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--title", + "New thing", + "--description", + "Some description", + "--team", + "ENG", + "-l", + "urgent", + "--project", + "Manhattan Rollout" + ]) + end) + + assert output =~ "Do you want to take this issue?" + assert output =~ "CRY-2" + assert output =~ "New thing" + end + + test "--dev still checks out and pushes the new issue's branch after declining to take it" do + repo = git_repo!() + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + created_issue = + issue_map(%{ + "id" => "i2", + "identifier" => "CRY-2", + "title" => "New thing", + "branchName" => "cry-2-new-thing", + "description" => "Some description", + "assignee" => me_map() + }) + + stub_responses([ + {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, + {"issueLabels", label_response(["urgent"])}, + {"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])}, + {"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}}, + {"issue(id: $id)", %{"data" => %{"issue" => created_issue}}} + ]) + + result = %{ + options: %{ + title: "New thing", + description: "Some description", + team: "ENG", + labels: ["urgent"], + project: "Manhattan Rollout", + output: "text" + }, + flags: %{develop: true, yes: false} + } + + output = + capture_io([input: "n\n"], fn -> + assert :ok = Create.issue_create(result, cwd: repo, me: me) + end) + + assert output =~ "Checked out branch cry-2-new-thing" + assert output =~ "Upstream branch not found, pushing local cry-2-new-thing to origin" + assert output =~ "Set upstream to origin/cry-2-new-thing" + assert output =~ "Ready to develop!" + end + + test "--body-file reads the description from a file verbatim" do + path = tmp_path("body_file") + # Includes a literal backslash-n and a $VAR-looking string — the same + # content that broke when built as an inline shell argument (EXT-17 incident). + File.write!(path, "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim") + on_exit(fn -> File.rm(path) end) + + 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issueLabels") -> + Req.Test.json(conn, label_response(["docs"])) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:sent_description, decoded["variables"]["input"]["description"]}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"identifier" => "CRY-2", "title" => "T"}) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--body-file", + path, + "--title", + "T", + "--team", + "ENG", + "-l", + "docs" + ]) + end) + + assert_received {:sent_description, + "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim"} + end + + test "--body-file - reads the description from stdin" do + # Uses Create.issue_create directly so that IO.read(:stdio, :eof) only + # consumes the piped content (not the yes/no prompt input too). The + # maybe_take prompt gets EOF after stdin is consumed; Owl.IO.confirm with + # default: true returns true, so gimme_da_issue! runs and finds the issue + # already assigned to `me`, short-circuiting without a second mutation. + test_pid = self() + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issueLabels") -> + Req.Test.json(conn, label_response([])) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:sent_description, decoded["variables"]["input"]["description"]}) + + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"id" => "i2", "identifier" => "CRY-2", "title" => "T"}) + } + } + }) + + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => + issue_map(%{"id" => "i2", "identifier" => "CRY-2", "assignee" => me_map()}) + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{ + options: %{ + title: "T", + body_file: "-", + description: nil, + team: "ENG", + labels: [], + project: nil, + output: "text" + }, + flags: %{develop: false, yes: false} + } + + capture_io("piped from stdin\nwith a real newline", fn -> + assert :ok = Create.issue_create(result, me: me) + end) + + assert_received {:sent_description, "piped from stdin\nwith a real newline"} + end + + test "--description and --body-file together is a smells_bad error, no GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + [ + "issue", + "create", + "--title", + "T", + "--team", + "ENG", + "-d", + "some desc", + "--body-file", + "somefile" + ], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "give --description or --body-file, not both" + end + + test "an unreadable --body-file surfaces an error, no GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + capture_io(:stderr, fn -> + LinearCli.CLI.main( + [ + "issue", + "create", + "--body-file", + "/nonexistent/path/does-not-exist", + "--title", + "T", + "--team", + "ENG" + ], + halt + ) + end) + + assert_received {:halted, _code} + end + + test "--no-take keeps a -y/--yes-created issue unassigned" do + created_issue = + issue_map(%{ + "id" => "i2", + "identifier" => "CRY-2", + "title" => "New thing", + "branchName" => "cry-2-new-thing", + "description" => "Some description", + "assignee" => nil + }) + + stub_responses([ + {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, + {"projects(first: 100", team_projects([])}, + {"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}} + ]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--title", + "New thing", + "--description", + "Some description", + "--team", + "ENG", + "--yes", + "--no-take" + ]) + end) + + refute output =~ "Do you want to take this issue?" + refute output =~ "Assigning issue" + assert output =~ "CRY-2" + end + + test "--no-take cannot be combined with --dev" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + [ + "issue", + "create", + "--title", + "New thing", + "--description", + "Some description", + "--team", + "ENG", + "--yes", + "--no-take", + "--dev" + ], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "--no-take cannot be used with --dev" + end + + test "-y/--yes with all required flags creates and self-assigns without any prompts" do + created_issue = + issue_map(%{ + "id" => "i2", + "identifier" => "CRY-2", + "title" => "New thing", + "branchName" => "cry-2-new-thing", + "description" => "Some description", + "assignee" => me_map() + }) + + stub_responses([ + {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, + {"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])}, + {"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}}, + {"viewer", %{"data" => %{"viewer" => me_map()}}}, + {"issue(id: $id)", %{"data" => %{"issue" => created_issue}}} + ]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "create", + "--title", + "New thing", + "--description", + "Some description", + "--team", + "ENG", + "--project", + "Manhattan Rollout", + "--yes" + ]) + end) + + refute output =~ "Do you want to take this issue?" + assert output =~ "CRY-2" + end + + test "-y without --title is a smells_bad error" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "create", "--description", "Some desc", "--team", "ENG", "--yes"], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "--title is required with --yes" + end + + test "-y without --description (or --body-file) is a smells_bad error" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "create", "--title", "New thing", "--team", "ENG", "--yes"], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "--description is required with --yes" + end + + test "-y without --team (and multiple teams) is a smells_bad error" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + stub_responses([ + {"viewer", + %{ + "data" => %{ + "viewer" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@x.com", + "teams" => %{ + "nodes" => [ + team_map(), + %{"id" => "t2", "key" => "OPS", "name" => "Ops", "description" => nil} + ] + } + } + } + }} + ]) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + [ + "issue", + "create", + "--title", + "New thing", + "--description", + "Some desc", + "--yes" + ], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "--team is required" + end + + test "-y with --project resolves it by exact match and uses it" do + test_pid = self() + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + created_issue = + issue_map(%{ + "id" => "i2", + "identifier" => "CRY-2", + "title" => "T", + "description" => "D", + "assignee" => me_map() + }) + + 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, "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"), + project_map("p2", "Other Project") + ]) + ) + + String.contains?(query, "issueCreate") -> + send(test_pid, {:project_id, decoded["variables"]["input"]["projectId"]}) + Req.Test.json(conn, %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}) + + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => created_issue}}) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + Create.issue_create( + %{ + options: %{ + title: "T", + description: "D", + team: "ENG", + labels: [], + project: "Manhattan Rollout", + output: "text" + }, + flags: %{develop: false, yes: true} + }, + me: me + ) + end) + + assert_received {:project_id, "p1"} + end + end +end diff --git a/app/test/linear_cli/cli/commands/issues/development_test.exs b/app/test/linear_cli/cli/commands/issues/development_test.exs new file mode 100644 index 0000000..0fc99da --- /dev/null +++ b/app/test/linear_cli/cli/commands/issues/development_test.exs @@ -0,0 +1,124 @@ +defmodule LinearCli.CLI.Commands.Issues.DevelopmentTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers + + alias LinearCli.CLI.Commands.Issues.Development + alias LinearCli.Linear.User + + describe "issue develop (Ruby: commands/issue/develop.rb)" do + 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"} + + stub_responses([ + {"issue(id: $id)", + %{"data" => %{"issue" => issue_map(%{"branchName" => "main", "assignee" => me_map()})}}} + ]) + + result = %{args: %{issue_id: "CRY-1"}} + + output = + capture_io(fn -> + assert :ok = Development.issue_develop(result, cwd: repo, me: me) + end) + + assert output =~ "You are already assigned CRY-1" + assert output =~ "Checked out branch main" + assert output =~ "Ready to develop!" + refute output =~ "Upstream branch not found" + end + + test "pushes a new branch and sets its upstream when the branch has no tracking branch yet" do + repo = git_repo!() + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + + stub_responses([ + {"issue(id: $id)", + %{ + "data" => %{ + "issue" => + issue_map(%{"branchName" => "cry-1-fix-the-thing", "assignee" => me_map()}) + } + }} + ]) + + result = %{args: %{issue_id: "CRY-1"}} + + output = + capture_io(fn -> + assert :ok = Development.issue_develop(result, cwd: repo, me: me) + end) + + assert output =~ "Checked out branch cry-1-fix-the-thing" + assert output =~ "Upstream branch not found, pushing local cry-1-fix-the-thing to origin" + assert output =~ "Set upstream to origin/cry-1-fix-the-thing" + assert output =~ "Ready to develop!" + end + end + + describe "issue pr (Ruby: commands/issue/pr.rb)" do + 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"} + + stub_responses([ + {"issue(id: $id)", + %{"data" => %{"issue" => issue_map(%{"branchName" => "main", "assignee" => me_map()})}}} + ]) + + result = %{ + args: %{issue_id: "CRY-1"}, + options: %{title: "fix: CRY-1 - Fix the thing", description: "body"} + } + + output = + capture_io(fn -> + assert :ok = + Development.issue_pr(result, + cwd: repo, + me: me, + runner: fn title, body -> "gh said: #{title} (#{body})" end + ) + end) + + assert output =~ "Checked out branch main" + assert output =~ "gh said: fix: CRY-1 - Fix the thing (body)" + refute output =~ "Ready to develop!" + end + end + + describe "issue take (Ruby: commands/issue/take.rb)" do + test "self-assigns unassigned issues and warns, but doesn't abort, on an unknown id" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + variables = decoded["variables"] || %{} + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => me_map()}}) + + query =~ "issue(id: $id)" and variables["id"] == "CRY-1" -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map(%{"assignee" => nil})}}) + + query =~ "issue(id: $id)" and variables["id"] == "NOPE" -> + Req.Test.json(conn, %{"data" => %{"issue" => nil}}) + + query =~ "issueUpdate" -> + Req.Test.json(conn, issue_updated(%{"assignee" => me_map()})) + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "CRY-1", "nope"]) + end) + + assert output =~ "Assigning issue CRY-1 to ya" + assert output =~ "No issue found with id nope" + assert output =~ "CRY-1" + 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 new file mode 100644 index 0000000..7ced5ef --- /dev/null +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -0,0 +1,1551 @@ +defmodule LinearCli.CLI.Commands.Issues.MutationsTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers + + alias LinearCli.CLI.Commands.Issues.Mutations + + describe "issue status" do + defp state_map(id, name, position, type) do + %{"id" => id, "name" => name, "position" => position, "type" => type, "description" => nil} + end + + defp issue_with_state(state_id, state_name) do + issue_map(%{"state" => %{"id" => state_id, "name" => state_name, "type" => "started"}}) + end + + defp states_response do + workflow_states([ + state_map("s1", "Triage", 0.0, "triage"), + state_map("s2", "In Progress", 1.0, "started"), + state_map("s3", "Done", 2.0, "completed") + ]) + end + + test "--status sets the workflow state by exact name (case-insensitive)" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + body_decoded = Jason.decode!(body) + send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "status", "--status", "done", "CRY-1"]) + end) + + assert_received {:state_id, "s3"} + assert output =~ "CRY-1" + assert output =~ "status set to Done" + end + + test "--status updates multiple issue IDs concurrently and emits a JSON array" do + test_pid = self() + + issue_details = fn + "CRY-1" -> {"i1", "t1", "ENG", "Engineering", "s-eng-done"} + "CRY-2" -> {"i2", "t2", "OPS", "Operations", "s-ops-done"} + end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + variables = decoded["variables"] || %{} + + cond do + String.contains?(query, "issue(id: $id)") -> + identifier = variables["id"] + {id, team_id, team_key, team_name, _state_id} = issue_details.(identifier) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => + issue_map(%{ + "id" => id, + "identifier" => identifier, + "team" => %{"id" => team_id, "key" => team_key, "name" => team_name} + }) + } + }) + + String.contains?(query, "states {") -> + team_id = variables["teamId"] + state_id = if team_id == "t1", do: "s-eng-done", else: "s-ops-done" + send(test_pid, {:states_queried, team_id}) + Req.Test.json(conn, workflow_states([state_map(state_id, "Done", 1.0, "completed")])) + + String.contains?(query, "issueUpdate") -> + identifier = variables["id"] + state_id = variables["input"]["stateId"] + {id, team_id, team_key, team_name, ^state_id} = issue_details.(identifier) + update_pid = self() + send(test_pid, {:status_update_started, identifier, state_id, update_pid}) + + receive do + :finish_status_update -> :ok + after + 2_000 -> raise "status update was not released by the concurrency assertion" + end + + Req.Test.json(conn, %{ + "data" => %{ + "issueUpdate" => %{ + "issue" => + issue_map(%{ + "id" => id, + "identifier" => identifier, + "team" => %{"id" => team_id, "key" => team_key, "name" => team_name}, + "state" => %{"id" => state_id, "name" => "Done", "type" => "completed"} + }) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + command = + Task.async(fn -> + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "status", + "--status", + "Done", + "--output", + "json", + "CRY-1", + "CRY-2" + ]) + end) + end) + + assert_receive {:status_update_started, "CRY-1", "s-eng-done", first_update}, 1_000 + assert_receive {:status_update_started, "CRY-2", "s-ops-done", second_update}, 1_000 + send(first_update, :finish_status_update) + send(second_update, :finish_status_update) + + output = Task.await(command) + + assert_received {:states_queried, "t1"} + assert_received {:states_queried, "t2"} + + assert {:ok, decoded} = Jason.decode(output) + assert Enum.map(decoded, & &1["identifier"]) == ["CRY-1", "CRY-2"] + end + + test "variadic issue IDs do not swallow unrecognized options" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "status", "--statuz", "Done", "CRY-1", "CRY-2"], + halt + ) + end) + + assert_received {:halted, 22} + assert stderr =~ "unrecognized option(s): --statuz" + end + + test "-s short flag also sets the workflow state" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + body_decoded = Jason.decode!(body) + send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "status", "-s", "Done", "CRY-1"]) + end) + + assert_received {:state_id, "s3"} + assert output =~ "status set to Done" + end + + test "--status with prefix match selects unique match" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + body_decoded = Jason.decode!(body) + send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s2", "In Progress")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "status", "--status", "in", "CRY-1"]) + end) + + assert_received {:state_id, "s2"} + assert output =~ "status set to In Progress" + end + + test "--status with unknown name exits 22 (smells bad)" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "states {") -> + Req.Test.json(conn, states_response()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "status", "--status", "Nonexistent", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "Unknown status" + assert stderr =~ "This smells bad! Bailing." + end + + test "--status with ambiguous prefix exits 22 (smells bad)" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "states {") -> + # Two states starting with "D" to trigger ambiguity + Req.Test.json( + conn, + workflow_states([ + state_map("s1", "Done", 1.0, "completed"), + state_map("s2", "Doing", 2.0, "started") + ]) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "status", "--status", "Do", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "Ambiguous status" + assert stderr =~ "This smells bad! Bailing." + end + + test "--comment adds a comment before changing the status" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "commentCreate") -> + send(test_pid, :comment_created) + Req.Test.json(conn, comment_created()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "status", + "--status", + "Done", + "--comment", + "Wrapping up", + "CRY-1" + ]) + end) + + assert_received :comment_created + assert output =~ "Comment added to CRY-1" + assert output =~ "status set to Done" + end + + test "interactive selection (no --status) prompts from sorted states" 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + # Select the third option ("Done") interactively via stdin + output = + capture_io([input: "3\n"], fn -> + assert :ok = LinearCli.CLI.main(["issue", "status", "CRY-1"]) + end) + + assert output =~ "Choose a status" + assert output =~ "status set to Done" + end + + test "--output json emits structured output" 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "status", + "--status", + "Done", + "--output", + "json", + "CRY-1" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + end + + test "alias 's' routes to issue status" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :updated) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "s", "--status", "Done", "CRY-1"]) + end) + + assert_received :updated + end + + test "alias 'st' routes to issue status" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :updated) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "st", "--status", "Done", "CRY-1"]) + end) + + assert_received :updated + end + + test "alias 'stat' routes to issue status" do + test_pid = self() + + 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, "states {") -> + Req.Test.json(conn, states_response()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :updated) + + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "stat", "--status", "Done", "CRY-1"]) + end) + + assert_received :updated + end + end + + describe "issue update (Ruby: commands/issue/update.rb)" do + test "--close --status selects a completed state without prompting" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "commentCreate") -> + Req.Test.json(conn, comment_created()) + + String.contains?(query, "states {") -> + Req.Test.json( + conn, + workflow_states([ + %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"}, + %{ + "id" => "s2", + "name" => "Shipped", + "position" => 2.0, + "type" => "completed" + } + ]) + ) + + String.contains?(query, "issueUpdate") -> + assert decoded["variables"]["input"] == %{"stateId" => "s2"} + send(test_pid, :closed_as_shipped) + Req.Test.json(conn, issue_updated()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "--close", + "--status", + "ship", + "--reason", + "Done", + "CRY-1" + ]) + end) + + assert output =~ "Comment added to CRY-1" + assert output =~ "CRY-1 was closed" + refute output =~ "Choose a completed state" + assert_received :closed_as_shipped + end + + test "--description updates the issue description via the issueUpdate mutation" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:description, decoded["variables"]["input"]["description"]}) + Req.Test.json(conn, issue_updated(%{"description" => "Updated body"})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "--description", + "Updated body", + "CRY-1" + ]) + end) + + assert_received {:description, "Updated body"} + assert output =~ "CRY-1 description updated" + end + + test "-d short flag also updates the issue description" do + stub_responses([ + {"issue(id: $id)", %{"data" => %{"issue" => issue_map()}}}, + {"issueUpdate", issue_updated(%{"description" => "Short flag body"})} + ]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "-d", + "Short flag body", + "CRY-1" + ]) + end) + + assert output =~ "CRY-1 description updated" + end + + test "with no issue ids, exits 22 (Ruby: raise SmellsBad -> exit 22)" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "update"], halt) + end) + + assert_received {:halted, 22} + assert output =~ "No issue IDs provided!" + assert output =~ "This smells bad! Bailing." + end + + test "--body-file reads the description from a file verbatim" do + path = tmp_path("body_file") + File.write!(path, "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim") + on_exit(fn -> File.rm(path) end) + + 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:description, decoded["variables"]["input"]["description"]}) + + Req.Test.json( + conn, + issue_updated(%{ + "description" => "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim" + }) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "update", + "--body-file", + path, + "CRY-1" + ]) + end) + + assert_received {:description, "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim"} + assert output =~ "CRY-1 description updated" + end + + test "--body-file - reads the description from stdin" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:description, decoded["variables"]["input"]["description"]}) + Req.Test.json(conn, issue_updated(%{"description" => "from stdin body"})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{ + unknown: ["CRY-1"], + options: %{ + body_file: "-", + description: nil, + comment: nil, + project: nil, + reason: nil, + status: nil + }, + flags: %{cancel: false, close: false, trash: false} + } + + capture_io("from stdin body", fn -> + assert :ok = Mutations.issue_update(result) + end) + + assert_received {:description, "from stdin body"} + end + + test "--description and --body-file together is a smells_bad error, no GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + [ + "issue", + "update", + "-d", + "some desc", + "--body-file", + "somefile", + "CRY-1" + ], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "give --description or --body-file, not both" + end + + test "an unreadable --body-file surfaces an error, no GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + capture_io(:stderr, fn -> + LinearCli.CLI.main( + [ + "issue", + "update", + "--body-file", + "/nonexistent/path/does-not-exist", + "CRY-1" + ], + halt + ) + end) + + assert_received {:halted, _code} + end + end + + describe "issue assign" do + defp member_map(id, name, email \\ nil) do + %{"id" => id, "name" => name, "email" => email || "#{id}@example.com"} + end + + defp members_response(members) do + %{"data" => %{"team" => %{"members" => %{"nodes" => members}}}} + end + + defp issue_assigned(assignee_map) do + %{"data" => %{"issueUpdate" => %{"issue" => issue_map(%{"assignee" => assignee_map})}}} + end + + test "--assignee sets the assignee by exact name (case-insensitive)" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + decoded = 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"), member_map("u3", "Alice")]) + ) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:assignee_id, decoded["variables"]["input"]["assigneeId"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "assign", "--assignee", "bob", "CRY-1"]) + end) + + assert_received {:assignee_id, "u2"} + assert output =~ "assigned to Bob" + end + + test "--assignee prefix match selects unique match" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + decoded = 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"), member_map("u3", "Alice")]) + ) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:assignee_id, decoded["variables"]["input"]["assigneeId"]}) + Req.Test.json(conn, issue_assigned(member_map("u3", "Alice"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "assign", "--assignee", "Ali", "CRY-1"]) + end) + + assert_received {:assignee_id, "u3"} + assert output =~ "assigned to Alice" + end + + test "--assignee with unknown name exits 22 (smells bad)" 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, "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"), member_map("u3", "Alice")]) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "assign", "--assignee", "Nobody", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "Unknown assignee" + assert stderr =~ "This smells bad! Bailing." + end + + test "--assignee with ambiguous prefix exits 22 (smells bad)" 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, "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"), member_map("u3", "Bobby")]) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "assign", "--assignee", "Bo", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "Ambiguous assignee" + assert stderr =~ "This smells bad! Bailing." + end + + test "interactive selection (no --assignee) prompts from sorted members" 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"), member_map("u3", "Alice")]) + ) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, issue_assigned(member_map("u3", "Alice"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + # Members are sorted by name: Alice (1), Bob (2) — select "1\n" for Alice + output = + capture_io([input: "1\n"], fn -> + assert :ok = LinearCli.CLI.main(["issue", "assign", "CRY-1"]) + end) + + assert output =~ "Choose an assignee" + assert output =~ "assigned to Alice" + end + + test "--output json emits structured output" 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") -> + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "assign", + "--assignee", + "Bob", + "--output", + "json", + "CRY-1" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + end + + test "alias 'a' routes to issue assign" do + test_pid = self() + + 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") -> + send(test_pid, :assigned) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "a", "--assignee", "Bob", "CRY-1"]) + end) + + assert_received :assigned + end + + test "no assignable members exits 22 (smells bad)" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([])) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "assign", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "No assignable members" + assert stderr =~ "This smells bad! Bailing." + end + + test "--status sends assigneeId and stateId in one issueUpdate" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + 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, "states {") -> + Req.Test.json( + conn, + workflow_states([ + state_map("s2", "In Progress", 1.0, "started") + ]) + ) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + + Req.Test.json( + conn, + issue_assigned(member_map("u2", "Bob")) + |> put_in( + ["data", "issueUpdate", "issue", "state"], + %{"id" => "s2", "name" => "In Progress", "type" => "started"} + ) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "assign", + "--assignee", + "Bob", + "--status", + "In Progress", + "CRY-1" + ]) + end) + + assert_received {:input, input} + assert input["assigneeId"] == "u2" + assert input["stateId"] == "s2" + assert output =~ "assigned to Bob" + assert output =~ "In Progress" + end + + test "--status short form -s also works on assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + 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, "states {") -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "-s", "Todo", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s1" + end + + test "--status with case-insensitive name match on assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + 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, "states {") -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "--status", "todo", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s1" + end + + test "--status unknown name exits 22 before sending any mutation on assign" 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, "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, "states {") -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :mutated) + raise "issueUpdate should not be called when status is invalid" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "assign", "-a", "Bob", "--status", "NoSuchState", "CRY-1"], + halt + ) + end) + + assert_received {:halted, 22} + refute_received :mutated + assert stderr =~ "Unknown status" + end + + test "omitting --status sends only assigneeId (backward compat) on assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + 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") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "CRY-1"]) + end) + + assert_received {:input, input} + assert input == %{"assigneeId" => "u2"} + refute Map.has_key?(input, "stateId") + end + + test "--output json with --status returns structured output on assign" 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, "states {") -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + String.contains?(query, "issueUpdate") -> + Req.Test.json( + conn, + issue_assigned(member_map("u2", "Bob")) + |> put_in( + ["data", "issueUpdate", "issue", "state"], + %{"id" => "s1", "name" => "Todo", "type" => "unstarted"} + ) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "assign", + "-a", + "Bob", + "--status", + "Todo", + "--output", + "json", + "CRY-1" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + assert decoded["state"]["name"] == "Todo" + end + + test "--status with space in name works on assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + 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, "states {") -> + Req.Test.json( + conn, + workflow_states([state_map("s-ip", "In Progress", 1.0, "started")]) + ) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "assign", + "-a", + "Bob", + "--status", + "In Progress", + "CRY-53" + ]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s-ip" + end + end + + describe "issue comment" do + defp stub_lookup_and(pairs) do + 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + match = Enum.find(pairs, fn {substr, _resp} -> String.contains?(query, substr) end) -> + {_substr, resp} = match + Req.Test.json(conn, (is_function(resp, 1) && resp.(decoded)) || resp) + + true -> + raise "no stub matched query: #{query}" + end + end) + end + + test "creates a new comment" do + stub_lookup_and([{"commentCreate", comment_created()}]) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "-m", "lgtm"]) + end) + + assert output =~ "Comment added to CRY-1" + end + + test "--body-file reads the body from a file verbatim" do + path = tmp_path("body_file") + # Deliberately includes a literal backslash-n and a $VAR-looking string - + # exactly the content that broke when built as an inline shell argument + # (see documents/phase-13-plan.adoc's Goal section). + File.write!(path, "## Investigation\n\nliteral \\n and $SOME_VAR survive verbatim") + on_exit(fn -> File.rm(path) end) + + test_pid = self() + + stub_lookup_and([ + {"commentCreate", + fn decoded -> + send(test_pid, {:sent_body, decoded["variables"]["body"]}) + comment_created() + end} + ]) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "--body-file", path]) + end) + + assert_received {:sent_body, + "## Investigation\n\nliteral \\n and $SOME_VAR survive verbatim"} + end + + test "--body-file - reads the body from stdin verbatim" do + test_pid = self() + + stub_lookup_and([ + {"commentCreate", + fn decoded -> + send(test_pid, {:sent_body, decoded["variables"]["body"]}) + comment_created() + end} + ]) + + capture_io("piped from stdin\nwith a real newline", fn -> + assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "--body-file", "-"]) + end) + + assert_received {:sent_body, "piped from stdin\nwith a real newline"} + end + + test "--comment and --body-file together is a smells_bad error, no GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "comment", "CRY-1", "-m", "text", "--body-file", "somefile"], + halt + ) + end) + + assert_received {:halted, 22} + assert output =~ "give --comment or --body-file, not both" + end + + test "an unreadable --body-file surfaces an error, no GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "comment", "CRY-1", "--body-file", "/nonexistent/path/does-not-exist"], + halt + ) + end) + + assert_received {:halted, _code} + end + + test "--output json prints the resulting comment as JSON" do + stub_lookup_and([{"commentCreate", comment_created()}]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "comment", "CRY-1", "-m", "lgtm", "-o", "json"]) + end) + + assert %{"id" => "c1"} = Jason.decode!(output) + end + + test "multiple ISSUE_IDs each receive the comment" do + test_pid = self() + + stub_lookup_and([ + {"commentCreate", + fn _decoded -> + send(test_pid, :comment_created) + comment_created() + end} + ]) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "comment", "CRY-1", "CRY-2", "-m", "lgtm"]) + end) + + assert output =~ "Comment added to" + assert_received :comment_created + assert_received :comment_created + end + + test "no ISSUE_IDs is a smells_bad error" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "comment", "-m", "lgtm"], halt) + end) + + assert_received {:halted, 22} + assert output =~ "No issue IDs provided!" + end + end +end diff --git a/app/test/linear_cli/cli/commands/issues/read_test.exs b/app/test/linear_cli/cli/commands/issues/read_test.exs new file mode 100644 index 0000000..318bf4d --- /dev/null +++ b/app/test/linear_cli/cli/commands/issues/read_test.exs @@ -0,0 +1,984 @@ +defmodule LinearCli.CLI.Commands.Issues.ReadTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers + + alias LinearCli.CLI.Commands.Issues.Read + + 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 "--project with --team resolves against team-scoped projects only" 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") -> + raise "--project with --team must not query all-workspace projects" + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100, filter: $filter)") -> + filters = decoded["variables"]["filter"]["or"] + + assert %{"name" => %{"containsIgnoreCase" => "Wallet Service Extraction"}} in filters + + Req.Test.json( + conn, + team_projects([ + project_map("p2", "Wallet Service Extraction for Humans"), + project_map("p1", "Wallet Service Extraction") + ]) + ) + + 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", + "--team", + "ENG", + "--project", + "Wallet Service Extraction" + ]) + 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 + + test "-N aliases --no-mine" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "-N"]) + end) + + assert_received {:filter, filter} + refute Map.has_key?(filter, "assignee") + end + + test "--all removes completedAt and canceledAt null-check filters" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--all"]) + end) + + assert_received {:filter, filter} + refute Map.has_key?(filter, "completedAt") + refute Map.has_key?(filter, "canceledAt") + end + + test "--state filters by workflow state type and removes corresponding date filters" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "started"]) + end) + + assert_received {:filter, filter} + assert filter["state"] == %{"type" => %{"in" => ["started"]}} + # "started" is not completed/cancelled so both date filters remain + assert Map.has_key?(filter, "completedAt") + assert Map.has_key?(filter, "canceledAt") + end + + test "--state completed removes completedAt filter but keeps canceledAt filter" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "completed"]) + end) + + assert_received {:filter, filter} + assert filter["state"] == %{"type" => %{"in" => ["completed"]}} + refute Map.has_key?(filter, "completedAt") + assert Map.has_key?(filter, "canceledAt") + end + + test "--state accepts multiple comma-separated types" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "started,completed"]) + end) + + assert_received {:filter, filter} + assert filter["state"] == %{"type" => %{"in" => ["started", "completed"]}} + refute Map.has_key?(filter, "completedAt") + assert Map.has_key?(filter, "canceledAt") + end + + test "--status filters by friendly workflow status name" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "Human Review"]) + end) + + assert_received {:filter, filter} + assert filter["state"] == %{"name" => %{"eqIgnoreCase" => "Human Review"}} + refute Map.has_key?(filter, "completedAt") + refute Map.has_key?(filter, "canceledAt") + end + + test "--state and comma-separated --status values combine as type AND friendly name" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "list", + "--state", + "started", + "--status", + "Human Review, Gate Approved" + ]) + end) + + assert_received {:filter, filter} + + assert filter["state"] == %{ + "type" => %{"in" => ["started"]}, + "or" => [ + %{"name" => %{"eqIgnoreCase" => "Human Review"}}, + %{"name" => %{"eqIgnoreCase" => "Gate Approved"}} + ] + } + + assert Map.has_key?(filter, "completedAt") + assert Map.has_key?(filter, "canceledAt") + end + + test "--no-profile bypasses active profile defaults via the full CLI dispatch path" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + if String.contains?(query, "projects(") do + raise "--no-profile must not query projects when --project wasn't given" + end + + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--no-profile"]) + end) + + assert output =~ "CRY-1" + assert_received {:filter, filter} + refute Map.has_key?(filter, "team") + refute Map.has_key?(filter, "project") + end + + test "--state with an unknown type exits 1 (Optimus parse error)" do + # The production halt function never returns. Throw from the test double + # too, so the parser's error path stops before it reaches the normal CLI + # dispatch and emits an unrelated exception diagnostic. + output = + capture_io(fn -> + assert catch_throw( + LinearCli.CLI.main( + ["issue", "list", "--state", "badtype"], + fn code -> throw({:halted, code}) end + ) + ) == {:halted, 1} + end) + + assert output =~ "invalid value \"badtype\" for --state option" + end + + test "--labels filters by a single label name (case-insensitive)" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Incident-followup"]) + end) + + assert_received {:filter, filter} + + assert filter["labels"] == %{ + "some" => %{"name" => %{"eqIgnoreCase" => "Incident-followup"}} + } + end + + test "--labels accepts comma-separated names and matches issues with any of them (OR)" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug,Feature"]) + end) + + assert_received {:filter, filter} + + assert filter["labels"] == %{ + "some" => %{ + "or" => [ + %{"name" => %{"eqIgnoreCase" => "Bug"}}, + %{"name" => %{"eqIgnoreCase" => "Feature"}} + ] + } + } + end + + test "--labels with an unknown label name returns empty result, not a crash" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "no-such-label"]) + end) + + assert output == "" or is_binary(output) + end + + test "--labels composes with --team" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + + if String.contains?(decoded["query"] || "", "teams(") do + Req.Test.json(conn, %{ + "data" => %{ + "teams" => %{ + "edges" => [ + %{ + "node" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "cursor" => "c1" + } + ], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => "c1"} + } + } + }) + else + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--team", "ENG", "--labels", "Bug"]) + end) + + assert_received {:filter, filter} + assert Map.has_key?(filter, "team") + assert filter["labels"] == %{"some" => %{"name" => %{"eqIgnoreCase" => "Bug"}}} + end + + test "compact listing includes workflow state name in brackets" 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(%{"state" => %{"id" => "s2", "name" => "In Review", "type" => "started"}}) + ]) + ) + end) + + output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) + assert output =~ "[In Review]" + assert output =~ "Fix the thing" + end + + test "compact listing omits state bracket when state is nil" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => _} = Jason.decode!(body) + Req.Test.json(conn, issues_response([issue_map(%{"state" => nil})])) + end) + + output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) + assert output =~ "CRY-1" + refute output =~ "[" + end + + test "--full listing includes workflow state name in header" do + 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(%{"state" => %{"id" => "s3", "name" => "Done", "type" => "completed"}}) + } + }) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--full", "CRY-1"]) + end) + + assert output =~ "[Done]" + assert output =~ "Fix the thing" + end + + test "-l shows label names in compact output" do + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "-l", "Bug"]) + end) + + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "--labels shows label names in compact output" do + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug"]) + end) + + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "--labels with multiple labels shows all label names in compact output" do + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false}, + %{"id" => "l2", "name" => "Feature", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug,Feature"]) + end) + + assert output =~ "CRY-1" + assert output =~ "[Bug, Feature]" + end + + test "compact listing without --labels does not show label brackets" do + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) + + assert output =~ "CRY-1" + refute output =~ "[Bug]" + end + + test "--include-labels requests label fields and shows them in compact output" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + send(test_pid, {:query, query}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--include-labels"]) + end) + + assert_received {:query, query} + assert String.contains?(query, "labels") + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "-i short flag requests label fields and shows them in compact output" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + send(test_pid, {:query, query}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "-i"]) + end) + + assert_received {:query, query} + assert String.contains?(query, "labels") + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "-N --include-labels --all sends no assignee/date filter, no label filter, requests label fields" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Feature", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + send(test_pid, {:query, decoded["query"]}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "list", + "-N", + "--include-labels", + "--all" + ]) + end) + + assert_received {:filter, filter} + assert_received {:query, query} + refute Map.has_key?(filter, "assignee") + refute Map.has_key?(filter, "completedAt") + refute Map.has_key?(filter, "canceledAt") + refute Map.has_key?(filter, "labels") + assert String.contains?(query, "labels") + assert output =~ "CRY-1" + assert output =~ "[Feature]" + end + + test "-N -i --all is the same as -N --include-labels --all" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Feature", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "-N", "-i", "--all"]) + end) + + assert_received {:filter, filter} + refute Map.has_key?(filter, "assignee") + refute Map.has_key?(filter, "labels") + assert output =~ "[Feature]" + end + + test "--labels --all exits 1 (Optimus parse error, no API request)" do + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(fn -> + assert catch_throw( + LinearCli.CLI.main( + ["issue", "list", "--labels", "--all"], + fn code -> throw({:halted, code}) end + ) + ) == {:halted, 1} + end) + + assert output =~ "--labels" + end + + test "-s/--status composes with --include-labels" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + send(test_pid, {:query, decoded["query"]}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "list", + "--status", + "Human Review", + "--include-labels" + ]) + end) + + assert_received {:filter, filter} + assert_received {:query, query} + assert filter["state"] == %{"name" => %{"eqIgnoreCase" => "Human Review"}} + assert String.contains?(query, "labels") + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "positional issue identifier with --include-labels uses full lookup and renders labels" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + send(test_pid, {:query, query}) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{ + "id" => "l1", + "name" => "Bug", + "description" => nil, + "isGroup" => false + } + ] + }, + "comments" => %{"nodes" => []}, + "relations" => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + }, + "inverseRelations" => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + }) + } + }) + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "list", "CRY-1", "--include-labels"]) + end) + + assert_received {:query, query} + assert String.contains?(query, "issue(id: $id)") + assert output =~ "CRY-1" + assert output =~ "Bug" + end + + test "--output json --include-labels contains fetched label objects in stdout" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + send(test_pid, {:query, query}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "list", + "--output", + "json", + "--include-labels" + ]) + end) + + assert_received {:query, query} + assert String.contains?(query, "labels") + assert {:ok, [decoded]} = Jason.decode(output) + assert [label] = decoded["labels"] + assert label["name"] == "Bug" + end + end + + describe "issue view" do + test "prints full issue details (header, description, state)" do + 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(%{ + "state" => %{"id" => "s1", "name" => "In Progress", "type" => "started"} + }) + } + }) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "view", "CRY-1"]) + end) + + assert output =~ "CRY-1" + assert output =~ "Fix the thing" + assert output =~ "[In Progress]" + end + + test "outputs JSON when --output json is given" do + 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()}}) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "view", "CRY-1", "--output", "json"]) + end) + + decoded = Jason.decode!(output) + assert decoded["identifier"] == "CRY-1" + assert decoded["title"] == "Fix the thing" + end + + test "lc i v ISSUE_ID alias routes to issue view" do + 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()}}) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["i", "v", "CRY-1"]) + end) + + assert output =~ "CRY-1" + assert output =~ "Fix the thing" + end + + test "--web opens the issue URL in the browser and prints nothing" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => _} = Jason.decode!(body) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) + } + }) + end) + + output = + capture_io(fn -> + assert :ok = + Read.issue_view( + %{ + args: %{issue_id: "CRY-1"}, + flags: %{web: true}, + options: %{output: "text"} + }, + opener: fn url -> + send(test_pid, {:opened, url}) + :ok + end + ) + end) + + assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} + assert output == "" + end + + test "-w short flag opens the browser via the full CLI dispatch path" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => _} = Jason.decode!(body) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) + } + }) + end) + + capture_io(fn -> + assert :ok = + Read.issue_view( + %{ + args: %{issue_id: "CRY-1"}, + flags: %{web: true}, + options: %{output: "text"} + }, + opener: fn url -> + send(test_pid, {:opened, url}) + :ok + end + ) + end) + + assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} + end + + test "--web with --output json opens browser and prints nothing" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => _} = Jason.decode!(body) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) + } + }) + end) + + output = + capture_io(fn -> + assert :ok = + Read.issue_view( + %{ + args: %{issue_id: "CRY-1"}, + flags: %{web: true}, + options: %{output: "json"} + }, + opener: fn url -> + send(test_pid, {:opened, url}) + :ok + end + ) + end) + + assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} + assert output == "" + end + end +end diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 90cbcf7..be3a649 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -1,3487 +1,9 @@ defmodule LinearCli.CLI.IssueCommandsTest do use ExUnit.Case, async: true import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers alias LinearCli.CLI.Commands - alias LinearCli.Linear.User - - # Dispatches to one of `pairs` ({substring, response_map}) based on which - # substring appears in the outgoing GraphQL document - see - # `LinearCli.CLI.Issue.ActionsTest`'s own `stub_responses/1` for why one - # stub per test is enough to drive an entire multi-call flow. - defp stub_responses(pairs) do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - - case Enum.find(pairs, fn {match, _resp} -> String.contains?(query, match) end) do - {_match, response} -> Req.Test.json(conn, response) - nil -> raise "no stub matched query: #{query}" - end - end) - end - - defp team_map, do: %{"id" => "t1", "key" => "ENG", "name" => "Engineering"} - - defp me_map(overrides \\ %{}) do - Map.merge( - %{"id" => "u1", "name" => "Ada", "email" => "ada@x.com", "teams" => %{"nodes" => []}}, - overrides - ) - end - - defp label_response(names) do - %{ - "data" => %{ - "issueLabels" => %{ - "edges" => - Enum.map(names, fn name -> - %{ - "node" => %{ - "id" => "l-#{name}", - "name" => name, - "description" => nil, - "isGroup" => false - } - } - end) - } - } - } - end - - defp project_map(id, name) do - %{ - "id" => id, - "name" => name, - "content" => nil, - "slugId" => "abc", - "description" => nil, - "url" => "https://linear.app/x/project/#{id}" - } - end - - defp team_projects(projects), - do: %{"data" => %{"team" => %{"projects" => %{"nodes" => projects}}}} - - defp issue_map(overrides \\ %{}) do - Map.merge( - %{ - "id" => "i1", - "identifier" => "CRY-1", - "title" => "Fix the thing", - "branchName" => "cry-1-fix-the-thing", - "description" => "It is broken", - "assignee" => nil, - "state" => %{"id" => "s1", "name" => "In Progress", "type" => "started"}, - "team" => team_map(), - "comments" => %{"nodes" => []} - }, - overrides - ) - end - - defp issue_updated(overrides \\ %{}) do - %{"data" => %{"issueUpdate" => %{"issue" => issue_map(overrides)}}} - end - - defp comment_created do - %{ - "data" => %{"commentCreate" => %{"comment" => %{"id" => "c1", "body" => "x", "url" => "u"}}} - } - end - - defp workflow_states(states) do - %{"data" => %{"team" => %{"states" => %{"nodes" => states}}}} - end - - # Every git-touching test gets a fresh local repo (one commit on "main", - # already pushed to/tracking a fresh bare "origin") under - # `System.tmp_dir!()` - never the real project working directory. See house - # rule 6 and `LinearCli.GitTest`'s own identical setup. - defp git_repo! do - origin_path = tmp_dir!("origin") - {_output, 0} = System.cmd("git", ["init", "--bare", "-q"], cd: origin_path) - - repo_path = tmp_dir!("repo") - {_output, 0} = System.cmd("git", ["init", "-q"], cd: repo_path) - {_output, 0} = System.cmd("git", ["config", "user.name", "Test User"], cd: repo_path) - {_output, 0} = System.cmd("git", ["config", "user.email", "test@example.com"], cd: repo_path) - File.write!(Path.join(repo_path, "README.md"), "hello") - {_output, 0} = System.cmd("git", ["add", "README.md"], cd: repo_path) - {_output, 0} = System.cmd("git", ["commit", "-q", "-m", "init"], cd: repo_path) - {_output, 0} = System.cmd("git", ["branch", "-M", "main"], cd: repo_path) - {_output, 0} = System.cmd("git", ["remote", "add", "origin", origin_path], cd: repo_path) - {_output, 0} = System.cmd("git", ["push", "-q", "-u", "origin", "main"], cd: repo_path) - - repo_path - end - - # `System.unique_integer/1` resets across BEAM VM restarts, so an interrupted - # prior run can reuse a stale /tmp directory. A cryptographic nonce avoids - # collisions across processes; `on_exit` is registered before any git command - # so a setup failure still cleans up. - defp tmp_dir!(prefix) do - nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) - path = Path.join(System.tmp_dir!(), "linear_cli_issue_commands_test_#{prefix}_#{nonce}") - File.mkdir!(path) - on_exit(fn -> File.rm_rf!(path) end) - path - end - - defp tmp_path(prefix) do - Path.join( - System.tmp_dir!(), - "linear_cli_issue_commands_test_#{prefix}_#{System.unique_integer([:positive, :monotonic])}" - ) - 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 "--project with --team resolves against team-scoped projects only" 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") -> - raise "--project with --team must not query all-workspace projects" - - String.contains?(query, "team(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) - - String.contains?(query, "projects(first: 100, filter: $filter)") -> - filters = decoded["variables"]["filter"]["or"] - - assert %{"name" => %{"containsIgnoreCase" => "Wallet Service Extraction"}} in filters - - Req.Test.json( - conn, - team_projects([ - project_map("p2", "Wallet Service Extraction for Humans"), - project_map("p1", "Wallet Service Extraction") - ]) - ) - - 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", - "--team", - "ENG", - "--project", - "Wallet Service Extraction" - ]) - 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 - - test "-N aliases --no-mine" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "-N"]) - end) - - assert_received {:filter, filter} - refute Map.has_key?(filter, "assignee") - end - - test "--all removes completedAt and canceledAt null-check filters" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--all"]) - end) - - assert_received {:filter, filter} - refute Map.has_key?(filter, "completedAt") - refute Map.has_key?(filter, "canceledAt") - end - - test "--state filters by workflow state type and removes corresponding date filters" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "started"]) - end) - - assert_received {:filter, filter} - assert filter["state"] == %{"type" => %{"in" => ["started"]}} - # "started" is not completed/cancelled so both date filters remain - assert Map.has_key?(filter, "completedAt") - assert Map.has_key?(filter, "canceledAt") - end - - test "--state completed removes completedAt filter but keeps canceledAt filter" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "completed"]) - end) - - assert_received {:filter, filter} - assert filter["state"] == %{"type" => %{"in" => ["completed"]}} - refute Map.has_key?(filter, "completedAt") - assert Map.has_key?(filter, "canceledAt") - end - - test "--state accepts multiple comma-separated types" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "started,completed"]) - end) - - assert_received {:filter, filter} - assert filter["state"] == %{"type" => %{"in" => ["started", "completed"]}} - refute Map.has_key?(filter, "completedAt") - assert Map.has_key?(filter, "canceledAt") - end - - test "--status filters by friendly workflow status name" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "Human Review"]) - end) - - assert_received {:filter, filter} - assert filter["state"] == %{"name" => %{"eqIgnoreCase" => "Human Review"}} - refute Map.has_key?(filter, "completedAt") - refute Map.has_key?(filter, "canceledAt") - end - - test "--state and comma-separated --status values combine as type AND friendly name" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "list", - "--state", - "started", - "--status", - "Human Review, Gate Approved" - ]) - end) - - assert_received {:filter, filter} - - assert filter["state"] == %{ - "type" => %{"in" => ["started"]}, - "or" => [ - %{"name" => %{"eqIgnoreCase" => "Human Review"}}, - %{"name" => %{"eqIgnoreCase" => "Gate Approved"}} - ] - } - - assert Map.has_key?(filter, "completedAt") - assert Map.has_key?(filter, "canceledAt") - end - - test "--no-profile bypasses active profile defaults via the full CLI dispatch path" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - query = decoded["query"] - - if String.contains?(query, "projects(") do - raise "--no-profile must not query projects when --project wasn't given" - end - - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--no-profile"]) - end) - - assert output =~ "CRY-1" - assert_received {:filter, filter} - refute Map.has_key?(filter, "team") - refute Map.has_key?(filter, "project") - end - - test "--state with an unknown type exits 1 (Optimus parse error)" do - # The production halt function never returns. Throw from the test double - # too, so the parser's error path stops before it reaches the normal CLI - # dispatch and emits an unrelated exception diagnostic. - output = - capture_io(fn -> - assert catch_throw( - LinearCli.CLI.main( - ["issue", "list", "--state", "badtype"], - fn code -> throw({:halted, code}) end - ) - ) == {:halted, 1} - end) - - assert output =~ "invalid value \"badtype\" for --state option" - end - - test "--labels filters by a single label name (case-insensitive)" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Incident-followup"]) - end) - - assert_received {:filter, filter} - - assert filter["labels"] == %{ - "some" => %{"name" => %{"eqIgnoreCase" => "Incident-followup"}} - } - end - - test "--labels accepts comma-separated names and matches issues with any of them (OR)" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug,Feature"]) - end) - - assert_received {:filter, filter} - - assert filter["labels"] == %{ - "some" => %{ - "or" => [ - %{"name" => %{"eqIgnoreCase" => "Bug"}}, - %{"name" => %{"eqIgnoreCase" => "Feature"}} - ] - } - } - end - - test "--labels with an unknown label name returns empty result, not a crash" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, issues_response([])) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "no-such-label"]) - end) - - assert output == "" or is_binary(output) - end - - test "--labels composes with --team" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - - if String.contains?(decoded["query"] || "", "teams(") do - Req.Test.json(conn, %{ - "data" => %{ - "teams" => %{ - "edges" => [ - %{ - "node" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, - "cursor" => "c1" - } - ], - "pageInfo" => %{"hasNextPage" => false, "endCursor" => "c1"} - } - } - }) - else - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([issue_map()])) - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--team", "ENG", "--labels", "Bug"]) - end) - - assert_received {:filter, filter} - assert Map.has_key?(filter, "team") - assert filter["labels"] == %{"some" => %{"name" => %{"eqIgnoreCase" => "Bug"}}} - end - - test "compact listing includes workflow state name in brackets" 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(%{"state" => %{"id" => "s2", "name" => "In Review", "type" => "started"}}) - ]) - ) - end) - - output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) - assert output =~ "[In Review]" - assert output =~ "Fix the thing" - end - - test "compact listing omits state bracket when state is nil" do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => _} = Jason.decode!(body) - Req.Test.json(conn, issues_response([issue_map(%{"state" => nil})])) - end) - - output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) - assert output =~ "CRY-1" - refute output =~ "[" - end - - test "--full listing includes workflow state name in header" do - 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(%{"state" => %{"id" => "s3", "name" => "Done", "type" => "completed"}}) - } - }) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--full", "CRY-1"]) - end) - - assert output =~ "[Done]" - assert output =~ "Fix the thing" - end - - test "-l shows label names in compact output" do - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "-l", "Bug"]) - end) - - assert output =~ "CRY-1" - assert output =~ "[Bug]" - end - - test "--labels shows label names in compact output" do - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug"]) - end) - - assert output =~ "CRY-1" - assert output =~ "[Bug]" - end - - test "--labels with multiple labels shows all label names in compact output" do - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false}, - %{"id" => "l2", "name" => "Feature", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug,Feature"]) - end) - - assert output =~ "CRY-1" - assert output =~ "[Bug, Feature]" - end - - test "compact listing without --labels does not show label brackets" do - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) - - assert output =~ "CRY-1" - refute output =~ "[Bug]" - end - - test "--include-labels requests label fields and shows them in compact output" do - test_pid = self() - - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - send(test_pid, {:query, query}) - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--include-labels"]) - end) - - assert_received {:query, query} - assert String.contains?(query, "labels") - assert output =~ "CRY-1" - assert output =~ "[Bug]" - end - - test "-i short flag requests label fields and shows them in compact output" do - test_pid = self() - - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - send(test_pid, {:query, query}) - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "-i"]) - end) - - assert_received {:query, query} - assert String.contains?(query, "labels") - assert output =~ "CRY-1" - assert output =~ "[Bug]" - end - - test "-N --include-labels --all sends no assignee/date filter, no label filter, requests label fields" do - test_pid = self() - - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Feature", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - send(test_pid, {:query, decoded["query"]}) - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "list", - "-N", - "--include-labels", - "--all" - ]) - end) - - assert_received {:filter, filter} - assert_received {:query, query} - refute Map.has_key?(filter, "assignee") - refute Map.has_key?(filter, "completedAt") - refute Map.has_key?(filter, "canceledAt") - refute Map.has_key?(filter, "labels") - assert String.contains?(query, "labels") - assert output =~ "CRY-1" - assert output =~ "[Feature]" - end - - test "-N -i --all is the same as -N --include-labels --all" do - test_pid = self() - - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Feature", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "-N", "-i", "--all"]) - end) - - assert_received {:filter, filter} - refute Map.has_key?(filter, "assignee") - refute Map.has_key?(filter, "labels") - assert output =~ "[Feature]" - end - - test "--labels --all exits 1 (Optimus parse error, no API request)" do - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - output = - capture_io(fn -> - assert catch_throw( - LinearCli.CLI.main( - ["issue", "list", "--labels", "--all"], - fn code -> throw({:halted, code}) end - ) - ) == {:halted, 1} - end) - - assert output =~ "--labels" - end - - test "-s/--status composes with --include-labels" do - test_pid = self() - - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - send(test_pid, {:filter, decoded["variables"]["filter"]}) - send(test_pid, {:query, decoded["query"]}) - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "list", - "--status", - "Human Review", - "--include-labels" - ]) - end) - - assert_received {:filter, filter} - assert_received {:query, query} - assert filter["state"] == %{"name" => %{"eqIgnoreCase" => "Human Review"}} - assert String.contains?(query, "labels") - assert output =~ "CRY-1" - assert output =~ "[Bug]" - end - - test "positional issue identifier with --include-labels uses full lookup and renders labels" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - send(test_pid, {:query, query}) - - Req.Test.json(conn, %{ - "data" => %{ - "issue" => - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{ - "id" => "l1", - "name" => "Bug", - "description" => nil, - "isGroup" => false - } - ] - }, - "comments" => %{"nodes" => []}, - "relations" => %{ - "edges" => [], - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} - }, - "inverseRelations" => %{ - "edges" => [], - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} - } - }) - } - }) - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main(["issue", "list", "CRY-1", "--include-labels"]) - end) - - assert_received {:query, query} - assert String.contains?(query, "issue(id: $id)") - assert output =~ "CRY-1" - assert output =~ "Bug" - end - - test "--output json --include-labels contains fetched label objects in stdout" do - test_pid = self() - - labeled_issue = - issue_map(%{ - "labels" => %{ - "nodes" => [ - %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} - ] - } - }) - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - send(test_pid, {:query, query}) - Req.Test.json(conn, issues_response([labeled_issue])) - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "list", - "--output", - "json", - "--include-labels" - ]) - end) - - assert_received {:query, query} - assert String.contains?(query, "labels") - assert {:ok, [decoded]} = Jason.decode(output) - assert [label] = decoded["labels"] - assert label["name"] == "Bug" - end - end - - describe "issue view" do - test "prints full issue details (header, description, state)" do - 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(%{ - "state" => %{"id" => "s1", "name" => "In Progress", "type" => "started"} - }) - } - }) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "view", "CRY-1"]) - end) - - assert output =~ "CRY-1" - assert output =~ "Fix the thing" - assert output =~ "[In Progress]" - end - - test "outputs JSON when --output json is given" do - 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()}}) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "view", "CRY-1", "--output", "json"]) - end) - - decoded = Jason.decode!(output) - assert decoded["identifier"] == "CRY-1" - assert decoded["title"] == "Fix the thing" - end - - test "lc i v ISSUE_ID alias routes to issue view" do - 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()}}) - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["i", "v", "CRY-1"]) - end) - - assert output =~ "CRY-1" - assert output =~ "Fix the thing" - end - - test "--web opens the issue URL in the browser and prints nothing" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => _} = Jason.decode!(body) - - Req.Test.json(conn, %{ - "data" => %{ - "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) - } - }) - end) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_view( - %{ - args: %{issue_id: "CRY-1"}, - flags: %{web: true}, - options: %{output: "text"} - }, - opener: fn url -> - send(test_pid, {:opened, url}) - :ok - end - ) - end) - - assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} - assert output == "" - end - - test "-w short flag opens the browser via the full CLI dispatch path" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => _} = Jason.decode!(body) - - Req.Test.json(conn, %{ - "data" => %{ - "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) - } - }) - end) - - capture_io(fn -> - assert :ok = - Commands.issue_view( - %{ - args: %{issue_id: "CRY-1"}, - flags: %{web: true}, - options: %{output: "text"} - }, - opener: fn url -> - send(test_pid, {:opened, url}) - :ok - end - ) - end) - - assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} - end - - test "--web with --output json opens browser and prints nothing" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => _} = Jason.decode!(body) - - Req.Test.json(conn, %{ - "data" => %{ - "issue" => issue_map(%{"url" => "https://linear.app/the-rubyists/issue/CRY-1"}) - } - }) - end) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_view( - %{ - args: %{issue_id: "CRY-1"}, - flags: %{web: true}, - options: %{output: "json"} - }, - opener: fn url -> - send(test_pid, {:opened, url}) - :ok - end - ) - end) - - assert_received {:opened, "https://linear.app/the-rubyists/issue/CRY-1"} - assert output == "" - end - end - - describe "issue create (Ruby: commands/issue/create.rb)" do - test "resolves every field, declines to take it, and displays the created issue" do - stub_responses([ - {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, - {"issueLabels", label_response(["urgent"])}, - {"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])}, - {"issueCreate", - %{ - "data" => %{ - "issueCreate" => %{ - "issue" => - issue_map(%{ - "id" => "i2", - "identifier" => "CRY-2", - "title" => "New thing", - "branchName" => "cry-2-new-thing", - "description" => "Some description" - }) - } - } - }} - ]) - - output = - capture_io([input: "n\n"], fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "create", - "--title", - "New thing", - "--description", - "Some description", - "--team", - "ENG", - "-l", - "urgent", - "--project", - "Manhattan Rollout" - ]) - end) - - assert output =~ "Do you want to take this issue?" - assert output =~ "CRY-2" - assert output =~ "New thing" - end - - test "--dev still checks out and pushes the new issue's branch after declining to take it" do - repo = git_repo!() - me = %User{id: "u1", name: "Ada", email: "ada@x.com"} - - created_issue = - issue_map(%{ - "id" => "i2", - "identifier" => "CRY-2", - "title" => "New thing", - "branchName" => "cry-2-new-thing", - "description" => "Some description", - "assignee" => me_map() - }) - - stub_responses([ - {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, - {"issueLabels", label_response(["urgent"])}, - {"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])}, - {"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}}, - {"issue(id: $id)", %{"data" => %{"issue" => created_issue}}} - ]) - - result = %{ - options: %{ - title: "New thing", - description: "Some description", - team: "ENG", - labels: ["urgent"], - project: "Manhattan Rollout", - output: "text" - }, - flags: %{develop: true, yes: false} - } - - output = - capture_io([input: "n\n"], fn -> - assert :ok = Commands.issue_create(result, cwd: repo, me: me) - end) - - assert output =~ "Checked out branch cry-2-new-thing" - assert output =~ "Upstream branch not found, pushing local cry-2-new-thing to origin" - assert output =~ "Set upstream to origin/cry-2-new-thing" - assert output =~ "Ready to develop!" - end - - test "--body-file reads the description from a file verbatim" do - path = tmp_path("body_file") - # Includes a literal backslash-n and a $VAR-looking string — the same - # content that broke when built as an inline shell argument (EXT-17 incident). - File.write!(path, "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim") - on_exit(fn -> File.rm(path) end) - - 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, "team(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) - - String.contains?(query, "issueLabels") -> - Req.Test.json(conn, label_response(["docs"])) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, team_projects([])) - - String.contains?(query, "issueCreate") -> - send(test_pid, {:sent_description, decoded["variables"]["input"]["description"]}) - - Req.Test.json(conn, %{ - "data" => %{ - "issueCreate" => %{ - "issue" => issue_map(%{"identifier" => "CRY-2", "title" => "T"}) - } - } - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io([input: "n\n"], fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "create", - "--body-file", - path, - "--title", - "T", - "--team", - "ENG", - "-l", - "docs" - ]) - end) - - assert_received {:sent_description, - "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim"} - end - - test "--body-file - reads the description from stdin" do - # Uses Commands.issue_create directly so that IO.read(:stdio, :eof) only - # consumes the piped content (not the yes/no prompt input too). The - # maybe_take prompt gets EOF after stdin is consumed; Owl.IO.confirm with - # default: true returns true, so gimme_da_issue! runs and finds the issue - # already assigned to `me`, short-circuiting without a second mutation. - test_pid = self() - me = %User{id: "u1", name: "Ada", email: "ada@x.com"} - - 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, "team(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) - - String.contains?(query, "issueLabels") -> - Req.Test.json(conn, label_response([])) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, team_projects([])) - - String.contains?(query, "issueCreate") -> - send(test_pid, {:sent_description, decoded["variables"]["input"]["description"]}) - - Req.Test.json(conn, %{ - "data" => %{ - "issueCreate" => %{ - "issue" => issue_map(%{"id" => "i2", "identifier" => "CRY-2", "title" => "T"}) - } - } - }) - - String.contains?(query, "issue(id: $id)") -> - Req.Test.json(conn, %{ - "data" => %{ - "issue" => - issue_map(%{"id" => "i2", "identifier" => "CRY-2", "assignee" => me_map()}) - } - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - result = %{ - options: %{ - title: "T", - body_file: "-", - description: nil, - team: "ENG", - labels: [], - project: nil, - output: "text" - }, - flags: %{develop: false, yes: false} - } - - capture_io("piped from stdin\nwith a real newline", fn -> - assert :ok = Commands.issue_create(result, me: me) - end) - - assert_received {:sent_description, "piped from stdin\nwith a real newline"} - end - - test "--description and --body-file together is a smells_bad error, no GraphQL call" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - [ - "issue", - "create", - "--title", - "T", - "--team", - "ENG", - "-d", - "some desc", - "--body-file", - "somefile" - ], - halt - ) - end) - - assert_received {:halted, 22} - assert output =~ "give --description or --body-file, not both" - end - - test "an unreadable --body-file surfaces an error, no GraphQL call" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - capture_io(:stderr, fn -> - LinearCli.CLI.main( - [ - "issue", - "create", - "--body-file", - "/nonexistent/path/does-not-exist", - "--title", - "T", - "--team", - "ENG" - ], - halt - ) - end) - - assert_received {:halted, _code} - end - - test "--no-take keeps a -y/--yes-created issue unassigned" do - created_issue = - issue_map(%{ - "id" => "i2", - "identifier" => "CRY-2", - "title" => "New thing", - "branchName" => "cry-2-new-thing", - "description" => "Some description", - "assignee" => nil - }) - - stub_responses([ - {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, - {"projects(first: 100", team_projects([])}, - {"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}} - ]) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "create", - "--title", - "New thing", - "--description", - "Some description", - "--team", - "ENG", - "--yes", - "--no-take" - ]) - end) - - refute output =~ "Do you want to take this issue?" - refute output =~ "Assigning issue" - assert output =~ "CRY-2" - end - - test "--no-take cannot be combined with --dev" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - [ - "issue", - "create", - "--title", - "New thing", - "--description", - "Some description", - "--team", - "ENG", - "--yes", - "--no-take", - "--dev" - ], - halt - ) - end) - - assert_received {:halted, 22} - assert output =~ "--no-take cannot be used with --dev" - end - - test "-y/--yes with all required flags creates and self-assigns without any prompts" do - created_issue = - issue_map(%{ - "id" => "i2", - "identifier" => "CRY-2", - "title" => "New thing", - "branchName" => "cry-2-new-thing", - "description" => "Some description", - "assignee" => me_map() - }) - - stub_responses([ - {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, - {"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])}, - {"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}}, - {"viewer", %{"data" => %{"viewer" => me_map()}}}, - {"issue(id: $id)", %{"data" => %{"issue" => created_issue}}} - ]) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "create", - "--title", - "New thing", - "--description", - "Some description", - "--team", - "ENG", - "--project", - "Manhattan Rollout", - "--yes" - ]) - end) - - refute output =~ "Do you want to take this issue?" - assert output =~ "CRY-2" - end - - test "-y without --title is a smells_bad error" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - ["issue", "create", "--description", "Some desc", "--team", "ENG", "--yes"], - halt - ) - end) - - assert_received {:halted, 22} - assert output =~ "--title is required with --yes" - end - - test "-y without --description (or --body-file) is a smells_bad error" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - ["issue", "create", "--title", "New thing", "--team", "ENG", "--yes"], - halt - ) - end) - - assert_received {:halted, 22} - assert output =~ "--description is required with --yes" - end - - test "-y without --team (and multiple teams) is a smells_bad error" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - stub_responses([ - {"viewer", - %{ - "data" => %{ - "viewer" => %{ - "id" => "u1", - "name" => "Ada", - "email" => "ada@x.com", - "teams" => %{ - "nodes" => [ - team_map(), - %{"id" => "t2", "key" => "OPS", "name" => "Ops", "description" => nil} - ] - } - } - } - }} - ]) - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - [ - "issue", - "create", - "--title", - "New thing", - "--description", - "Some desc", - "--yes" - ], - halt - ) - end) - - assert_received {:halted, 22} - assert output =~ "--team is required" - end - - test "-y with --project resolves it by exact match and uses it" do - test_pid = self() - me = %User{id: "u1", name: "Ada", email: "ada@x.com"} - - created_issue = - issue_map(%{ - "id" => "i2", - "identifier" => "CRY-2", - "title" => "T", - "description" => "D", - "assignee" => me_map() - }) - - 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, "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"), - project_map("p2", "Other Project") - ]) - ) - - String.contains?(query, "issueCreate") -> - send(test_pid, {:project_id, decoded["variables"]["input"]["projectId"]}) - Req.Test.json(conn, %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}) - - String.contains?(query, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => created_issue}}) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - Commands.issue_create( - %{ - options: %{ - title: "T", - description: "D", - team: "ENG", - labels: [], - project: "Manhattan Rollout", - output: "text" - }, - flags: %{develop: false, yes: true} - }, - me: me - ) - end) - - assert_received {:project_id, "p1"} - end - end - - describe "issue develop (Ruby: commands/issue/develop.rb)" do - 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"} - - stub_responses([ - {"issue(id: $id)", - %{"data" => %{"issue" => issue_map(%{"branchName" => "main", "assignee" => me_map()})}}} - ]) - - result = %{args: %{issue_id: "CRY-1"}} - - output = - capture_io(fn -> - assert :ok = Commands.issue_develop(result, cwd: repo, me: me) - end) - - assert output =~ "You are already assigned CRY-1" - assert output =~ "Checked out branch main" - assert output =~ "Ready to develop!" - refute output =~ "Upstream branch not found" - end - - test "pushes a new branch and sets its upstream when the branch has no tracking branch yet" do - repo = git_repo!() - me = %User{id: "u1", name: "Ada", email: "ada@x.com"} - - stub_responses([ - {"issue(id: $id)", - %{ - "data" => %{ - "issue" => - issue_map(%{"branchName" => "cry-1-fix-the-thing", "assignee" => me_map()}) - } - }} - ]) - - result = %{args: %{issue_id: "CRY-1"}} - - output = - capture_io(fn -> - assert :ok = Commands.issue_develop(result, cwd: repo, me: me) - end) - - assert output =~ "Checked out branch cry-1-fix-the-thing" - assert output =~ "Upstream branch not found, pushing local cry-1-fix-the-thing to origin" - assert output =~ "Set upstream to origin/cry-1-fix-the-thing" - assert output =~ "Ready to develop!" - end - end - - describe "issue pr (Ruby: commands/issue/pr.rb)" do - 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"} - - stub_responses([ - {"issue(id: $id)", - %{"data" => %{"issue" => issue_map(%{"branchName" => "main", "assignee" => me_map()})}}} - ]) - - result = %{ - args: %{issue_id: "CRY-1"}, - options: %{title: "fix: CRY-1 - Fix the thing", description: "body"} - } - - output = - capture_io(fn -> - assert :ok = - Commands.issue_pr(result, - cwd: repo, - me: me, - runner: fn title, body -> "gh said: #{title} (#{body})" end - ) - end) - - assert output =~ "Checked out branch main" - assert output =~ "gh said: fix: CRY-1 - Fix the thing (body)" - refute output =~ "Ready to develop!" - end - end - - describe "issue take (Ruby: commands/issue/take.rb)" do - test "self-assigns unassigned issues and warns, but doesn't abort, on an unknown id" do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - query = decoded["query"] - variables = decoded["variables"] || %{} - - cond do - query =~ "viewer" -> - Req.Test.json(conn, %{"data" => %{"viewer" => me_map()}}) - - query =~ "issue(id: $id)" and variables["id"] == "CRY-1" -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map(%{"assignee" => nil})}}) - - query =~ "issue(id: $id)" and variables["id"] == "NOPE" -> - Req.Test.json(conn, %{"data" => %{"issue" => nil}}) - - query =~ "issueUpdate" -> - Req.Test.json(conn, issue_updated(%{"assignee" => me_map()})) - end - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "take", "CRY-1", "nope"]) - end) - - assert output =~ "Assigning issue CRY-1 to ya" - assert output =~ "No issue found with id nope" - assert output =~ "CRY-1" - end - end - - describe "issue status" do - defp state_map(id, name, position, type) do - %{"id" => id, "name" => name, "position" => position, "type" => type, "description" => nil} - end - - defp issue_with_state(state_id, state_name) do - issue_map(%{"state" => %{"id" => state_id, "name" => state_name, "type" => "started"}}) - end - - defp states_response do - workflow_states([ - state_map("s1", "Triage", 0.0, "triage"), - state_map("s2", "In Progress", 1.0, "started"), - state_map("s3", "Done", 2.0, "completed") - ]) - end - - test "--status sets the workflow state by exact name (case-insensitive)" do - test_pid = self() - - 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "issueUpdate") -> - body_decoded = Jason.decode!(body) - send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) - - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "status", "--status", "done", "CRY-1"]) - end) - - assert_received {:state_id, "s3"} - assert output =~ "CRY-1" - assert output =~ "status set to Done" - end - - test "--status updates multiple issue IDs concurrently and emits a JSON array" do - test_pid = self() - - issue_details = fn - "CRY-1" -> {"i1", "t1", "ENG", "Engineering", "s-eng-done"} - "CRY-2" -> {"i2", "t2", "OPS", "Operations", "s-ops-done"} - end - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - variables = decoded["variables"] || %{} - - cond do - String.contains?(query, "issue(id: $id)") -> - identifier = variables["id"] - {id, team_id, team_key, team_name, _state_id} = issue_details.(identifier) - - Req.Test.json(conn, %{ - "data" => %{ - "issue" => - issue_map(%{ - "id" => id, - "identifier" => identifier, - "team" => %{"id" => team_id, "key" => team_key, "name" => team_name} - }) - } - }) - - String.contains?(query, "states {") -> - team_id = variables["teamId"] - state_id = if team_id == "t1", do: "s-eng-done", else: "s-ops-done" - send(test_pid, {:states_queried, team_id}) - Req.Test.json(conn, workflow_states([state_map(state_id, "Done", 1.0, "completed")])) - - String.contains?(query, "issueUpdate") -> - identifier = variables["id"] - state_id = variables["input"]["stateId"] - {id, team_id, team_key, team_name, ^state_id} = issue_details.(identifier) - update_pid = self() - send(test_pid, {:status_update_started, identifier, state_id, update_pid}) - - receive do - :finish_status_update -> :ok - after - 2_000 -> raise "status update was not released by the concurrency assertion" - end - - Req.Test.json(conn, %{ - "data" => %{ - "issueUpdate" => %{ - "issue" => - issue_map(%{ - "id" => id, - "identifier" => identifier, - "team" => %{"id" => team_id, "key" => team_key, "name" => team_name}, - "state" => %{"id" => state_id, "name" => "Done", "type" => "completed"} - }) - } - } - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - command = - Task.async(fn -> - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "status", - "--status", - "Done", - "--output", - "json", - "CRY-1", - "CRY-2" - ]) - end) - end) - - assert_receive {:status_update_started, "CRY-1", "s-eng-done", first_update}, 1_000 - assert_receive {:status_update_started, "CRY-2", "s-ops-done", second_update}, 1_000 - send(first_update, :finish_status_update) - send(second_update, :finish_status_update) - - output = Task.await(command) - - assert_received {:states_queried, "t1"} - assert_received {:states_queried, "t2"} - - assert {:ok, decoded} = Jason.decode(output) - assert Enum.map(decoded, & &1["identifier"]) == ["CRY-1", "CRY-2"] - end - - test "variadic issue IDs do not swallow unrecognized options" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - ["issue", "status", "--statuz", "Done", "CRY-1", "CRY-2"], - halt - ) - end) - - assert_received {:halted, 22} - assert stderr =~ "unrecognized option(s): --statuz" - end - - test "-s short flag also sets the workflow state" do - test_pid = self() - - 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "issueUpdate") -> - body_decoded = Jason.decode!(body) - send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) - - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "status", "-s", "Done", "CRY-1"]) - end) - - assert_received {:state_id, "s3"} - assert output =~ "status set to Done" - end - - test "--status with prefix match selects unique match" do - test_pid = self() - - 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "issueUpdate") -> - body_decoded = Jason.decode!(body) - send(test_pid, {:state_id, body_decoded["variables"]["input"]["stateId"]}) - - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s2", "In Progress")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "status", "--status", "in", "CRY-1"]) - end) - - assert_received {:state_id, "s2"} - assert output =~ "status set to In Progress" - end - - test "--status with unknown name exits 22 (smells bad)" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "states {") -> - Req.Test.json(conn, states_response()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "status", "--status", "Nonexistent", "CRY-1"], halt) - end) - - assert_received {:halted, 22} - assert stderr =~ "Unknown status" - assert stderr =~ "This smells bad! Bailing." - end - - test "--status with ambiguous prefix exits 22 (smells bad)" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "states {") -> - # Two states starting with "D" to trigger ambiguity - Req.Test.json( - conn, - workflow_states([ - state_map("s1", "Done", 1.0, "completed"), - state_map("s2", "Doing", 2.0, "started") - ]) - ) - - true -> - raise "no stub matched query: #{query}" - end - end) - - stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "status", "--status", "Do", "CRY-1"], halt) - end) - - assert_received {:halted, 22} - assert stderr =~ "Ambiguous status" - assert stderr =~ "This smells bad! Bailing." - end - - test "--comment adds a comment before changing the status" do - test_pid = self() - - 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "commentCreate") -> - send(test_pid, :comment_created) - Req.Test.json(conn, comment_created()) - - String.contains?(query, "issueUpdate") -> - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "status", - "--status", - "Done", - "--comment", - "Wrapping up", - "CRY-1" - ]) - end) - - assert_received :comment_created - assert output =~ "Comment added to CRY-1" - assert output =~ "status set to Done" - end - - test "interactive selection (no --status) prompts from sorted states" 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "issueUpdate") -> - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - # Select the third option ("Done") interactively via stdin - output = - capture_io([input: "3\n"], fn -> - assert :ok = LinearCli.CLI.main(["issue", "status", "CRY-1"]) - end) - - assert output =~ "Choose a status" - assert output =~ "status set to Done" - end - - test "--output json emits structured output" 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "issueUpdate") -> - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "status", - "--status", - "Done", - "--output", - "json", - "CRY-1" - ]) - end) - - assert {:ok, decoded} = Jason.decode(output) - assert decoded["identifier"] == "CRY-1" - end - - test "alias 's' routes to issue status" do - test_pid = self() - - 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :updated) - - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "s", "--status", "Done", "CRY-1"]) - end) - - assert_received :updated - end - - test "alias 'st' routes to issue status" do - test_pid = self() - - 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :updated) - - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "st", "--status", "Done", "CRY-1"]) - end) - - assert_received :updated - end - - test "alias 'stat' routes to issue status" do - test_pid = self() - - 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, "states {") -> - Req.Test.json(conn, states_response()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :updated) - - Req.Test.json(conn, %{ - "data" => %{"issueUpdate" => %{"issue" => issue_with_state("s3", "Done")}} - }) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "stat", "--status", "Done", "CRY-1"]) - end) - - assert_received :updated - end - end - - describe "issue update (Ruby: commands/issue/update.rb)" do - test "--close --status selects a completed state without prompting" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "commentCreate") -> - Req.Test.json(conn, comment_created()) - - String.contains?(query, "states {") -> - Req.Test.json( - conn, - workflow_states([ - %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"}, - %{ - "id" => "s2", - "name" => "Shipped", - "position" => 2.0, - "type" => "completed" - } - ]) - ) - - String.contains?(query, "issueUpdate") -> - assert decoded["variables"]["input"] == %{"stateId" => "s2"} - send(test_pid, :closed_as_shipped) - Req.Test.json(conn, issue_updated()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "update", - "--close", - "--status", - "ship", - "--reason", - "Done", - "CRY-1" - ]) - end) - - assert output =~ "Comment added to CRY-1" - assert output =~ "CRY-1 was closed" - refute output =~ "Choose a completed state" - assert_received :closed_as_shipped - end - - test "--description updates the issue description via the issueUpdate mutation" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:description, decoded["variables"]["input"]["description"]}) - Req.Test.json(conn, issue_updated(%{"description" => "Updated body"})) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "update", - "--description", - "Updated body", - "CRY-1" - ]) - end) - - assert_received {:description, "Updated body"} - assert output =~ "CRY-1 description updated" - end - - test "-d short flag also updates the issue description" do - stub_responses([ - {"issue(id: $id)", %{"data" => %{"issue" => issue_map()}}}, - {"issueUpdate", issue_updated(%{"description" => "Short flag body"})} - ]) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "update", - "-d", - "Short flag body", - "CRY-1" - ]) - end) - - assert output =~ "CRY-1 description updated" - end - - test "with no issue ids, exits 22 (Ruby: raise SmellsBad -> exit 22)" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "update"], halt) - end) - - assert_received {:halted, 22} - assert output =~ "No issue IDs provided!" - assert output =~ "This smells bad! Bailing." - end - - test "--body-file reads the description from a file verbatim" do - path = tmp_path("body_file") - File.write!(path, "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim") - on_exit(fn -> File.rm(path) end) - - 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:description, decoded["variables"]["input"]["description"]}) - - Req.Test.json( - conn, - issue_updated(%{ - "description" => "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim" - }) - ) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "update", - "--body-file", - path, - "CRY-1" - ]) - end) - - assert_received {:description, "## Summary\n\nliteral \\n and $SOME_VAR survive verbatim"} - assert output =~ "CRY-1 description updated" - end - - test "--body-file - reads the description from stdin" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:description, decoded["variables"]["input"]["description"]}) - Req.Test.json(conn, issue_updated(%{"description" => "from stdin body"})) - - true -> - raise "no stub matched query: #{query}" - end - end) - - result = %{ - unknown: ["CRY-1"], - options: %{ - body_file: "-", - description: nil, - comment: nil, - project: nil, - reason: nil, - status: nil - }, - flags: %{cancel: false, close: false, trash: false} - } - - capture_io("from stdin body", fn -> - assert :ok = Commands.issue_update(result) - end) - - assert_received {:description, "from stdin body"} - end - - test "--description and --body-file together is a smells_bad error, no GraphQL call" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - [ - "issue", - "update", - "-d", - "some desc", - "--body-file", - "somefile", - "CRY-1" - ], - halt - ) - end) - - assert_received {:halted, 22} - assert output =~ "give --description or --body-file, not both" - end - - test "an unreadable --body-file surfaces an error, no GraphQL call" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - capture_io(:stderr, fn -> - LinearCli.CLI.main( - [ - "issue", - "update", - "--body-file", - "/nonexistent/path/does-not-exist", - "CRY-1" - ], - halt - ) - end) - - assert_received {:halted, _code} - end - end - - describe "issue assign" do - defp member_map(id, name, email \\ nil) do - %{"id" => id, "name" => name, "email" => email || "#{id}@example.com"} - end - - defp members_response(members) do - %{"data" => %{"team" => %{"members" => %{"nodes" => members}}}} - end - - defp issue_assigned(assignee_map) do - %{"data" => %{"issueUpdate" => %{"issue" => issue_map(%{"assignee" => assignee_map})}}} - end - - test "--assignee sets the assignee by exact name (case-insensitive)" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - decoded = 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"), member_map("u3", "Alice")]) - ) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:assignee_id, decoded["variables"]["input"]["assigneeId"]}) - Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "assign", "--assignee", "bob", "CRY-1"]) - end) - - assert_received {:assignee_id, "u2"} - assert output =~ "assigned to Bob" - end - - test "--assignee prefix match selects unique match" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - decoded = 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"), member_map("u3", "Alice")]) - ) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:assignee_id, decoded["variables"]["input"]["assigneeId"]}) - Req.Test.json(conn, issue_assigned(member_map("u3", "Alice"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "assign", "--assignee", "Ali", "CRY-1"]) - end) - - assert_received {:assignee_id, "u3"} - assert output =~ "assigned to Alice" - end - - test "--assignee with unknown name exits 22 (smells bad)" 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, "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"), member_map("u3", "Alice")]) - ) - - true -> - raise "no stub matched query: #{query}" - end - end) - - stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "assign", "--assignee", "Nobody", "CRY-1"], halt) - end) - - assert_received {:halted, 22} - assert stderr =~ "Unknown assignee" - assert stderr =~ "This smells bad! Bailing." - end - - test "--assignee with ambiguous prefix exits 22 (smells bad)" 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, "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"), member_map("u3", "Bobby")]) - ) - - true -> - raise "no stub matched query: #{query}" - end - end) - - stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "assign", "--assignee", "Bo", "CRY-1"], halt) - end) - - assert_received {:halted, 22} - assert stderr =~ "Ambiguous assignee" - assert stderr =~ "This smells bad! Bailing." - end - - test "interactive selection (no --assignee) prompts from sorted members" 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"), member_map("u3", "Alice")]) - ) - - String.contains?(query, "issueUpdate") -> - Req.Test.json(conn, issue_assigned(member_map("u3", "Alice"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - # Members are sorted by name: Alice (1), Bob (2) — select "1\n" for Alice - output = - capture_io([input: "1\n"], fn -> - assert :ok = LinearCli.CLI.main(["issue", "assign", "CRY-1"]) - end) - - assert output =~ "Choose an assignee" - assert output =~ "assigned to Alice" - end - - test "--output json emits structured output" 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") -> - Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "assign", - "--assignee", - "Bob", - "--output", - "json", - "CRY-1" - ]) - end) - - assert {:ok, decoded} = Jason.decode(output) - assert decoded["identifier"] == "CRY-1" - end - - test "alias 'a' routes to issue assign" do - test_pid = self() - - 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") -> - send(test_pid, :assigned) - Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "a", "--assignee", "Bob", "CRY-1"]) - end) - - assert_received :assigned - end - - test "no assignable members exits 22 (smells bad)" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "members(first: 50)") -> - Req.Test.json(conn, members_response([])) - - true -> - raise "no stub matched query: #{query}" - end - end) - - stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "assign", "CRY-1"], halt) - end) - - assert_received {:halted, 22} - assert stderr =~ "No assignable members" - assert stderr =~ "This smells bad! Bailing." - end - - test "--status sends assigneeId and stateId in one issueUpdate" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - 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, "states {") -> - Req.Test.json( - conn, - workflow_states([ - state_map("s2", "In Progress", 1.0, "started") - ]) - ) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:input, decoded["variables"]["input"]}) - - Req.Test.json( - conn, - issue_assigned(member_map("u2", "Bob")) - |> put_in( - ["data", "issueUpdate", "issue", "state"], - %{"id" => "s2", "name" => "In Progress", "type" => "started"} - ) - ) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "assign", - "--assignee", - "Bob", - "--status", - "In Progress", - "CRY-1" - ]) - end) - - assert_received {:input, input} - assert input["assigneeId"] == "u2" - assert input["stateId"] == "s2" - assert output =~ "assigned to Bob" - assert output =~ "In Progress" - end - - test "--status short form -s also works on assign" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - 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, "states {") -> - Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "-s", "Todo", "CRY-1"]) - end) - - assert_received {:input, input} - assert input["stateId"] == "s1" - end - - test "--status with case-insensitive name match on assign" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - 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, "states {") -> - Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "--status", "todo", "CRY-1"]) - end) - - assert_received {:input, input} - assert input["stateId"] == "s1" - end - - test "--status unknown name exits 22 before sending any mutation on assign" 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, "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, "states {") -> - Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :mutated) - raise "issueUpdate should not be called when status is invalid" - - true -> - raise "no stub matched query: #{query}" - end - end) - - stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - ["issue", "assign", "-a", "Bob", "--status", "NoSuchState", "CRY-1"], - halt - ) - end) - - assert_received {:halted, 22} - refute_received :mutated - assert stderr =~ "Unknown status" - end - - test "omitting --status sends only assigneeId (backward compat) on assign" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - 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") -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "assign", "-a", "Bob", "CRY-1"]) - end) - - assert_received {:input, input} - assert input == %{"assigneeId" => "u2"} - refute Map.has_key?(input, "stateId") - end - - test "--output json with --status returns structured output on assign" 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, "states {") -> - Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) - - String.contains?(query, "issueUpdate") -> - Req.Test.json( - conn, - issue_assigned(member_map("u2", "Bob")) - |> put_in( - ["data", "issueUpdate", "issue", "state"], - %{"id" => "s1", "name" => "Todo", "type" => "unstarted"} - ) - ) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "assign", - "-a", - "Bob", - "--status", - "Todo", - "--output", - "json", - "CRY-1" - ]) - end) - - assert {:ok, decoded} = Jason.decode(output) - assert decoded["identifier"] == "CRY-1" - assert decoded["state"]["name"] == "Todo" - end - - test "--status with space in name works on assign" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - 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, "states {") -> - Req.Test.json( - conn, - workflow_states([state_map("s-ip", "In Progress", 1.0, "started")]) - ) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "assign", - "-a", - "Bob", - "--status", - "In Progress", - "CRY-53" - ]) - end) - - assert_received {:input, input} - assert input["stateId"] == "s-ip" - end - end - - describe "issue take with --status" do - defp take_member_map, do: %{"id" => "u1", "name" => "Ada", "email" => "ada@x.com"} - - defp take_issue_map(overrides \\ %{}) do - Map.merge( - %{ - "id" => "i1", - "identifier" => "CRY-1", - "title" => "Fix the thing", - "branchName" => "cry-1-fix-the-thing", - "description" => nil, - "assignee" => nil, - "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, - "comments" => %{"nodes" => []} - }, - overrides - ) - end - - test "--status sends both assigneeId and stateId in one issueUpdate" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - cond do - query =~ "viewer" -> - Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) - - query =~ "issue(id: $id)" -> - Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) - - query =~ "states {" -> - Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) - - query =~ "issueUpdate" -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "Todo", "CRY-1"]) - end) - - assert_received {:input, input} - assert input["assigneeId"] == "u1" - assert input["stateId"] == "s1" - end - - test "-s short form works on take" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - cond do - query =~ "viewer" -> - Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) - - query =~ "issue(id: $id)" -> - Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) - - query =~ "states {" -> - Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) - - query =~ "issueUpdate" -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "take", "-s", "Todo", "CRY-1"]) - end) - - assert_received {:input, input} - assert input["stateId"] == "s1" - end - - test "already-self-assigned issue still updates status when --status given" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - cond do - query =~ "viewer" -> - Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) - - query =~ "issue(id: $id)" -> - Req.Test.json( - conn, - %{ - "data" => %{ - "issue" => take_issue_map(%{"assignee" => take_member_map()}) - } - } - ) - - query =~ "states {" -> - Req.Test.json(conn, workflow_states([state_map("s2", "In Progress", 1.0, "started")])) - - query =~ "issueUpdate" -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main(["issue", "take", "--status", "In Progress", "CRY-1"]) - end) - - assert_received {:input, input} - assert input["assigneeId"] == "u1" - assert input["stateId"] == "s2" - end - - test "--status unknown name exits 22 before any mutation on take" 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 - query =~ "viewer" -> - Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) - - query =~ "issue(id: $id)" -> - Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) - - query =~ "states {" -> - Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) - - query =~ "issueUpdate" -> - send(test_pid, :mutated) - raise "issueUpdate should not be called" - - true -> - raise "no stub matched query: #{query}" - end - end) - - stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "take", "--status", "Bogus", "CRY-1"], halt) - end) - - assert_received {:halted, 22} - refute_received :mutated - assert stderr =~ "Unknown status" - end - - test "omitting --status sends only assigneeId on take (backward compat)" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - cond do - query =~ "viewer" -> - Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) - - query =~ "issue(id: $id)" -> - Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) - - query =~ "issueUpdate" -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "take", "CRY-1"]) - end) - - assert_received {:input, input} - assert input == %{"assigneeId" => "u1"} - refute Map.has_key?(input, "stateId") - end - - test "multiple issues from different teams resolve status independently" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - variables = decoded["variables"] || %{} - - cond do - query =~ "viewer" -> - Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) - - query =~ "issue(id: $id)" and variables["id"] == "CRY-1" -> - Req.Test.json( - conn, - %{ - "data" => %{ - "issue" => - take_issue_map(%{ - "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"} - }) - } - } - ) - - query =~ "issue(id: $id)" and variables["id"] == "CRY-2" -> - Req.Test.json( - conn, - %{ - "data" => %{ - "issue" => - take_issue_map(%{ - "id" => "i2", - "identifier" => "CRY-2", - "team" => %{"id" => "t2", "key" => "OPS", "name" => "Operations"} - }) - } - } - ) - - query =~ "states {" and variables["teamId"] == "t1" -> - Req.Test.json( - conn, - workflow_states([state_map("s-eng-todo", "Todo", 0.0, "unstarted")]) - ) - - query =~ "states {" and variables["teamId"] == "t2" -> - Req.Test.json( - conn, - workflow_states([state_map("s-ops-todo", "Todo", 0.0, "unstarted")]) - ) - - query =~ "issueUpdate" -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "Todo", "CRY-1", "CRY-2"]) - end) - - assert_received {:input, input1} - assert_received {:input, input2} - - state_ids = MapSet.new([input1["stateId"], input2["stateId"]]) - assert MapSet.member?(state_ids, "s-eng-todo") - assert MapSet.member?(state_ids, "s-ops-todo") - end - - test "--status with space in name works on take" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - cond do - query =~ "viewer" -> - Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) - - query =~ "issue(id: $id)" -> - Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) - - query =~ "states {" -> - Req.Test.json( - conn, - workflow_states([state_map("s-ip", "In Progress", 1.0, "started")]) - ) - - query =~ "issueUpdate" -> - send(test_pid, {:input, decoded["variables"]["input"]}) - Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "In Progress", "CRY-53"]) - end) - - assert_received {:input, input} - assert input["stateId"] == "s-ip" - end - end describe "issue move" do defp move_project_map(id \\ "p1", name \\ "Manhattan") do @@ -4227,166 +749,6 @@ defmodule LinearCli.CLI.IssueCommandsTest do end end - describe "issue comment" do - defp stub_lookup_and(pairs) do - 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - match = Enum.find(pairs, fn {substr, _resp} -> String.contains?(query, substr) end) -> - {_substr, resp} = match - Req.Test.json(conn, (is_function(resp, 1) && resp.(decoded)) || resp) - - true -> - raise "no stub matched query: #{query}" - end - end) - end - - test "creates a new comment" do - stub_lookup_and([{"commentCreate", comment_created()}]) - - output = - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "-m", "lgtm"]) - end) - - assert output =~ "Comment added to CRY-1" - end - - test "--body-file reads the body from a file verbatim" do - path = tmp_path("body_file") - # Deliberately includes a literal backslash-n and a $VAR-looking string - - # exactly the content that broke when built as an inline shell argument - # (see documents/phase-13-plan.adoc's Goal section). - File.write!(path, "## Investigation\n\nliteral \\n and $SOME_VAR survive verbatim") - on_exit(fn -> File.rm(path) end) - - test_pid = self() - - stub_lookup_and([ - {"commentCreate", - fn decoded -> - send(test_pid, {:sent_body, decoded["variables"]["body"]}) - comment_created() - end} - ]) - - capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "--body-file", path]) - end) - - assert_received {:sent_body, - "## Investigation\n\nliteral \\n and $SOME_VAR survive verbatim"} - end - - test "--body-file - reads the body from stdin verbatim" do - test_pid = self() - - stub_lookup_and([ - {"commentCreate", - fn decoded -> - send(test_pid, {:sent_body, decoded["variables"]["body"]}) - comment_created() - end} - ]) - - capture_io("piped from stdin\nwith a real newline", fn -> - assert :ok = LinearCli.CLI.main(["issue", "comment", "CRY-1", "--body-file", "-"]) - end) - - assert_received {:sent_body, "piped from stdin\nwith a real newline"} - end - - test "--comment and --body-file together is a smells_bad error, no GraphQL call" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main( - ["issue", "comment", "CRY-1", "-m", "text", "--body-file", "somefile"], - halt - ) - end) - - assert_received {:halted, 22} - assert output =~ "give --comment or --body-file, not both" - end - - test "an unreadable --body-file surfaces an error, no GraphQL call" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - capture_io(:stderr, fn -> - LinearCli.CLI.main( - ["issue", "comment", "CRY-1", "--body-file", "/nonexistent/path/does-not-exist"], - halt - ) - end) - - assert_received {:halted, _code} - end - - test "--output json prints the resulting comment as JSON" do - stub_lookup_and([{"commentCreate", comment_created()}]) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main(["issue", "comment", "CRY-1", "-m", "lgtm", "-o", "json"]) - end) - - assert %{"id" => "c1"} = Jason.decode!(output) - end - - test "multiple ISSUE_IDs each receive the comment" do - test_pid = self() - - stub_lookup_and([ - {"commentCreate", - fn _decoded -> - send(test_pid, :comment_created) - comment_created() - end} - ]) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main(["issue", "comment", "CRY-1", "CRY-2", "-m", "lgtm"]) - end) - - assert output =~ "Comment added to" - assert_received :comment_created - assert_received :comment_created - end - - test "no ISSUE_IDs is a smells_bad error" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "comment", "-m", "lgtm"], halt) - end) - - assert_received {:halted, 22} - assert output =~ "No issue IDs provided!" - end - end - describe "issue relation list" do defp relation_node(id, type, src_ident, rel_ident) do %{ diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index 9117ddf..edcf099 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -8,6 +8,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do import ExUnit.CaptureIO alias LinearCli.CLI.Commands + alias LinearCli.CLI.Commands.Issues.{Development, Mutations, Read} alias LinearCli.CLI.Issue.Creation alias LinearCli.Linear.User alias LinearCli.Profiles @@ -174,7 +175,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do unknown: [] } - output = capture_io(fn -> assert :ok = Commands.issue_list(result) end) + output = capture_io(fn -> assert :ok = Read.issue_list(result) end) assert output =~ "CRY-1" assert_received {:filter, filter} @@ -215,7 +216,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do unknown: [] } - capture_io(fn -> assert :ok = Commands.issue_list(result) end) + capture_io(fn -> assert :ok = Read.issue_list(result) end) assert_received {:filter, filter} assert filter["team"] == %{"key" => %{"eq" => "ENG"}} @@ -251,7 +252,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do unknown: [] } - output = capture_io(fn -> assert :ok = Commands.issue_list(result) end) + output = capture_io(fn -> assert :ok = Read.issue_list(result) end) assert output =~ "CRY-1" assert_received {:filter, filter} @@ -284,7 +285,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do unknown: [] } - capture_io(fn -> assert :ok = Commands.issue_list(result) end) + capture_io(fn -> assert :ok = Read.issue_list(result) end) assert_received {:filter, filter} assert filter["team"] == %{"key" => %{"eq" => "ENG"}} @@ -321,7 +322,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do unknown: [] } - capture_io(fn -> assert :ok = Commands.issue_list(result) end) + capture_io(fn -> assert :ok = Read.issue_list(result) end) assert_received {:filter, filter} refute Map.has_key?(filter, "team") @@ -353,7 +354,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do unknown: ["42"] } - output = capture_io(fn -> assert :ok = Commands.issue_list(result) end) + output = capture_io(fn -> assert :ok = Read.issue_list(result) end) assert output =~ "CRY-1" assert_received {:id, "CRY-42"} @@ -391,7 +392,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do flags: %{cancel: false, close: false, trash: false} } - output = capture_io(fn -> assert :ok = Commands.issue_update(result) end) + output = capture_io(fn -> assert :ok = Mutations.issue_update(result) end) assert output =~ "Comment added to CRY-1" assert_received {:id, "CRY-42"} @@ -432,7 +433,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do output = capture_io(fn -> - assert :ok = Commands.issue_develop(result, cwd: repo, me: me) + assert :ok = Development.issue_develop(result, cwd: repo, me: me) end) assert output =~ "Checked out branch main" @@ -473,7 +474,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do output = capture_io(fn -> assert :ok = - Commands.issue_pr(result, + Development.issue_pr(result, cwd: repo, me: me, runner: fn _title, _body -> "https://github.com/x/y/pull/1" end @@ -515,7 +516,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do output = capture_io(fn -> - assert :ok = Commands.issue_take(result, me: me) + assert :ok = Development.issue_take(result, me: me) end) assert output =~ "Assigning issue CRY-42 to ya" diff --git a/app/test/support/issue_commands_helpers.ex b/app/test/support/issue_commands_helpers.ex new file mode 100644 index 0000000..e22cdcf --- /dev/null +++ b/app/test/support/issue_commands_helpers.ex @@ -0,0 +1,155 @@ +defmodule LinearCli.CLI.IssueCommandsHelpers do + @moduledoc false + + # Dispatches to one of `pairs` ({substring, response_map}) based on which + # substring appears in the outgoing GraphQL document. + def stub_responses(pairs) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + case Enum.find(pairs, fn {match, _resp} -> String.contains?(query, match) end) do + {_match, response} -> Req.Test.json(conn, response) + nil -> raise "no stub matched query: #{query}" + end + end) + end + + def team_map, do: %{"id" => "t1", "key" => "ENG", "name" => "Engineering"} + + def me_map(overrides \\ %{}) do + Map.merge( + %{"id" => "u1", "name" => "Ada", "email" => "ada@x.com", "teams" => %{"nodes" => []}}, + overrides + ) + end + + def label_response(names) do + %{ + "data" => %{ + "issueLabels" => %{ + "edges" => + Enum.map(names, fn name -> + %{ + "node" => %{ + "id" => "l-#{name}", + "name" => name, + "description" => nil, + "isGroup" => false + } + } + end) + } + } + } + end + + def project_map(id, name) do + %{ + "id" => id, + "name" => name, + "content" => nil, + "slugId" => "abc", + "description" => nil, + "url" => "https://linear.app/x/project/#{id}" + } + end + + def team_projects(projects), + do: %{"data" => %{"team" => %{"projects" => %{"nodes" => projects}}}} + + def issue_map(overrides \\ %{}) do + Map.merge( + %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => "It is broken", + "assignee" => nil, + "state" => %{"id" => "s1", "name" => "In Progress", "type" => "started"}, + "team" => team_map(), + "comments" => %{"nodes" => []} + }, + overrides + ) + end + + def issue_updated(overrides \\ %{}) do + %{"data" => %{"issueUpdate" => %{"issue" => issue_map(overrides)}}} + end + + def comment_created do + %{ + "data" => %{"commentCreate" => %{"comment" => %{"id" => "c1", "body" => "x", "url" => "u"}}} + } + end + + def workflow_states(states) do + %{"data" => %{"team" => %{"states" => %{"nodes" => states}}}} + end + + # Workspace-wide (not team-scoped) projects query shape. + def all_projects(projects) do + %{ + "data" => %{ + "projects" => %{ + "edges" => Enum.map(projects, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => false} + } + } + } + end + + def issues_response(issues) do + %{ + "data" => %{ + "issues" => %{ + "edges" => Enum.map(issues, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => false} + } + } + } + end + + def tmp_path(prefix) do + Path.join( + System.tmp_dir!(), + "linear_cli_issue_commands_test_#{prefix}_#{System.unique_integer([:positive, :monotonic])}" + ) + end + + # Every git-touching test gets a fresh local repo (one commit on "main", + # already pushed to/tracking a fresh bare "origin") under + # `System.tmp_dir!()` - never the real project working directory. See house + # rule 6 and `LinearCli.GitTest`'s own identical setup. + def git_repo! do + origin_path = tmp_dir!("origin") + {_output, 0} = System.cmd("git", ["init", "--bare", "-q"], cd: origin_path) + + repo_path = tmp_dir!("repo") + {_output, 0} = System.cmd("git", ["init", "-q"], cd: repo_path) + {_output, 0} = System.cmd("git", ["config", "user.name", "Test User"], cd: repo_path) + {_output, 0} = System.cmd("git", ["config", "user.email", "test@example.com"], cd: repo_path) + File.write!(Path.join(repo_path, "README.md"), "hello") + {_output, 0} = System.cmd("git", ["add", "README.md"], cd: repo_path) + {_output, 0} = System.cmd("git", ["commit", "-q", "-m", "init"], cd: repo_path) + {_output, 0} = System.cmd("git", ["branch", "-M", "main"], cd: repo_path) + {_output, 0} = System.cmd("git", ["remote", "add", "origin", origin_path], cd: repo_path) + {_output, 0} = System.cmd("git", ["push", "-q", "-u", "origin", "main"], cd: repo_path) + + repo_path + end + + # `System.unique_integer/1` resets across BEAM VM restarts, so an interrupted + # prior run can reuse a stale /tmp directory. A cryptographic nonce avoids + # collisions across processes; `on_exit` is registered before any git command + # so a setup failure still cleans up. + def tmp_dir!(prefix) do + nonce = :crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false) + path = Path.join(System.tmp_dir!(), "linear_cli_issue_commands_test_#{prefix}_#{nonce}") + File.mkdir!(path) + ExUnit.Callbacks.on_exit(fn -> File.rm_rf!(path) end) + path + end +end From 4ad6468bd1a121f0bae5b61f766d44b8a85c271f Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 09:18:48 -0400 Subject: [PATCH 2/5] refactor(cli): remove unused Commands alias from profile_defaults_test Co-Authored-By: Claude Sonnet 4.6 --- app/test/linear_cli/cli/profile_defaults_test.exs | 1 - 1 file changed, 1 deletion(-) diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index edcf099..ad46548 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -7,7 +7,6 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do use ExUnit.Case, async: false import ExUnit.CaptureIO - alias LinearCli.CLI.Commands alias LinearCli.CLI.Commands.Issues.{Development, Mutations, Read} alias LinearCli.CLI.Issue.Creation alias LinearCli.Linear.User From d9ea9f02b16a27884cc60378a513d173aaaf0967 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 09:19:48 -0400 Subject: [PATCH 3/5] refactor(cli): hoist state_map/4 out of describe block in mutations_test state_map/4 is used by both "issue status" and "issue take with --status" describe blocks, so it must live at module level. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/commands/issues/mutations_test.exs | 326 +++++++++++++++++- 1 file changed, 322 insertions(+), 4 deletions(-) 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 7ced5ef..de4574f 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -5,11 +5,12 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do alias LinearCli.CLI.Commands.Issues.Mutations - describe "issue status" do - defp state_map(id, name, position, type) do - %{"id" => id, "name" => name, "position" => position, "type" => type, "description" => nil} - end + # Shared across "issue status" and "issue take with --status" describe blocks + defp state_map(id, name, position, type) do + %{"id" => id, "name" => name, "position" => position, "type" => type, "description" => nil} + end + describe "issue status" do defp issue_with_state(state_id, state_name) do issue_map(%{"state" => %{"id" => state_id, "name" => state_name, "type" => "started"}}) end @@ -1389,6 +1390,323 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end end + describe "issue take with --status" do + defp take_member_map, do: %{"id" => "u1", "name" => "Ada", "email" => "ada@x.com"} + + defp take_issue_map(overrides \\ %{}) do + Map.merge( + %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => nil, + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + }, + overrides + ) + end + + test "--status sends both assigneeId and stateId in one issueUpdate" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "states {" -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "Todo", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["assigneeId"] == "u1" + assert input["stateId"] == "s1" + end + + test "-s short form works on take" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "states {" -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "-s", "Todo", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s1" + end + + test "already-self-assigned issue still updates status when --status given" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => take_issue_map(%{"assignee" => take_member_map()}) + } + } + ) + + query =~ "states {" -> + Req.Test.json(conn, workflow_states([state_map("s2", "In Progress", 1.0, "started")])) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "take", "--status", "In Progress", "CRY-1"]) + end) + + assert_received {:input, input} + assert input["assigneeId"] == "u1" + assert input["stateId"] == "s2" + end + + test "--status unknown name exits 22 before any mutation on take" 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 + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "states {" -> + Req.Test.json(conn, workflow_states([state_map("s1", "Todo", 0.0, "unstarted")])) + + query =~ "issueUpdate" -> + send(test_pid, :mutated) + raise "issueUpdate should not be called" + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "take", "--status", "Bogus", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + refute_received :mutated + assert stderr =~ "Unknown status" + end + + test "omitting --status sends only assigneeId on take (backward compat)" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "CRY-1"]) + end) + + assert_received {:input, input} + assert input == %{"assigneeId" => "u1"} + refute Map.has_key?(input, "stateId") + end + + test "multiple issues from different teams resolve status independently" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + variables = decoded["variables"] || %{} + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" and variables["id"] == "CRY-1" -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => + take_issue_map(%{ + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"} + }) + } + } + ) + + query =~ "issue(id: $id)" and variables["id"] == "CRY-2" -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => + take_issue_map(%{ + "id" => "i2", + "identifier" => "CRY-2", + "team" => %{"id" => "t2", "key" => "OPS", "name" => "Operations"} + }) + } + } + ) + + query =~ "states {" and variables["teamId"] == "t1" -> + Req.Test.json( + conn, + workflow_states([state_map("s-eng-todo", "Todo", 0.0, "unstarted")]) + ) + + query =~ "states {" and variables["teamId"] == "t2" -> + Req.Test.json( + conn, + workflow_states([state_map("s-ops-todo", "Todo", 0.0, "unstarted")]) + ) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "Todo", "CRY-1", "CRY-2"]) + end) + + assert_received {:input, input1} + assert_received {:input, input2} + + state_ids = MapSet.new([input1["stateId"], input2["stateId"]]) + assert MapSet.member?(state_ids, "s-eng-todo") + assert MapSet.member?(state_ids, "s-ops-todo") + end + + test "--status with space in name works on take" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + cond do + query =~ "viewer" -> + Req.Test.json(conn, %{"data" => %{"viewer" => take_member_map()}}) + + query =~ "issue(id: $id)" -> + Req.Test.json(conn, %{"data" => %{"issue" => take_issue_map()}}) + + query =~ "states {" -> + Req.Test.json( + conn, + workflow_states([state_map("s-ip", "In Progress", 1.0, "started")]) + ) + + query =~ "issueUpdate" -> + send(test_pid, {:input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => take_member_map()})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "take", "--status", "In Progress", "CRY-53"]) + end) + + assert_received {:input, input} + assert input["stateId"] == "s-ip" + end + end + describe "issue comment" do defp stub_lookup_and(pairs) do Req.Test.stub(LinearCli.Api, fn conn -> From c1ad5e3a3a0058abfb5e49f9c057ef1f647a9ebc Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 09:40:47 -0400 Subject: [PATCH 4/5] docs(cli): update stale module references after issue command extraction profiles.ex moduledoc and profile_defaults_test describe strings still referenced LinearCli.CLI.Commands.issue_list/1, issue_update/1, and issue_develop/2 after those functions were extracted into the Issues.* modules in the previous commit. --- app/lib/linear_cli/profiles.ex | 2 +- app/test/linear_cli/cli/profile_defaults_test.exs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/lib/linear_cli/profiles.ex b/app/lib/linear_cli/profiles.ex index 266a3a0..b88c3d7 100644 --- a/app/lib/linear_cli/profiles.ex +++ b/app/lib/linear_cli/profiles.ex @@ -6,7 +6,7 @@ defmodule LinearCli.Profiles do time - enforced by a partial unique index (`active_idx`), not application-level bookkeeping. `default_team/0`/`default_project/0` are what `LinearCli.CLI.Issue.Creation.make_da_issue!/1` and - `LinearCli.CLI.Commands.issue_list/1` fall back to when `--team`/ + `LinearCli.CLI.Commands.Issues.Read.issue_list/1` fall back to when `--team`/ `--project` are omitted - see `documents/phase-9-plan.adoc`. New in this port - Ruby has no equivalent. Uses `Exqlite.Sqlite3` diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index ad46548..c90ea38 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -140,7 +140,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do path end - describe "Commands.issue_list/1 falls back to the active profile" do + describe "Read.issue_list/1 falls back to the active profile" do test "uses the active profile's team/project when both flags are omitted" do {:ok, _} = Profiles.create("manhattan", team: "CRY", project: "Manhattan Rollout") :ok = Profiles.activate("manhattan") @@ -360,7 +360,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do end end - describe "Commands.issue_update/1 resolves bare issue numbers via the active profile" do + describe "Mutations.issue_update/1 resolves bare issue numbers via the active profile" do test "expands a bare positional id before looking it up" do {:ok, _} = Profiles.create("manhattan", team: "CRY") :ok = Profiles.activate("manhattan") @@ -398,7 +398,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do end end - describe "Commands.issue_develop/2, issue_pr/2, issue_take/2 resolve bare issue numbers via the active profile" do + describe "Development.issue_develop/2, issue_pr/2, issue_take/2 resolve bare issue numbers via the active profile" do test "issue_develop/2 expands the bare issue_id before self-assigning/checking it out" do {:ok, _} = Profiles.create("manhattan", team: "CRY") :ok = Profiles.activate("manhattan") From 2bdc4ec96735d2c560233c3b752db7a6ce9f873b Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 11:44:17 -0400 Subject: [PATCH 5/5] fix(test): make CLITest async: false to stop stderr capture leakage capture_io(:stderr, ...) redirects the global :standard_error device. With async: true, output from concurrent test modules' CLI invocations leaked into CLITest's capture windows, causing intermittent failures in the refute output =~ "What the heck is this?" assertion (line 559), and the HTTP 401 error from CLITest:402 leaked into issue_commands_test:1571. Fixes both flakes by making CLITest async: false (same pattern as missing_api_key_test.exs). Also adds profiles-db cleanup to setup so a stale active team from ProfilesTest/ProfileDefaultsTest doesn't cause project_update to call team(id: $id) instead of the viewer-based team resolution the stub expects. --- app/test/linear_cli/cli_test.exs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index 71de1f9..cd6a0e5 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -1,9 +1,19 @@ defmodule LinearCli.CLITest do - use ExUnit.Case, async: true + # async: false - capture_io(:stderr, ...) redirects the global :standard_error + # device. Multiple async modules capturing stderr simultaneously causes output + # from one test's CLI invocation to leak into another's capture window, breaking + # the refute output =~ "What the heck is this?" assertions. Same pattern as + # missing_api_key_test.exs (shared global state → async: false). + use ExUnit.Case, async: false import ExUnit.CaptureIO import ExUnit.CaptureLog setup do + # Clear any profile left by a prior async: false module (ProfilesTest, + # ProfileDefaultsTest) so Profiles.default_team/0 returns nil and the + # project-update test doesn't pick up a stale active team. + Application.fetch_env!(:linear_cli, :profiles_db_path) |> File.rm() + Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) %{"query" => query} = Jason.decode!(body)