From f9ff91e6dccb1393679b7230e15752bd710e53a9 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 21 Sep 2026 10:06:31 -0500 Subject: [PATCH 1/3] fix(test): eliminate async stderr capture race --- app/lib/linear_cli/cli.ex | 261 ++++++++++-------- .../cli/commands/issues/relations.ex | 60 ++-- .../cli/commands/issues/create_test.exs | 30 +- .../cli/commands/issues/graph_test.exs | 5 +- .../cli/commands/issues/move_test.exs | 34 ++- .../cli/commands/issues/mutations_test.exs | 82 ++++-- .../cli/commands/issues/relations_test.exs | 64 +++-- app/test/support/issue_commands_helpers.ex | 14 + 8 files changed, 337 insertions(+), 213 deletions(-) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index b18e63e..b4a5c38 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -13,13 +13,15 @@ defmodule LinearCli.CLI do @workflow_state_types ~w(triage backlog unstarted started completed canceled duplicate) - def main(argv, halt \\ &System.halt/1) do + def main(argv, halt \\ &System.halt/1, opts \\ []) do + context = %{halt: halt, stderr: Keyword.get(opts, :stderr, :stderr)} + argv = argv |> normalize_aliases() |> normalize_subcommand_aliases() |> normalize_help() - |> default_to_issue_list() + |> default_to_issue_list(context.stderr) # Optimus.parse!/3 returns *either* {subcommand_path, parse_result} # (a subcommand matched) *or* a bare %Optimus.ParseResult{} (nothing @@ -36,7 +38,7 @@ defmodule LinearCli.CLI do end try do - dispatch(subcommand_path, parse_result, halt) + dispatch(subcommand_path, parse_result, context) rescue # Ported from CLI::Caller#call's catch-all `rescue StandardError` # clause - Ruby's rescue catches any unexpected raised exception, not @@ -52,7 +54,7 @@ defmodule LinearCli.CLI do # visible in that same try's `rescue`, but outer-scope bindings are). exception -> debug = is_struct(parse_result, Optimus.ParseResult) && parse_result.options[:debug] - handle_error(exception, debug, halt) + handle_error(exception, debug, context) end end @@ -160,13 +162,15 @@ defmodule LinearCli.CLI do # (including its stderr text) - a bare `lc` invocation defaults to # `issue list` rather than dumping top-level help. @doc false - def default_to_issue_list([]) do - IO.puts(:stderr, "No subcommand provided, defaulting to 'lc issue list'") - IO.puts(:stderr, "lc --help to see subcommands") + def default_to_issue_list(argv, stderr \\ :stderr) + + def default_to_issue_list([], stderr) do + IO.puts(stderr, "No subcommand provided, defaulting to 'lc issue list'") + IO.puts(stderr, "lc --help to see subcommands") ["issue", "list"] end - def default_to_issue_list(argv), do: argv + def default_to_issue_list(argv, _stderr), do: argv # Optimus only special-cases bare top-level `--help` and the `help ` # form - `issue list --help` isn't recognized, and since `issue list` allows @@ -194,66 +198,93 @@ defmodule LinearCli.CLI do end end - defp dispatch([:whoami], result, halt), do: run(&SystemCmds.whoami/1, result, halt) - defp dispatch([:version], result, halt), do: run(&SystemCmds.version/1, result, halt) - defp dispatch([:team, :list], result, halt), do: run(&Teams.team_list/1, result, halt) + defp dispatch([:whoami], result, context), do: run(&SystemCmds.whoami/1, result, context) + defp dispatch([:version], result, context), do: run(&SystemCmds.version/1, result, context) + defp dispatch([:team, :list], result, context), do: run(&Teams.team_list/1, result, context) + + defp dispatch([:team, :favorite], result, context), + do: run(&Teams.team_favorite/1, result, context) + + defp dispatch([:team, :unfavorite], result, context), + do: run(&Teams.team_unfavorite/1, result, context) + + defp dispatch([:project, :list], result, context), + do: run(&Projects.project_list/1, result, context) + + defp dispatch([:project, :favorite], result, context), + do: run(&Projects.project_favorite/1, result, context) + + defp dispatch([:project, :unfavorite], result, context), + do: run(&Projects.project_unfavorite/1, result, context) + + defp dispatch([:project, :update], result, context), + do: run(&Projects.project_update/1, result, context) - defp dispatch([:team, :favorite], result, halt), - do: run(&Teams.team_favorite/1, result, halt) + defp dispatch([:profile, :create], result, context), + do: run(&Profiles.profile_create/1, result, context) - defp dispatch([:team, :unfavorite], result, halt), - do: run(&Teams.team_unfavorite/1, result, halt) + defp dispatch([:profile, :list], result, context), + do: run(&Profiles.profile_list/1, result, context) - defp dispatch([:project, :list], result, halt), do: run(&Projects.project_list/1, result, halt) + defp dispatch([:profile, :use], result, context), + do: run(&Profiles.profile_use/1, result, context) - defp dispatch([:project, :favorite], result, halt), - do: run(&Projects.project_favorite/1, result, halt) + defp dispatch([:profile, :show], result, context), + do: run(&Profiles.profile_show/1, result, context) - defp dispatch([:project, :unfavorite], result, halt), - do: run(&Projects.project_unfavorite/1, result, halt) + defp dispatch([:profile, :delete], result, context), + do: run(&Profiles.profile_delete/1, result, context) - defp dispatch([:project, :update], result, halt), - do: run(&Projects.project_update/1, result, halt) + defp dispatch([:profile, :clear], result, context), + do: run(&Profiles.profile_clear/1, result, context) - defp dispatch([:profile, :create], result, halt), - do: run(&Profiles.profile_create/1, result, halt) + defp dispatch([:issue, :list], result, context), do: run(&Read.issue_list/1, result, context) + defp dispatch([:issue, :view], result, context), do: run(&Read.issue_view/1, result, context) - defp dispatch([:profile, :list], result, halt), do: run(&Profiles.profile_list/1, result, halt) - defp dispatch([:profile, :use], result, halt), do: run(&Profiles.profile_use/1, result, halt) - defp dispatch([:profile, :show], result, halt), do: run(&Profiles.profile_show/1, result, halt) + defp dispatch([:issue, :assign], result, context), + do: run(&Mutations.issue_assign/1, result, context) - defp dispatch([:profile, :delete], result, halt), - do: run(&Profiles.profile_delete/1, result, halt) + defp dispatch([:issue, :create], result, context), + do: run(&Create.issue_create/1, result, context) - defp dispatch([:profile, :clear], result, halt), - do: run(&Profiles.profile_clear/1, result, halt) + defp dispatch([:issue, :develop], result, context), + do: run(&Development.issue_develop/1, result, context) - 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, :pr], result, context), + do: run(&Development.issue_pr/1, result, context) - defp dispatch([:issue, :develop], result, halt), - do: run(&Development.issue_develop/1, result, halt) + defp dispatch([:issue, :move], result, context), do: run(&Move.issue_move/1, result, context) - defp dispatch([:issue, :pr], result, halt), do: run(&Development.issue_pr/1, result, halt) - defp dispatch([:issue, :move], result, halt), do: run(&Move.issue_move/1, result, halt) + defp dispatch([:issue, :comment], result, context), + do: run(&Mutations.issue_comment/1, result, context) - defp dispatch([:issue, :comment], result, halt), - do: run(&Mutations.issue_comment/1, result, halt) + defp dispatch([:issue, :take], result, context), + do: run(&Development.issue_take/1, result, context) - 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, :status], result, context), + do: run(&Mutations.issue_status/1, result, context) - defp dispatch([:issue, :relation, :list], result, halt), - do: run(&Relations.issue_relation_list/1, result, halt) + defp dispatch([:issue, :update], result, context), + do: run(&Mutations.issue_update/1, result, context) - defp dispatch([:issue, :relation, :add], result, halt), - do: run(&Relations.issue_relation_add/1, result, halt) + defp dispatch([:issue, :relation, :list], result, context), + do: run(&Relations.issue_relation_list/1, result, context) - defp dispatch([:issue, :relation, :remove], result, halt), - do: run(&Relations.issue_relation_remove/1, result, halt) + defp dispatch([:issue, :relation, :add], result, context) do + run( + &Relations.issue_relation_add(&1, stderr: context.stderr), + result, + context + ) + end + + defp dispatch([:issue, :relation, :remove], result, context) do + run( + &Relations.issue_relation_remove(&1, stderr: context.stderr), + result, + context + ) + end # A valid subcommand path that stops short of a leaf (e.g. `lc project` # with nothing after it) - Optimus itself doesn't require reaching a leaf, @@ -261,9 +292,9 @@ defmodule LinearCli.CLI do # raise a bare FunctionClauseError. Show that path's own help instead, # exactly as `lc help project` would, and exit 1 (a usage error, not a # program bug - distinct from the crash-safety-net catch-all in main/1). - defp dispatch(subcommand_path, _result, halt) do + defp dispatch(subcommand_path, _result, context) do spec() |> Optimus.Help.help(subcommand_path, columns()) |> Enum.each(&IO.puts/1) - halt.(1) + context.halt.(1) end # Mirrors Optimus's own private columns/0 (vendor/optimus/lib/optimus.ex) - @@ -275,16 +306,16 @@ defmodule LinearCli.CLI do end end - defp run(fun, result, halt) do + defp run(fun, result, context) do case reject_unknown_flags(result.unknown) do :ok -> case fun.(result) do :ok -> :ok - {:error, error} -> handle_error(error, result.options[:debug], halt) + {:error, error} -> handle_error(error, result.options[:debug], context) end {:error, error} -> - handle_error(error, result.options[:debug], halt) + handle_error(error, result.options[:debug], context) end end @@ -311,11 +342,15 @@ defmodule LinearCli.CLI do end # Ported from CLI::Caller#call's `rescue NotFoundError` clause. - defp handle_error(%Ash.Error.Unknown{errors: [%{value: [{:not_found, id}]} | _]}, debug, halt) do - IO.puts(:stderr, "No issue found with id #{id}") - IO.puts(:stderr, "** Record not found, Cannot Continue **") - maybe_print_backtrace(debug) - halt.(66) + defp handle_error( + %Ash.Error.Unknown{errors: [%{value: [{:not_found, id}]} | _]}, + debug, + context + ) do + IO.puts(context.stderr, "No issue found with id #{id}") + IO.puts(context.stderr, "** Record not found, Cannot Continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(66) end # Same NotFoundError intent as the clause above, but for Ash's own built-in @@ -328,13 +363,13 @@ defmodule LinearCli.CLI do defp handle_error( %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{resource: resource} | _]}, debug, - halt + context ) do name = resource |> Module.split() |> List.last() |> String.downcase() - IO.puts(:stderr, "No such #{name} found") - IO.puts(:stderr, "** Record not found, Cannot Continue **") - maybe_print_backtrace(debug) - halt.(66) + IO.puts(context.stderr, "No such #{name} found") + IO.puts(context.stderr, "** Record not found, Cannot Continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(66) end # LinearCli.Api.call/2's {:error, :missing_api_key} (LINEAR_API_KEY not @@ -354,28 +389,28 @@ defmodule LinearCli.CLI do errors: [%Ash.Error.Unknown.UnknownError{error: "unknown error: :missing_api_key"} | _] }, debug, - halt + context ) do - IO.puts(:stderr, "LINEAR_API_KEY is not set.") + IO.puts(context.stderr, "LINEAR_API_KEY is not set.") IO.puts( - :stderr, + context.stderr, "Set it to your Linear API key - see https://linear.app/settings/account/security" ) - IO.puts(:stderr, "** Missing configuration, cannot continue **") - maybe_print_backtrace(debug) - halt.(78) + IO.puts(context.stderr, "** Missing configuration, cannot continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(78) end # Ported from CLI::Caller#call's `rescue SmellsBad` clause. See # `LinearCli.CLI.Issue.Actions`'s moduledoc for where this tagged tuple # comes from. - defp handle_error({:smells_bad, message}, debug, halt) do - IO.puts(:stderr, message) - IO.puts(:stderr, "** This smells bad! Bailing. **") - maybe_print_backtrace(debug) - halt.(22) + defp handle_error({:smells_bad, message}, debug, context) do + IO.puts(context.stderr, message) + IO.puts(context.stderr, "** This smells bad! Bailing. **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(22) end # Safety net for any LinearCli.Api.call/2 site whose {:error, {:graphql_errors, @@ -389,12 +424,12 @@ defmodule LinearCli.CLI do errors: [%{value: [{:graphql_errors, [%{"message" => message} | _]}]} | _] }, debug, - halt + context ) do - IO.puts(:stderr, "Linear API error: #{message}") - IO.puts(:stderr, "** API Error, Cannot Continue **") - maybe_print_backtrace(debug) - halt.(88) + IO.puts(context.stderr, "Linear API error: #{message}") + IO.puts(context.stderr, "** API Error, Cannot Continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(88) end # LinearCli.Api.call/2's {:error, {:http_error, status, body}} for 401/403 - @@ -404,14 +439,14 @@ defmodule LinearCli.CLI do defp handle_error( %Ash.Error.Unknown{errors: [%{value: [{:http_error, status}]} | _]}, debug, - halt + context ) when status in [401, 403] do - IO.puts(:stderr, "Linear API authentication failed (HTTP #{status}).") - IO.puts(:stderr, "Check that LINEAR_API_KEY is valid.") - IO.puts(:stderr, "** Authentication error, cannot continue **") - maybe_print_backtrace(debug) - halt.(77) + IO.puts(context.stderr, "Linear API authentication failed (HTTP #{status}).") + IO.puts(context.stderr, "Check that LINEAR_API_KEY is valid.") + IO.puts(context.stderr, "** Authentication error, cannot continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(77) end # LinearCli.Api.call/2's {:error, {:http_error, status, body}} for any other @@ -419,12 +454,12 @@ defmodule LinearCli.CLI do defp handle_error( %Ash.Error.Unknown{errors: [%{value: [{:http_error, status}]} | _]}, debug, - halt + context ) do - IO.puts(:stderr, "Linear API returned HTTP #{status}.") - IO.puts(:stderr, "** API Error, Cannot Continue **") - maybe_print_backtrace(debug) - halt.(88) + IO.puts(context.stderr, "Linear API returned HTTP #{status}.") + IO.puts(context.stderr, "** API Error, Cannot Continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(88) end # LinearCli.Api.call/2's {:error, {:transport_error, exception}} - DNS failure, @@ -432,12 +467,12 @@ defmodule LinearCli.CLI do defp handle_error( %Ash.Error.Unknown{errors: [%{value: [{:transport_error, _exception}]} | _]}, debug, - halt + context ) do - IO.puts(:stderr, "Could not reach the Linear API.") - IO.puts(:stderr, "** Network error, cannot continue **") - maybe_print_backtrace(debug) - halt.(69) + IO.puts(context.stderr, "Could not reach the Linear API.") + IO.puts(context.stderr, "** Network error, cannot continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(69) end # LinearCli.Api.call/2's {:error, {:unexpected_response, body}} - a 200 with @@ -446,35 +481,35 @@ defmodule LinearCli.CLI do defp handle_error( %Ash.Error.Unknown{errors: [%{value: [{:unexpected_response, _body}]} | _]}, debug, - halt + context ) do - IO.puts(:stderr, "Linear API returned an unexpected response.") - IO.puts(:stderr, "** API Error, Cannot Continue **") - maybe_print_backtrace(debug) - halt.(88) + IO.puts(context.stderr, "Linear API returned an unexpected response.") + IO.puts(context.stderr, "** API Error, Cannot Continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(88) end # Graph.build/2 returns {:error, {issue_id, reason}} to identify which # issue's relations could not be fetched. Prefix context and re-dispatch # so the underlying reason uses its own handler. - defp handle_error({issue_id, reason}, debug, halt) when is_binary(issue_id) do - IO.puts(:stderr, "could not fetch relations for #{issue_id}:") - handle_error(reason, debug, halt) + defp handle_error({issue_id, reason}, debug, context) when is_binary(issue_id) do + IO.puts(context.stderr, "could not fetch relations for #{issue_id}:") + handle_error(reason, debug, context) end # Ported from CLI::Caller#call's catch-all `rescue StandardError` clause. - defp handle_error(error, debug, halt) do - IO.puts(:stderr, "What the heck is this? #{Exception.format_banner(:error, error)}") - IO.puts(:stderr, "** WTH? Cannot Continue **") - maybe_print_backtrace(debug) - halt.(88) + defp handle_error(error, debug, context) do + IO.puts(context.stderr, "What the heck is this? #{Exception.format_banner(:error, error)}") + IO.puts(context.stderr, "** WTH? Cannot Continue **") + maybe_print_backtrace(debug, context.stderr) + context.halt.(88) end - defp maybe_print_backtrace(debug) when is_integer(debug) and debug > 0 do - IO.puts(:stderr, Exception.format_stacktrace(Process.info(self(), :current_stacktrace))) + defp maybe_print_backtrace(debug, stderr) when is_integer(debug) and debug > 0 do + IO.puts(stderr, Exception.format_stacktrace(Process.info(self(), :current_stacktrace))) end - defp maybe_print_backtrace(_debug), do: :ok + defp maybe_print_backtrace(_debug, _stderr), do: :ok defp parse_states(value) do states = diff --git a/app/lib/linear_cli/cli/commands/issues/relations.ex b/app/lib/linear_cli/cli/commands/issues/relations.ex index b0c055c..45b498c 100644 --- a/app/lib/linear_cli/cli/commands/issues/relations.ex +++ b/app/lib/linear_cli/cli/commands/issues/relations.ex @@ -40,14 +40,16 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do successful mutations. All results are printed before returning; a non-zero exit identifies the overall failure count if any target failed. """ - @spec issue_relation_add(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_relation_add(%{unknown: []}), + @spec issue_relation_add(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} + def issue_relation_add(result, opts \\ []) + + def issue_relation_add(%{unknown: []}, _opts), do: {:error, {:smells_bad, "ISSUE and at least one RELATED_ISSUE are required"}} - def issue_relation_add(%{unknown: [_subject]}), + def issue_relation_add(%{unknown: [_subject]}, _opts), do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}} - def issue_relation_add(%{unknown: [subject_id | related_ids], options: options}) do + def issue_relation_add(%{unknown: [subject_id | related_ids], options: options}, opts) do expanded_subject = Identifiers.expand_issue_id(subject_id) user_type = options.type @@ -57,7 +59,7 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do add_single_relation(expanded_subject, expanded_related, user_type) end) - print_relation_add_results(results, options.output) + print_relation_add_results(results, options.output, Keyword.get(opts, :stderr, :stderr)) failed_count = Enum.count(results, fn r -> match?({:failed, _, _}, r) or match?({:self_link, _}, r) end) @@ -93,14 +95,14 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do end end - defp print_relation_add_results(results, output) do + defp print_relation_add_results(results, output, stderr) do if output == "json" do results |> Enum.map(&relation_add_result_to_plain/1) |> Jason.encode!(pretty: true) |> IO.puts() else - Enum.each(results, &print_relation_add_result_text/1) + Enum.each(results, &print_relation_add_result_text(&1, stderr)) end end @@ -129,21 +131,21 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do %{"target" => related_id, "status" => "error", "message" => msg} end - defp print_relation_add_result_text({:created, _related_id, relation}) do + defp print_relation_add_result_text({:created, _related_id, relation}, _stderr) do IO.puts(relation_add_created_text(relation)) end - defp print_relation_add_result_text({:exists, related_id}) do + defp print_relation_add_result_text({:exists, related_id}, _stderr) do Prompt.ok("#{related_id}: relation already exists (no change)") end - defp print_relation_add_result_text({:self_link, id}) do - IO.puts(:stderr, "#{id}: self-link — an issue cannot be related to itself") + defp print_relation_add_result_text({:self_link, id}, stderr) do + IO.puts(stderr, "#{id}: self-link — an issue cannot be related to itself") end - defp print_relation_add_result_text({:failed, related_id, reason}) do + defp print_relation_add_result_text({:failed, related_id, reason}, stderr) do msg = relation_add_error_message(reason) - IO.puts(:stderr, "#{related_id}: #{msg}") + IO.puts(stderr, "#{related_id}: #{msg}") end defp relation_add_created_text(%{type: "blocks", issue: issue, related_issue: related}) do @@ -193,14 +195,16 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do successful deletions. All results are printed before returning; a non-zero exit identifies the overall failure count if any target failed. """ - @spec issue_relation_remove(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_relation_remove(%{unknown: []}), + @spec issue_relation_remove(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()} + def issue_relation_remove(result, opts \\ []) + + def issue_relation_remove(%{unknown: []}, _opts), do: {:error, {:smells_bad, "ISSUE and at least one RELATED_ISSUE are required"}} - def issue_relation_remove(%{unknown: [_subject]}), + def issue_relation_remove(%{unknown: [_subject]}, _opts), do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}} - def issue_relation_remove(%{unknown: [subject_id | related_ids], options: options}) do + def issue_relation_remove(%{unknown: [subject_id | related_ids], options: options}, opts) do expanded_subject = Identifiers.expand_issue_id(subject_id) user_type = options.type @@ -211,7 +215,7 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do remove_single_relation(expanded_subject, expanded_related, user_type, all_relations) end) - print_relation_remove_results(results, options.output) + print_relation_remove_results(results, options.output, Keyword.get(opts, :stderr, :stderr)) failed_count = Enum.count(results, fn r -> @@ -273,14 +277,14 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do end) end - defp print_relation_remove_results(results, output) do + defp print_relation_remove_results(results, output, stderr) do if output == "json" do results |> Enum.map(&relation_remove_result_to_plain/1) |> Jason.encode!(pretty: true) |> IO.puts() else - Enum.each(results, &print_relation_remove_result_text/1) + Enum.each(results, &print_relation_remove_result_text(&1, stderr)) end end @@ -317,28 +321,28 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do %{"target" => related_id, "status" => "error", "message" => msg} end - defp print_relation_remove_result_text({:removed, _related_id, relation}) do + defp print_relation_remove_result_text({:removed, _related_id, relation}, _stderr) do IO.puts(relation_remove_removed_text(relation)) end - defp print_relation_remove_result_text({:absent, related_id}) do + defp print_relation_remove_result_text({:absent, related_id}, _stderr) do Prompt.ok("#{related_id}: relation not found (no change)") end - defp print_relation_remove_result_text({:self_link, id}) do - IO.puts(:stderr, "#{id}: self-link — an issue cannot be related to itself") + defp print_relation_remove_result_text({:self_link, id}, stderr) do + IO.puts(stderr, "#{id}: self-link — an issue cannot be related to itself") end - defp print_relation_remove_result_text({:ambiguous, related_id, ids}) do + defp print_relation_remove_result_text({:ambiguous, related_id, ids}, stderr) do IO.puts( - :stderr, + stderr, "#{related_id}: ambiguous — #{length(ids)} matching relations: #{Enum.join(ids, ", ")}" ) end - defp print_relation_remove_result_text({:failed, related_id, reason}) do + defp print_relation_remove_result_text({:failed, related_id, reason}, stderr) do msg = relation_remove_error_message(reason) - IO.puts(:stderr, "#{related_id}: #{msg}") + IO.puts(stderr, "#{related_id}: #{msg}") end defp relation_remove_removed_text(%{type: "blocks", issue: issue, related_issue: related}) do diff --git a/app/test/linear_cli/cli/commands/issues/create_test.exs b/app/test/linear_cli/cli/commands/issues/create_test.exs index 6d06f3d..222ea27 100644 --- a/app/test/linear_cli/cli/commands/issues/create_test.exs +++ b/app/test/linear_cli/cli/commands/issues/create_test.exs @@ -233,7 +233,7 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( [ "issue", @@ -247,7 +247,8 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do "--body-file", "somefile" ], - halt + halt, + stderr: stderr ) end) @@ -261,7 +262,7 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( [ "issue", @@ -273,7 +274,8 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do "--team", "ENG" ], - halt + halt, + stderr: stderr ) end) @@ -326,7 +328,7 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( [ "issue", @@ -341,7 +343,8 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do "--no-take", "--dev" ], - halt + halt, + stderr: stderr ) end) @@ -397,10 +400,11 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( ["issue", "create", "--description", "Some desc", "--team", "ENG", "--yes"], - halt + halt, + stderr: stderr ) end) @@ -415,10 +419,11 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( ["issue", "create", "--title", "New thing", "--team", "ENG", "--yes"], - halt + halt, + stderr: stderr ) end) @@ -450,7 +455,7 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do ]) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( [ "issue", @@ -461,7 +466,8 @@ defmodule LinearCli.CLI.Commands.Issues.CreateTest do "Some desc", "--yes" ], - halt + halt, + stderr: stderr ) end) diff --git a/app/test/linear_cli/cli/commands/issues/graph_test.exs b/app/test/linear_cli/cli/commands/issues/graph_test.exs index 878b181..22d79b4 100644 --- a/app/test/linear_cli/cli/commands/issues/graph_test.exs +++ b/app/test/linear_cli/cli/commands/issues/graph_test.exs @@ -454,11 +454,12 @@ defmodule LinearCli.CLI.Commands.Issues.GraphTest do test "--graph and --web together return a usage error" do output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> assert catch_throw( LinearCli.CLI.main( ["issue", "view", "EXT-56", "--graph", "--web"], - fn code -> throw({:halted, code}) end + fn code -> throw({:halted, code}) end, + stderr: stderr ) ) == {:halted, 22} end) diff --git a/app/test/linear_cli/cli/commands/issues/move_test.exs b/app/test/linear_cli/cli/commands/issues/move_test.exs index d2c3396..d0cf8b3 100644 --- a/app/test/linear_cli/cli/commands/issues/move_test.exs +++ b/app/test/linear_cli/cli/commands/issues/move_test.exs @@ -287,8 +287,12 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do halt = fn code -> send(test_pid, {:halted, code}) end output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "move", "--project", "Manhattan"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "move", "--project", "Manhattan"], + halt, + stderr: stderr + ) end) assert_received {:halted, 22} @@ -583,7 +587,7 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do end end) - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( [ "issue", @@ -596,7 +600,8 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do "ENG", "--yes" ], - halt + halt, + stderr: stderr ) end) @@ -681,10 +686,11 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do raise "no API call should be made for same-ID move; got: #{query}" end) - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( ["issue", "move", "--from", same_uuid, "--to", same_uuid], - halt + halt, + stderr: stderr ) end) @@ -725,8 +731,12 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do halt = fn code -> send(test_pid, {:halted, code}) end output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "move", "--from", "Source Project"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "move", "--from", "Source Project"], + halt, + stderr: stderr + ) end) assert_received {:halted, 22} @@ -738,8 +748,12 @@ defmodule LinearCli.CLI.Commands.Issues.MoveTest do halt = fn code -> send(test_pid, {:halted, code}) end output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "move", "--to", "Target Project"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "move", "--to", "Target Project"], + halt, + stderr: stderr + ) end) assert_received {:halted, 22} 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 de4574f..687ac3f 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -164,10 +164,11 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do halt = fn code -> send(test_pid, {:halted, code}) end stderr = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( ["issue", "status", "--statuz", "Done", "CRY-1", "CRY-2"], - halt + halt, + stderr: stderr ) end) @@ -268,8 +269,12 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "status", "--status", "Nonexistent", "CRY-1"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "status", "--status", "Nonexistent", "CRY-1"], + halt, + stderr: stderr + ) end) assert_received {:halted, 22} @@ -305,8 +310,12 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "status", "--status", "Do", "CRY-1"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "status", "--status", "Do", "CRY-1"], + halt, + stderr: stderr + ) end) assert_received {:halted, 22} @@ -657,8 +666,8 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do halt = fn code -> send(test_pid, {:halted, code}) end output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "update"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main(["issue", "update"], halt, stderr: stderr) end) assert_received {:halted, 22} @@ -761,7 +770,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( [ "issue", @@ -772,7 +781,8 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do "somefile", "CRY-1" ], - halt + halt, + stderr: stderr ) end) @@ -786,7 +796,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( [ "issue", @@ -795,7 +805,8 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do "/nonexistent/path/does-not-exist", "CRY-1" ], - halt + halt, + stderr: stderr ) end) @@ -912,8 +923,12 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "assign", "--assignee", "Nobody", "CRY-1"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "assign", "--assignee", "Nobody", "CRY-1"], + halt, + stderr: stderr + ) end) assert_received {:halted, 22} @@ -945,8 +960,12 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "assign", "--assignee", "Bo", "CRY-1"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "assign", "--assignee", "Bo", "CRY-1"], + halt, + stderr: stderr + ) end) assert_received {:halted, 22} @@ -1076,8 +1095,8 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "assign", "CRY-1"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main(["issue", "assign", "CRY-1"], halt, stderr: stderr) end) assert_received {:halted, 22} @@ -1246,10 +1265,11 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) stderr = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( ["issue", "assign", "-a", "Bob", "--status", "NoSuchState", "CRY-1"], - halt + halt, + stderr: stderr ) end) @@ -1552,8 +1572,12 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) stderr = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "take", "--status", "Bogus", "CRY-1"], halt) + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "take", "--status", "Bogus", "CRY-1"], + halt, + stderr: stderr + ) end) assert_received {:halted, 22} @@ -1790,10 +1814,11 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( ["issue", "comment", "CRY-1", "-m", "text", "--body-file", "somefile"], - halt + halt, + stderr: stderr ) end) @@ -1807,10 +1832,11 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> LinearCli.CLI.main( ["issue", "comment", "CRY-1", "--body-file", "/nonexistent/path/does-not-exist"], - halt + halt, + stderr: stderr ) end) @@ -1858,8 +1884,8 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do 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) + capture_stderr(fn stderr -> + LinearCli.CLI.main(["issue", "comment", "-m", "lgtm"], halt, stderr: stderr) end) assert_received {:halted, 22} diff --git a/app/test/linear_cli/cli/commands/issues/relations_test.exs b/app/test/linear_cli/cli/commands/issues/relations_test.exs index c2fc95a..e112dbe 100644 --- a/app/test/linear_cli/cli/commands/issues/relations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/relations_test.exs @@ -1,6 +1,7 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do use ExUnit.Case, async: true import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers alias LinearCli.CLI.Commands.Issues.Relations @@ -250,8 +251,13 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do end) output_stderr = - capture_io(:stderr, fn -> - result = Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-1"], "blocks")) + capture_stderr(fn stderr -> + result = + Relations.issue_relation_add( + add_parse_result("EXT-1", ["EXT-1"], "blocks"), + stderr: stderr + ) + assert {:error, {:smells_bad, _}} = result end) @@ -280,8 +286,13 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do end) stderr = - capture_io(:stderr, fn -> - result = Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + capture_stderr(fn stderr -> + result = + Relations.issue_relation_add( + add_parse_result("EXT-1", ["EXT-2"], "blocks"), + stderr: stderr + ) + assert {:error, {:smells_bad, msg}} = result assert msg =~ "failed" end) @@ -305,12 +316,13 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do end) stderr = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> output = capture_io(fn -> result = Relations.issue_relation_add( - add_parse_result("EXT-1", ["EXT-2", "EXT-bad"], "blocks") + add_parse_result("EXT-1", ["EXT-2", "EXT-bad"], "blocks"), + stderr: stderr ) assert {:error, {:smells_bad, _}} = result @@ -366,13 +378,16 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do Req.Test.stub(LinearCli.Api, fn _conn -> raise "should not be called" end) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> output_stdout = capture_io(fn -> - Relations.issue_relation_add(%{ - unknown: ["EXT-1", "EXT-1"], - options: %{output: "json", type: "blocks"} - }) + Relations.issue_relation_add( + %{ + unknown: ["EXT-1", "EXT-1"], + options: %{output: "json", type: "blocks"} + }, + stderr: stderr + ) end) [entry] = Jason.decode!(output_stdout) @@ -620,9 +635,12 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do remove_relations_stub([], []) output_stderr = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> result = - Relations.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-1"], "blocks")) + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-1"], "blocks"), + stderr: stderr + ) assert {:error, {:smells_bad, _}} = result end) @@ -656,10 +674,13 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do ) output_stderr = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> result = capture_io(fn -> - Relations.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "blocks"), + stderr: stderr + ) end) _ = result @@ -821,13 +842,16 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do remove_relations_stub([], []) output = - capture_io(:stderr, fn -> + capture_stderr(fn stderr -> output_stdout = capture_io(fn -> - Relations.issue_relation_remove(%{ - unknown: ["EXT-1", "EXT-1"], - options: %{output: "json", type: "blocks"} - }) + Relations.issue_relation_remove( + %{ + unknown: ["EXT-1", "EXT-1"], + options: %{output: "json", type: "blocks"} + }, + stderr: stderr + ) end) [entry] = Jason.decode!(output_stdout) diff --git a/app/test/support/issue_commands_helpers.ex b/app/test/support/issue_commands_helpers.ex index e22cdcf..b058b09 100644 --- a/app/test/support/issue_commands_helpers.ex +++ b/app/test/support/issue_commands_helpers.ex @@ -1,6 +1,20 @@ defmodule LinearCli.CLI.IssueCommandsHelpers do @moduledoc false + # Named ExUnit stderr capture replaces a global device, so concurrent tests + # can receive each other's output. Give each test its own device instead. + def capture_stderr(fun) when is_function(fun, 1) do + {:ok, stderr} = StringIO.open("") + + try do + fun.(stderr) + {_input, output} = StringIO.contents(stderr) + output + after + StringIO.close(stderr) + end + end + # Dispatches to one of `pairs` ({substring, response_map}) based on which # substring appears in the outgoing GraphQL document. def stub_responses(pairs) do From cdcb2cc27bc12a7548534e5ac068e9dcf6ce3fc2 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 21 Sep 2026 10:09:29 -0500 Subject: [PATCH 2/3] chore: updates vulnerable dependencies --- app/mix.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/mix.lock b/app/mix.lock index 31cc2ad..e53d72c 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -1,5 +1,5 @@ %{ - "ash": {:hex, :ash, "3.33.0", "ffbf9c14d00135cd2ae53199a5d00a1a3c456b69c0b74fc53bd2aba771eb879f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a1b313eefd0e04aa626d4e54313aadec17f3d82422f75d45e66c2736b807bf00"}, + "ash": {:hex, :ash, "3.33.8", "9faa5cc0b25ec0b7488591e0020259a746036e382d843f77df7fa8fb2a501e60", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9968f68ff2a527d8f5df71d318d77d792bb2bc60b17c92fc4fac73da0cee6899"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "burrito": {:hex, :burrito, "1.6.0", "7af0a75f11680e8a6e9c01370c9af51cb9d0e15b3226eddf4f438dbc68570520", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "e636a00b032c45a69ff755d9fc53fa5fdc9e1d21bdbd229075fe4a15b05355fe"}, "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, @@ -30,9 +30,9 @@ "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"}, "mdex_native": {:hex, :mdex_native, "0.2.7", "46203ee9c4fe2c94feef26e4d976235ad022b4770d3d307fa6b445d293b2571f", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "05efa68775644ef6be6109f76c1f5f8778ff7e7ed9fe4a03541758158172bc77"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, - "multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"}, + "multigraph": {:hex, :multigraph, "0.16.1-mg.5", "5118665ea3e2eede7244104ccc716b7c517bba44e33c26fb84d7297d77872646", [:mix], [], "hexpm", "d7e3511f1d29e96ed7e7cd01b92bb52eea487b6c61da838f1e61a2ec6838799d"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, @@ -44,14 +44,14 @@ "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, "protobuf": {:hex, :protobuf, "0.16.1", "7bf0c3e6b24fd297e2e3722ecb985c3900bcc55d004b7faf0737e248c3e64691", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "97fb9f1fe48c38c4ebfc0f8a7a20adb22b2491bbfbdb192fab9a6821d5d604e1"}, "purl": {:hex, :purl, "0.3.0", "b6e2f792e944007ed474299e56ea43ea4819f064a4a4f67e4153e1ba02d066de", [:mix, :rebar3], [{:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "158cf2c0533517fe0d68752bd790392f065c5ffdfbde38524890066ebe640302"}, - "reactor": {:hex, :reactor, "1.0.6", "546a87255693bcee99451d022cc86927161cdb527f5216d4dcac2b31e08eb122", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "e3f8fd8e870c2b011316ca2ac422bb4bc710cac9f02c578f8526f7a6348d932b"}, + "reactor": {:hex, :reactor, "1.0.7", "804bd237609df9162d7c61f68c6243722ef3ea2f9c48e693a04b7f09cf99e4d6", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "d5eddbdcdf1016d76d713c927741f6db21421271cea015b0d355d6b1097206a4"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "sbom": {:hex, :sbom, "0.10.0", "b99be5407bc196d0ad71b8061126a67aae46dc3bfaa852b4c1c04645dd1ad984", [:mix], [{:hex_core, "~> 0.15.0", [hex: :hex_core, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:optimus, "~> 0.6.1", [hex: :optimus, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.16.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:purl, "~> 0.3.0", [hex: :purl, repo: "hexpm", optional: false]}], "hexpm", "a8116ef965c1ebd103e223545794bd0a6691edd3ec678ec07972d473e2badc95"}, - "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, - "spark": {:hex, :spark, "2.7.2", "36becc6ff03b40908cc821d403d7f06d893498e293d2f718afc6ca097fcb9d93", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "adb323ddbf9dbbe326f9e5def54ac96c47911e852b2c270bb19a5147c56f1b45"}, - "spitfire": {:hex, :spitfire, "0.4.1", "69e90335d00ca328295e1e1e77cac5d7575aa6d34274e3467ebfc654b8858be3", [:mix], [], "hexpm", "27d86f67681179682b15c6758d64ac2eb2b3637ed8340800c8b885c69754cdcd"}, + "sourceror": {:hex, :sourceror, "1.12.3", "f58eebef0765c7a369a49a755ab2a5ee88d92777f403ea5a08b722cefcc37f51", [:mix], [], "hexpm", "d5f2f37099de794840f08c54ae546d7f6e4ea015e397be64aebe4996fa9f7da7"}, + "spark": {:hex, :spark, "2.7.3", "006c41968d557f4c82ebad5dad29656d604ff59a0f8e705a75f0cc70b76c8e7c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "ffced2f13ee13695f3a7284e5ae03724adb0014bd4b65b2cf40ee48a878d5d05"}, + "spitfire": {:hex, :spitfire, "0.4.2", "5c719208d4eeb810e5b2a2aa1024d1e4b2974b7ca422c274b2486666e5159735", [:mix], [], "hexpm", "9bbbbffe93e6f88ccf193487ef56b83c2646a6dc3875bb0975d4654bfe96c5bb"}, "splode": {:hex, :splode, "0.3.2", "7716b6b2260a98a6f018c65cc0393da2cdf17314202eb351a0956e8762190dff", [:mix], [], "hexpm", "08fd658f80da7f1cd254b149164dcff8acd44b22f032001d5416b97223d32bc9"}, "stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, From 17fc406f1554540c621ffa68ca30a5379347a207 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 21 Sep 2026 16:18:11 -0400 Subject: [PATCH 3/3] ci(hooks): stop rerunning precommit before push --- app/usage-rules.md | 4 +-- documents/quality-gates-decision.adoc | 3 +- git-hooks/pre-push | 7 ++--- lib/mix/tasks/git_hooks.ex | 4 +-- lib/mix/tasks/precommit.ex | 6 ++-- test/git_hooks_test.exs | 45 ++------------------------- 6 files changed, 13 insertions(+), 56 deletions(-) diff --git a/app/usage-rules.md b/app/usage-rules.md index 5cb5cb9..6c09617 100644 --- a/app/usage-rules.md +++ b/app/usage-rules.md @@ -14,8 +14,8 @@ - Enforced locally by the `commit-msg` hook at `git-hooks/commit-msg` (each commit's own subject, via `ci/validate_conventional_subject.sh`) and the `pre-push` hook at `git-hooks/pre-push` (every non-deletion ref update, - via `ci/validate_push_refs.sh`, followed by `mix precommit` and then - `ci/hex-audit.sh`) — run `mix setup` once per clone to activate both. The + via `ci/validate_push_refs.sh`, followed by `ci/hex-audit.sh`) — run `mix + setup` once per clone to activate both. The Hex audit needs network access and prevents a push when it finds a vulnerable or retired package. - Enforced in CI across a whole PR's commit range by diff --git a/documents/quality-gates-decision.adoc b/documents/quality-gates-decision.adoc index af44d93..5cc1a40 100644 --- a/documents/quality-gates-decision.adoc +++ b/documents/quality-gates-decision.adoc @@ -94,5 +94,4 @@ calls `mix ci`, not `mix precommit`. The repository's `pre-commit` hook refuses commits directly on `main` and then invokes `mix precommit`. The subsequent `commit-msg` hook validates the commit message after Git writes it; the `pre-push` hook remains the final local guard -by validating every pushed commit subject, rerunning `mix precommit`, and then -performing the Hex audit. +by validating every pushed commit subject and then performing the Hex audit. diff --git a/git-hooks/pre-push b/git-hooks/pre-push index 3220e75..c08d611 100755 --- a/git-hooks/pre-push +++ b/git-hooks/pre-push @@ -1,12 +1,9 @@ #!/bin/sh -# Validates every commit subject introduced by this push, reruns the fast local -# quality gate, then rejects known vulnerable or retired Hex dependencies. See +# Validates every commit subject introduced by this push, then rejects known +# vulnerable or retired Hex dependencies. See # app/usage-rules.md. Activate with: mix git_hooks repo_top=$(git rev-parse --show-toplevel) || exit 1 "$repo_top/ci/validate_push_refs.sh" "$@" || exit $? -cd "$repo_top" || exit 1 -mix precommit || exit $? - exec "$repo_top/ci/hex-audit.sh" diff --git a/lib/mix/tasks/git_hooks.ex b/lib/mix/tasks/git_hooks.ex index e1eb6a5..0a42cf8 100644 --- a/lib/mix/tasks/git_hooks.ex +++ b/lib/mix/tasks/git_hooks.ex @@ -9,8 +9,8 @@ defmodule Mix.Tasks.GitHooks do Sets `core.hooksPath` to `git-hooks/`: its `pre-commit` hook prevents direct commits to `main` and runs `mix precommit`; its `commit-msg` hook enforces Conventional Commits on each commit subject; and its `pre-push` hook validates - all commit subjects introduced by the push, reruns `mix precommit`, and then - runs Hex's dependency security audit. + all commit subjects introduced by the push and then runs Hex's dependency + security audit. This is idempotent and safe to run repeatedly: setting the same Git config value twice is a no-op. Wired into `mix setup` - see that task. """ diff --git a/lib/mix/tasks/precommit.ex b/lib/mix/tasks/precommit.ex index d9756f2..a474afa 100644 --- a/lib/mix/tasks/precommit.ex +++ b/lib/mix/tasks/precommit.ex @@ -7,9 +7,9 @@ defmodule Mix.Tasks.Precommit do mix precommit A fast, self-contained command designed for frequent developer use: between - edits and before committing. The repository's pre-commit and pre-push hooks - invoke it. On a warm checkout with dependencies already installed, it - completes in under five seconds. + edits and before committing. The repository's pre-commit hook invokes it. On + a warm checkout with dependencies already installed, it completes in under + five seconds. It requires no network access, credentials, containers, or external services. Run `mix deps.get` inside `app/` once after cloning or after updating diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index 3df90ec..afcb817 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -287,12 +287,10 @@ defmodule GitHooksTest do assert File.read!(marker) == "precommit\n" end - test "the pre-push adapter validates refs before running the quality and Hex audits" do + test "the pre-push adapter validates refs before running the Hex audit" do {worktree, ci_dir} = setup_ci_worktree!() audit_marker = Path.join(worktree, "hex-audit-ran") - precommit_marker = Path.join(worktree, "precommit-ran") hook = install_pre_push_hook!(worktree, ci_dir) - fake_bin = install_fake_mix!(worktree) {base_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) base_sha = String.trim(base_sha) @@ -301,16 +299,10 @@ defmodule GitHooksTest do assert {"", 0} = run_with_stdin(hook, valid_stdin, cd: worktree, - env: [ - {"HEX_AUDIT_MARKER", audit_marker}, - {"MIX_MARKER", precommit_marker}, - {"PATH", fake_bin <> ":" <> System.get_env("PATH")} - ] + env: [{"HEX_AUDIT_MARKER", audit_marker}] ) - assert File.read!(precommit_marker) == "precommit\n" assert File.read!(audit_marker) == "audited\n" - File.rm!(precommit_marker) File.rm!(audit_marker) File.write!(Path.join(worktree, "invalid"), "commit\n") @@ -323,41 +315,10 @@ defmodule GitHooksTest do assert {output, 1} = run_with_stdin(hook, invalid_stdin, cd: worktree, - env: [ - {"HEX_AUDIT_MARKER", audit_marker}, - {"MIX_MARKER", precommit_marker}, - {"PATH", fake_bin <> ":" <> System.get_env("PATH")} - ] + env: [{"HEX_AUDIT_MARKER", audit_marker}] ) assert output =~ "not conventional" - refute File.exists?(precommit_marker) - refute File.exists?(audit_marker) - end - - test "the pre-push adapter stops before the Hex audit when the quality gate fails" do - {worktree, ci_dir} = setup_ci_worktree!() - audit_marker = Path.join(worktree, "hex-audit-ran") - precommit_marker = Path.join(worktree, "precommit-ran") - hook = install_pre_push_hook!(worktree, ci_dir) - fake_bin = install_fake_mix!(worktree) - - {base_sha, 0} = System.cmd("git", ["rev-parse", "HEAD"], cd: worktree) - base_sha = String.trim(base_sha) - stdin = "refs/heads/main #{base_sha} refs/heads/main #{base_sha}\n" - - assert {"", 1} = - run_with_stdin(hook, stdin, - cd: worktree, - env: [ - {"HEX_AUDIT_MARKER", audit_marker}, - {"MIX_MARKER", precommit_marker}, - {"MIX_EXIT_STATUS", "1"}, - {"PATH", fake_bin <> ":" <> System.get_env("PATH")} - ] - ) - - assert File.read!(precommit_marker) == "precommit\n" refute File.exists?(audit_marker) end