fangorn/ex_git_objectstore
public
ref:main
# Copyright 2026 Cole Christensen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
Code.require_file("../../ci/pin_bump.exs", __DIR__)
defmodule ExGitObjectstore.CI.PinBumpTest do
@moduledoc """
The decidable parts of the pin bump (#81).
The fixtures below are the real shapes from Anvil's `mix.exs` and
`mix.lock`, trimmed. Testing against invented shapes would prove the regexes
match something, not that they match the files this actually rewrites.
"""
use ExUnit.Case, async: true
alias ExGitObjectstore.CI.PinBump
@old "228bb1ed4637ae81b0dc6be4801f86a55eff0888"
@new "5af34d4db754004bf2071bd5b97e28b447fcfc29"
# Trimmed from Anvil's mix.exs, keeping the shape that matters.
defp mix_exs do
"""
defp ex_git_objectstore_dep do
local_path =
System.get_env("EX_GIT_OBJECTSTORE_PATH") ||
Path.expand("../ex_git_objectstore", __DIR__)
if File.dir?(local_path) and System.get_env("EX_GIT_OBJECTSTORE_GIT") in [nil, "0"] do
[path: local_path]
else
[
git: "https://anvil.fangorn.io/fangorn/ex_git_objectstore.git",
ref: "#{@old}"
]
end
end
"""
end
# Trimmed from Anvil's mix.lock — neighbours kept so "nothing else moves"
# is actually observable.
defp mix_lock do
"""
%{
"ecto": {:hex, :ecto, "3.13.2", "abc", [:mix], [], "hexpm", "def"},
"ex_git_objectstore": {:git, "https://anvil.fangorn.io/fangorn/ex_git_objectstore.git", "#{@old}", [ref: "#{@old}"]},
"expo": {:hex, :expo, "1.1.0", "abc", [:mix], [], "hexpm", "def"},
}
"""
end
describe "reading the current pin" do
@tag requirements: ["REQ-CI-002"]
test "finds the SHA mix.exs pins" do
assert {:ok, @old} = PinBump.current_pin(mix_exs())
end
@tag requirements: ["REQ-CI-002"]
test "errors when the dependency is not in the expected shape" do
assert {:error, message} = PinBump.current_pin("defp deps, do: []")
assert message =~ "ref:"
end
end
describe "rewriting mix.exs" do
@tag requirements: ["REQ-CI-002"]
test "moves the ref and leaves everything else alone" do
assert {:ok, bumped} = PinBump.bump_mix_exs(mix_exs(), @new)
assert bumped =~ ~s(ref: "#{@new}")
refute bumped =~ @old
# The surrounding logic must survive untouched — this file decides
# whether Anvil builds against the sibling checkout or the pin.
assert bumped =~ "EX_GIT_OBJECTSTORE_PATH"
assert bumped =~ "[path: local_path]"
assert bumped =~ "git: \"https://anvil.fangorn.io/fangorn/ex_git_objectstore.git\""
# Only the SHA changed.
assert String.replace(bumped, @new, @old) == mix_exs()
end
@tag requirements: ["REQ-CI-002"]
test "refuses a file with no pin rather than writing one it did not find" do
assert {:error, message} = PinBump.bump_mix_exs("defp deps, do: []", @new)
assert message =~ "no `ref:"
end
@tag requirements: ["REQ-CI-002"]
test "refuses a file with more than one pin rather than guessing" do
doubled = mix_exs() <> ~s(\n ref: "#{String.duplicate("b", 40)}"\n)
assert {:error, message} = PinBump.bump_mix_exs(doubled, @new)
assert message =~ "refusing to guess"
end
@tag requirements: ["REQ-CI-002"]
test "rejects anything that is not a 40-hex SHA" do
for bad <- ["main", "", "abc", String.duplicate("z", 40), nil] do
assert {:error, message} = PinBump.bump_mix_exs(mix_exs(), bad)
assert message =~ "hex SHA"
end
end
end
describe "rewriting mix.lock" do
@tag requirements: ["REQ-CI-002"]
test "moves both SHAs in the entry and no other line" do
assert {:ok, bumped} = PinBump.bump_mix_lock(mix_lock(), @new)
assert bumped =~
~s("ex_git_objectstore": {:git, "https://anvil.fangorn.io/fangorn/ex_git_objectstore.git", "#{@new}", [ref: "#{@new}"]},)
refute bumped =~ @old
# Neighbouring entries are untouched.
assert bumped =~ ~s("ecto": {:hex, :ecto, "3.13.2")
assert bumped =~ ~s("expo": {:hex, :expo, "1.1.0")
assert String.replace(bumped, @new, @old) == mix_lock()
end
@tag requirements: ["REQ-CI-002"]
test "refuses a lock without the entry" do
lock = ~s(%{\n "ecto": {:hex, :ecto, "3.13.2", "abc", [:mix], [], "hexpm", "def"},\n}\n)
assert {:error, message} = PinBump.bump_mix_lock(lock, @new)
assert message =~ "expected shape"
end
@tag requirements: ["REQ-CI-002"]
test "rejects anything that is not a 40-hex SHA" do
assert {:error, message} = PinBump.bump_mix_lock(mix_lock(), "main")
assert message =~ "hex SHA"
end
end
describe "idempotency" do
@tag requirements: ["REQ-CI-001"]
test "updates the existing pin-bump PR when one is open" do
prs = [
%{"number" => 240, "head_branch" => "someone/unrelated"},
%{"number" => 241, "head_branch" => PinBump.branch()}
]
assert {:update, 241} = PinBump.pr_action(prs)
end
@tag requirements: ["REQ-CI-001"]
test "opens a new one when none is" do
assert :create = PinBump.pr_action([])
assert :create = PinBump.pr_action([%{"number" => 1, "head_branch" => "other"}])
end
# The whole point is that repeated merges do not pile up PRs, so this must
# never rewrite a PR that is not ours.
@tag requirements: ["REQ-CI-001"]
test "never touches a PR opened from another branch" do
prs = [
%{"number" => 10, "head_branch" => "feat/something"},
%{"number" => 11, "head_branch" => "chore/bump-something-else"}
]
assert :create = PinBump.pr_action(prs)
end
@tag requirements: ["REQ-CI-001"]
test "picks the lowest number so the choice is stable across runs" do
prs = [
%{"number" => 300, "head_branch" => PinBump.branch()},
%{"number" => 200, "head_branch" => PinBump.branch()}
]
assert {:update, 200} = PinBump.pr_action(prs)
end
end
describe "reading the PR listing" do
# The real payload from `anvil pr list --json`: an object carrying
# `pull_requests` alongside pagination, not a bare array. Decoding this
# wrong reads as "no PR is open" and opens a duplicate every run.
@tag requirements: ["REQ-CI-001"]
test "pulls the list out of the paginated envelope" do
json = """
{
"page": 1,
"per_page": 30,
"pull_requests": [
{"number": 242, "head_branch": "fix/something", "state": "open"},
{"number": 243, "head_branch": "#{PinBump.branch()}", "state": "open"}
]
}
"""
prs = PinBump.decode_prs(json)
assert length(prs) == 2
assert {:update, 243} = PinBump.pr_action(prs)
end
@tag requirements: ["REQ-CI-001"]
test "accepts a bare array too" do
json = ~s([{"number": 1, "head_branch": "#{PinBump.branch()}"}])
assert {:update, 1} = PinBump.pr_action(PinBump.decode_prs(json))
end
@tag requirements: ["REQ-CI-001"]
test "an empty listing means there is genuinely nothing open" do
assert :create = PinBump.pr_action(PinBump.decode_prs(~s({"pull_requests": []})))
end
end
describe "the cross-repo credential" do
@tag requirements: ["REQ-CI-003"]
test "is read from its own variable, not the injected job token" do
refute PinBump.token_var() == "ANVIL_TOKEN",
"CI secrets merge over the job environment, so reusing ANVIL_TOKEN " <>
"would replace the injected per-job token for the whole job"
assert {:ok, "tok"} = PinBump.fetch_token(%{PinBump.token_var() => "tok"})
end
@tag requirements: ["REQ-CI-003"]
test "absence is an error naming the secret, the repo and the permission" do
for env <- [%{}, %{PinBump.token_var() => ""}, %{PinBump.token_var() => " "}] do
assert {:error, message} = PinBump.fetch_token(env)
assert message =~ PinBump.token_var()
assert message =~ PinBump.anvil_repo()
assert message =~ "contents: write"
end
end
@tag requirements: ["REQ-CI-003"]
test "the injected job token alone is not enough" do
assert {:error, _} = PinBump.fetch_token(%{"ANVIL_TOKEN" => "job-scoped"})
end
end
describe "the generated pull request" do
@tag requirements: ["REQ-CI-002"]
test "shows a reviewer which commits are being pulled in" do
body = PinBump.pr_body(@old, @new, ["abc1234 fix(a): one", "def5678 feat(b): two"])
assert body =~ "- abc1234 fix(a): one"
assert body =~ "- def5678 feat(b): two"
assert body =~ @old
assert body =~ @new
end
@tag requirements: ["REQ-CI-002"]
test "says so plainly when the commit range came back empty" do
body = PinBump.pr_body(@old, @new, [])
refute body =~ "- \n"
assert body =~ "no commits listed"
end
@tag requirements: ["REQ-CI-001"]
test "states that it is never merged automatically" do
body = PinBump.pr_body(@old, @new, ["abc1234 x"])
# Assert on phrases that cannot straddle a line wrap.
assert body =~ "never merged"
assert body =~ "Anvil's CI is the gate"
end
@tag requirements: ["REQ-CI-002"]
test "the title carries the short SHA" do
assert PinBump.pr_title(@new) =~ String.slice(@new, 0, 8)
end
end
end