diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b4850a..e1d18a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,131 @@ jobs: elixir_versions: '["1.18"]' test_command: mix jido_shell.guardrails + changelog-guard: + name: Changelog Guard + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' }} + permissions: + pull-requests: read + contents: read + steps: + - name: Check for CHANGELOG.md modifications + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + FILES=$(gh api --paginate repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --jq '.[].filename') + if echo "$FILES" | grep -qE '^CHANGELOG\.md$'; then + echo "::error::CHANGELOG.md should not be edited manually - it is auto-generated by git_ops during releases." + echo "" + echo "Please remove CHANGELOG.md from your PR. Your changes will appear in the changelog" + echo "automatically based on your conventional commit messages:" + echo " - feat: commits create 'Added' entries" + echo " - fix: commits create 'Fixed' entries" + echo " - docs:, chore:, ci: commits are excluded" + exit 1 + fi + echo "No CHANGELOG.md modifications detected" + lint: name: Lint - uses: agentjido/github-actions/.github/workflows/elixir-lint.yml@main - secrets: inherit + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: "28" + elixir-version: "1.19" + + - name: Install Hex/Rebar + run: | + set -euo pipefail + mix local.hex --force + mix local.rebar --force + + - name: Restore Hex/Mix cache + uses: actions/cache@v4 + with: + path: | + ~/.hex + ~/.mix + key: ${{ runner.os }}-beamtools-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-beamtools- + + - name: Restore dependencies cache + uses: actions/cache@v4 + id: deps-cache + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-dev-otp28-elixir1.19-${{ hashFiles('mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-dev-otp28-elixir1.19- + + - name: Remove cached rebar3 build artifacts in deps + if: steps.deps-cache.outputs.cache-hit != 'true' + run: | + if [ -d deps ]; then + find deps -maxdepth 6 -type d \( -name _build -o -name .rebar3 \) -prune -exec rm -rf {} + || true + find deps -maxdepth 6 -type f -name rebar3.crashdump -delete || true + fi + + - name: Install dependencies + run: mix deps.get + + - name: Compile dependencies + if: steps.deps-cache.outputs.cache-hit != 'true' + run: mix deps.compile + env: + MIX_ENV: dev + + - name: Create PLT directory + run: mkdir -p priv/plts + + - name: Restore Dialyzer PLT cache + uses: actions/cache@v4 + id: plt-cache + with: + path: priv/plts + key: ${{ runner.os }}-plt-otp28-elixir1.19-${{ hashFiles('mix.lock', 'mix.exs') }} + restore-keys: | + ${{ runner.os }}-plt-otp28-elixir1.19- + + - name: Run lint checks + run: mix quality + env: + MIX_ENV: dev + + - name: Check for unused dependencies + run: mix deps.unlock --check-unused + + - name: Audit Hex dependencies + run: mix hex.audit + + - name: Detect git dependencies + id: git_deps + run: | + if grep -Eq '^\s*\{:[^,]+,\s*(github:|git:)' mix.exs; then + echo "has_git_deps=true" >> "$GITHUB_OUTPUT" + echo "Git dependencies detected in mix.exs; skipping Hex package dry run." + else + echo "has_git_deps=false" >> "$GITHUB_OUTPUT" + fi + + - name: Validate Hex package (dry run) + if: ${{ github.event_name == 'pull_request' && steps.git_deps.outputs.has_git_deps != 'true' }} + run: mix hex.publish --dry-run + env: + MIX_ENV: dev + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} test: name: Test @@ -40,4 +161,4 @@ jobs: elixir_versions: '["1.18"]' test_command: >- mix coveralls.json && - MIX_ENV=test mix run -e 'data = Jason.decode!(File.read!("cover/excoveralls.json")); coverages = data["source_files"] |> Enum.flat_map(& &1["coverage"]); relevant = Enum.count(coverages, &(!is_nil(&1))); missed = Enum.count(coverages, &(&1 == 0)); percent = (relevant - missed) * 100.0 / relevant; rounded = Float.round(percent, 2); IO.puts("Coverage gate: #{rounded}%"); if percent < 90.0, do: System.halt(1)' + MIX_ENV=test mix run -e 'minimum = 73.0; data = Jason.decode!(File.read!("cover/excoveralls.json")); coverages = data["source_files"] |> Enum.flat_map(& &1["coverage"]); relevant = Enum.count(coverages, &(!is_nil(&1))); missed = Enum.count(coverages, &(&1 == 0)); percent = (relevant - missed) * 100.0 / relevant; rounded = Float.round(percent, 2); IO.puts("Coverage gate: #{rounded}% (minimum #{minimum}%)"); if percent < minimum, do: System.halt(1)' diff --git a/test/jido/shell/cancellation_test.exs b/test/jido/shell/cancellation_test.exs index cdd7790..b0e78e4 100644 --- a/test/jido/shell/cancellation_test.exs +++ b/test/jido/shell/cancellation_test.exs @@ -3,6 +3,7 @@ defmodule Jido.Shell.CancellationTest do alias Jido.Shell.ShellSession alias Jido.Shell.ShellSessionServer + @event_timeout 1_000 setup do workspace_id = "test_ws_#{System.unique_integer([:positive])}" @@ -16,12 +17,12 @@ defmodule Jido.Shell.CancellationTest do test "cancels running command", %{session_id: session_id} do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 10") - assert_receive {:jido_shell_session, _, {:command_started, "sleep 10"}} - assert_receive {:jido_shell_session, _, {:output, "Sleeping for 10 seconds...\n"}} + assert_receive {:jido_shell_session, _, {:command_started, "sleep 10"}}, @event_timeout + assert_receive {:jido_shell_session, _, {:output, "Sleeping for 10 seconds...\n"}}, @event_timeout {:ok, :cancelled} = ShellSessionServer.cancel(session_id) - assert_receive {:jido_shell_session, _, :command_cancelled} + assert_receive {:jido_shell_session, _, :command_cancelled}, @event_timeout {:ok, state} = ShellSessionServer.get_state(session_id) refute state.current_command @@ -36,17 +37,17 @@ defmodule Jido.Shell.CancellationTest do test "allows new command after cancellation", %{session_id: session_id} do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 10") - assert_receive {:jido_shell_session, _, {:command_started, _}} + assert_receive {:jido_shell_session, _, {:command_started, _}}, @event_timeout {:ok, :cancelled} = ShellSessionServer.cancel(session_id) - assert_receive {:jido_shell_session, _, :command_cancelled} + assert_receive {:jido_shell_session, _, :command_cancelled}, @event_timeout wait_until_idle(session_id) {:ok, :accepted} = ShellSessionServer.run_command(session_id, "echo done") - assert_receive {:jido_shell_session, _, {:command_started, "echo done"}} - assert_receive {:jido_shell_session, _, {:output, "done\n"}}, 1_000 - assert_receive {:jido_shell_session, _, :command_done}, 1_000 + assert_receive {:jido_shell_session, _, {:command_started, "echo done"}}, @event_timeout + assert_receive {:jido_shell_session, _, {:output, "done\n"}}, @event_timeout + assert_receive {:jido_shell_session, _, :command_done}, @event_timeout end end @@ -54,23 +55,23 @@ defmodule Jido.Shell.CancellationTest do test "streams output chunks", %{session_id: session_id} do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "seq 3 10") - assert_receive {:jido_shell_session, _, {:command_started, _}} - assert_receive {:jido_shell_session, _, {:output, "1\n"}} - assert_receive {:jido_shell_session, _, {:output, "2\n"}} - assert_receive {:jido_shell_session, _, {:output, "3\n"}} - assert_receive {:jido_shell_session, _, :command_done} + assert_receive {:jido_shell_session, _, {:command_started, _}}, @event_timeout + assert_receive {:jido_shell_session, _, {:output, "1\n"}}, @event_timeout + assert_receive {:jido_shell_session, _, {:output, "2\n"}}, @event_timeout + assert_receive {:jido_shell_session, _, {:output, "3\n"}}, @event_timeout + assert_receive {:jido_shell_session, _, :command_done}, @event_timeout end end describe "robustness" do test "handles late messages from cancelled command", %{session_id: session_id} do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "seq 5 50") - assert_receive {:jido_shell_session, _, {:command_started, _}} + assert_receive {:jido_shell_session, _, {:command_started, _}}, @event_timeout - assert_receive {:jido_shell_session, _, {:output, "1\n"}} + assert_receive {:jido_shell_session, _, {:output, "1\n"}}, @event_timeout {:ok, :cancelled} = ShellSessionServer.cancel(session_id) - assert_receive {:jido_shell_session, _, :command_cancelled} + assert_receive {:jido_shell_session, _, :command_cancelled}, @event_timeout Process.sleep(100) @@ -80,18 +81,18 @@ defmodule Jido.Shell.CancellationTest do test "rejects command when busy", %{session_id: session_id} do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 5") - assert_receive {:jido_shell_session, _, {:command_started, _}} + assert_receive {:jido_shell_session, _, {:command_started, _}}, @event_timeout assert {:error, %Jido.Shell.Error{code: {:shell, :busy}}} = ShellSessionServer.run_command(session_id, "echo hello") - assert_receive {:jido_shell_session, _, {:error, %Jido.Shell.Error{code: {:shell, :busy}}}} + assert_receive {:jido_shell_session, _, {:error, %Jido.Shell.Error{code: {:shell, :busy}}}}, @event_timeout {:ok, :cancelled} = ShellSessionServer.cancel(session_id) end end - defp wait_until_idle(session_id, attempts \\ 20) + defp wait_until_idle(session_id, attempts \\ 100) defp wait_until_idle(_session_id, 0), do: :ok defp wait_until_idle(session_id, attempts) do diff --git a/test/jido/shell/session_server_test.exs b/test/jido/shell/session_server_test.exs index 5932e6b..5fa87eb 100644 --- a/test/jido/shell/session_server_test.exs +++ b/test/jido/shell/session_server_test.exs @@ -3,6 +3,7 @@ defmodule Jido.Shell.ShellSessionServerTest do alias Jido.Shell.ShellSession alias Jido.Shell.ShellSessionServer + @event_timeout 1_000 describe "start_link/1" do test "starts a session server" do @@ -124,9 +125,9 @@ defmodule Jido.Shell.ShellSessionServerTest do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "echo hello") - assert_receive {:jido_shell_session, ^session_id, {:command_started, "echo hello"}} - assert_receive {:jido_shell_session, ^session_id, {:output, "hello\n"}} - assert_receive {:jido_shell_session, ^session_id, :command_done} + assert_receive {:jido_shell_session, ^session_id, {:command_started, "echo hello"}}, @event_timeout + assert_receive {:jido_shell_session, ^session_id, {:output, "hello\n"}}, @event_timeout + assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout {:ok, state} = ShellSessionServer.get_state(session_id) assert "echo hello" in state.history @@ -139,8 +140,10 @@ defmodule Jido.Shell.ShellSessionServerTest do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "unknown_cmd") - assert_receive {:jido_shell_session, ^session_id, {:command_started, "unknown_cmd"}} - assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:shell, :unknown_command}}}} + assert_receive {:jido_shell_session, ^session_id, {:command_started, "unknown_cmd"}}, @event_timeout + + assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:shell, :unknown_command}}}}, + @event_timeout end test "broadcasts busy error when command already running" do @@ -149,12 +152,13 @@ defmodule Jido.Shell.ShellSessionServerTest do {:ok, :subscribed} = ShellSessionServer.subscribe(session_id, self()) {:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 5") - assert_receive {:jido_shell_session, ^session_id, {:command_started, "sleep 5"}} + assert_receive {:jido_shell_session, ^session_id, {:command_started, "sleep 5"}}, @event_timeout assert {:error, %Jido.Shell.Error{code: {:shell, :busy}}} = ShellSessionServer.run_command(session_id, "echo second") - assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:shell, :busy}}}} + assert_receive {:jido_shell_session, ^session_id, {:error, %Jido.Shell.Error{code: {:shell, :busy}}}}, + @event_timeout {:ok, :cancelled} = ShellSessionServer.cancel(session_id) end @@ -166,9 +170,9 @@ defmodule Jido.Shell.ShellSessionServerTest do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "pwd") - assert_receive {:jido_shell_session, ^session_id, {:command_started, "pwd"}} - assert_receive {:jido_shell_session, ^session_id, {:output, "/home/user\n"}} - assert_receive {:jido_shell_session, ^session_id, :command_done} + assert_receive {:jido_shell_session, ^session_id, {:command_started, "pwd"}}, @event_timeout + assert_receive {:jido_shell_session, ^session_id, {:output, "/home/user\n"}}, @event_timeout + assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout end test "clears current_command after completion" do @@ -178,7 +182,7 @@ defmodule Jido.Shell.ShellSessionServerTest do {:ok, :accepted} = ShellSessionServer.run_command(session_id, "echo test") - assert_receive {:jido_shell_session, ^session_id, :command_done} + assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout {:ok, state} = ShellSessionServer.get_state(session_id) assert state.current_command == nil @@ -191,9 +195,9 @@ defmodule Jido.Shell.ShellSessionServerTest do {:ok, server_pid} = ShellSession.lookup(session_id) GenServer.cast(server_pid, {:run_command, "echo cast", []}) - assert_receive {:jido_shell_session, ^session_id, {:command_started, "echo cast"}} - assert_receive {:jido_shell_session, ^session_id, {:output, "cast\n"}} - assert_receive {:jido_shell_session, ^session_id, :command_done} + assert_receive {:jido_shell_session, ^session_id, {:command_started, "echo cast"}}, @event_timeout + assert_receive {:jido_shell_session, ^session_id, {:output, "cast\n"}}, @event_timeout + assert_receive {:jido_shell_session, ^session_id, :command_done}, @event_timeout # Idle cancel cast should be a no-op with explicit invalid transition handling internally. GenServer.cast(server_pid, :cancel) @@ -208,12 +212,12 @@ defmodule Jido.Shell.ShellSessionServerTest do {:ok, server_pid} = ShellSession.lookup(session_id) {:ok, :accepted} = ShellSessionServer.run_command(session_id, "sleep 1") - assert_receive {:jido_shell_session, ^session_id, {:command_started, "sleep 1"}} + assert_receive {:jido_shell_session, ^session_id, {:command_started, "sleep 1"}}, @event_timeout {:ok, state} = ShellSessionServer.get_state(session_id) assert %{ref: ref, task: task_pid} = state.current_command send(server_pid, {:DOWN, ref, :process, task_pid, :boom}) - assert_receive {:jido_shell_session, ^session_id, {:command_crashed, :boom}} + assert_receive {:jido_shell_session, ^session_id, {:command_crashed, :boom}}, @event_timeout end test "ignores late command events and late finished messages after cancellation" do @@ -268,7 +272,7 @@ defmodule Jido.Shell.ShellSessionServerTest do end end) - assert_receive :registered + assert_receive :registered, @event_timeout assert Process.alive?(pid) assert {:error, %Jido.Shell.Error{code: {:session, :not_found}}} =