diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 0290e77..15a197c 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -738,7 +738,8 @@ defmodule LinearCli.CLI do title: [short: "-t", long: "--title", help: "Issue Title"] ], flags: [ - develop: [long: "--dev", help: "Start development after creating the issue"] + develop: [long: "--dev", help: "Start development after creating the issue"], + yes: [short: "-y", long: "--yes", help: "Skip all interactive prompts"] ] ], develop: [ diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index ce6856b..44873f1 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -359,16 +359,24 @@ defmodule LinearCli.CLI.Commands do description: description, team: options.team, labels: options.labels, - project: options.project + project: options.project, + yes: flags.yes ], {:ok, issue} <- IssueHelpers.make_da_issue!(create_opts), - :ok <- maybe_take(issue, opts) do + :ok <- maybe_take(issue, flags.yes, opts) do Display.show(issue, %{output: options.output}) if flags.develop, do: run_develop(issue.id, opts), else: :ok end end - defp maybe_take(issue, opts) do + defp maybe_take(issue, true, opts) do + case IssueHelpers.gimme_da_issue!(issue.id, opts) do + {:ok, _updated} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp maybe_take(issue, _yes, opts) do if Prompt.yes?("Do you want to take this issue?") do case IssueHelpers.gimme_da_issue!(issue.id, opts) do {:ok, _updated} -> :ok diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index e93dcca..7ef27b5 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -416,6 +416,10 @@ defmodule LinearCli.CLI.IssueHelpers do """ @spec make_da_issue!(keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} def make_da_issue!(opts \\ []) do + if opts[:yes], do: make_da_issue_no_prompts!(opts), else: make_da_issue_interactive!(opts) + end + + defp make_da_issue_interactive!(opts) do title = WhatFor.title_for(opts[:title]) description = WhatFor.description_for(opts[:description]) team = WhatFor.team_for(opts[:team] || Profiles.default_team()) @@ -431,6 +435,60 @@ defmodule LinearCli.CLI.IssueHelpers do end end + defp make_da_issue_no_prompts!(opts) do + with {:ok, title} <- require_field(opts[:title], "--title"), + {:ok, description} <- require_field(opts[:description], "--description"), + {:ok, team} <- resolve_team_strict(opts[:team] || Profiles.default_team()) do + labels = + case opts[:labels] do + nil -> [] + [] -> [] + labels -> WhatFor.labels_for(team, labels) + end + + project_search = opts[:project] || Profiles.default_project() + + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: project_search}) do + project = + if project_search, + do: Projects.project_for_strict(projects, project_search), + else: nil + + label_ids = Enum.map(labels, & &1.id) + params = maybe_put_project_id(%{label_ids: label_ids}, project) + Linear.create_issue(title, description, team.id, params) + end + end + end + + defp require_field(nil, flag), + do: {:error, {:smells_bad, "#{flag} is required with --yes"}} + + defp require_field(value, _flag), do: {:ok, value} + + defp resolve_team_strict(nil) do + case Linear.my_teams() do + {:ok, [team]} -> + {:ok, team} + + {:ok, []} -> + {:error, {:smells_bad, "--team is required (you belong to no teams)"}} + + {:ok, _teams} -> + {:error, {:smells_bad, "--team is required when you belong to multiple teams"}} + + {:error, reason} -> + {:error, {:smells_bad, "Could not fetch teams: #{inspect(reason)}"}} + end + end + + defp resolve_team_strict(key) do + case Linear.find_team(key) do + {:ok, team} -> {:ok, team} + {:error, _reason} -> {:error, {:smells_bad, "--team #{inspect(key)} not found"}} + end + end + defp maybe_put_project_id(params, nil), do: params defp maybe_put_project_id(params, project), do: Map.put(params, :project_id, project.id) diff --git a/app/lib/linear_cli/cli/projects.ex b/app/lib/linear_cli/cli/projects.ex index df9c06e..b13bffd 100644 --- a/app/lib/linear_cli/cli/projects.ex +++ b/app/lib/linear_cli/cli/projects.ex @@ -55,6 +55,20 @@ defmodule LinearCli.CLI.Projects do end end + @doc """ + Like `project_for/2` but never prompts: returns the project only when there + is an exact match (score 100), `nil` otherwise. Used by `--yes` flows where + interactive prompts are disabled. + """ + def project_for_strict(projects, search \\ nil) + + def project_for_strict([], _search), do: nil + def project_for_strict(_projects, nil), do: nil + + def project_for_strict(projects, search) do + Enum.find(project_scores(projects, search), &(Project.match_score?(&1, search) == 100)) + end + @doc """ Ported from Ruby's `CLI::Projects#project_scores`. The subset of `projects` with a positive `Project.match_score?/2` against diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 80d9b3f..5cc5a32 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -750,7 +750,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do project: "Manhattan Rollout", output: "text" }, - flags: %{develop: true} + flags: %{develop: true, yes: false} } output = @@ -882,7 +882,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do project: nil, output: "text" }, - flags: %{develop: false} + flags: %{develop: false, yes: false} } capture_io("piped from stdin\nwith a real newline", fn -> @@ -945,6 +945,192 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert_received {:halted, _code} end + + test "-y/--yes with all required flags creates and self-assigns without any prompts" do + 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()}}}, + {"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