Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions lib/mix/tasks/merge_projects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,30 +89,34 @@ defmodule Mix.Tasks.Lightning.MergeProjects do
write_output(output, output_path)
end

defp perform_merge(source_project, target_project) do
defp perform_merge(source_data, target_data) do
source_project = atomize_keys(source_data)
target_project = atomize_keys(target_data)

MergeProjects.merge_project(source_project, target_project)
rescue
e in KeyError ->
ArgumentError ->
Mix.raise("""
Failed to merge projects - missing required field: #{inspect(e.key)}

#{Exception.message(e)}
Failed to merge projects - encountered unknown field in JSON

This may indicate incompatible or corrupted project state files.
Please verify both files are valid Lightning project exports.
This may indicate the JSON contains invalid or unexpected fields.
Please ensure both files are valid Lightning project exports.
""")
end

e ->
Mix.raise("""
Failed to merge projects

#{Exception.message(e)}
defp atomize_keys(data) when is_map(data) do
Map.new(data, fn {key, value} ->
atom_key = if is_binary(key), do: String.to_existing_atom(key), else: key
{atom_key, atomize_keys(value)}
end)
end

This may indicate incompatible project structures or corrupted data.
Please verify both files are valid Lightning project exports.
""")
defp atomize_keys(data) when is_list(data) do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@elias-ba If I comment out this method, no tests fail - is it still required?

Enum.map(data, &atomize_keys/1)
end

defp atomize_keys(data), do: data

defp encode_json(project) do
Jason.encode!(project, pretty: true)
rescue
Expand Down
208 changes: 189 additions & 19 deletions test/mix/tasks/merge_projects_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
source_state =
build_simple_project(
id: "source-id",
name: "Source",
name: "source-project",
workflow_name: "Test Workflow",
job_name: "Job 1",
job_body: "console.log('updated')"
Expand All @@ -21,7 +21,7 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
target_state =
build_simple_project(
id: "target-id",
name: "Target",
name: "target-project",
env: "production",
workflow_name: "Test Workflow",
job_name: "Job 1",
Expand All @@ -42,8 +42,7 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
{:ok, result} = Jason.decode(output)

assert result["id"] == "target-id"
assert result["name"] == "Target"
assert result["env"] == "production"
assert result["name"] == "target-project"
assert length(result["workflows"]) == 1

workflow = hd(result["workflows"])
Expand All @@ -59,10 +58,18 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
tmp_dir: tmp_dir
} do
source_state =
build_project_state(id: "source-id", name: "Source", workflows: [])
build_project_state(
id: "source-id",
name: "source-project",
workflows: []
)

target_state =
build_project_state(id: "target-id", name: "Target", workflows: [])
build_project_state(
id: "target-id",
name: "target-project",
workflows: []
)

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")
Expand All @@ -85,17 +92,25 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
{:ok, result} = Jason.decode(content)

assert result["id"] == "target-id"
assert result["name"] == "Target"
assert result["name"] == "target-project"
end

test "writes merged output to file when -o flag is provided", %{
tmp_dir: tmp_dir
} do
source_state =
build_project_state(id: "source-id", name: "Source", workflows: [])
build_project_state(
id: "source-id",
name: "source-project",
workflows: []
)

target_state =
build_project_state(id: "target-id", name: "Target", workflows: [])
build_project_state(
id: "target-id",
name: "target-project",
workflows: []
)

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")
Expand Down Expand Up @@ -267,8 +282,11 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
test "raises clear error when output directory does not exist", %{
tmp_dir: tmp_dir
} do
source_state = build_project_state(id: "s", name: "S", workflows: [])
target_state = build_project_state(id: "t", name: "T", workflows: [])
source_state =
build_project_state(id: "s", name: "source-project", workflows: [])

target_state =
build_project_state(id: "t", name: "target-project", workflows: [])

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")
Expand All @@ -292,8 +310,11 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
test "raises clear error when output directory is not writable", %{
tmp_dir: tmp_dir
} do
source_state = build_project_state(id: "s", name: "S", workflows: [])
target_state = build_project_state(id: "t", name: "T", workflows: [])
source_state =
build_project_state(id: "s", name: "source-project", workflows: [])

target_state =
build_project_state(id: "t", name: "target-project", workflows: [])

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")
Expand Down Expand Up @@ -325,8 +346,11 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
%{
tmp_dir: tmp_dir
} do
source_state = build_project_state(id: "s", name: "S", workflows: [])
target_state = build_project_state(id: "t", name: "T", workflows: [])
source_state =
build_project_state(id: "s", name: "source-project", workflows: [])

target_state =
build_project_state(id: "t", name: "target-project", workflows: [])

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")
Expand Down Expand Up @@ -356,14 +380,12 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
end

describe "run/1 - merge operation errors" do
test "raises clear error when project structure causes merge to fail", %{
test "raises error when project structure causes merge to fail", %{
tmp_dir: tmp_dir
} do
source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")

# Source with workflows as a string instead of array
# This will cause an error when MergeProjects tries to iterate
File.write!(
source_file,
Jason.encode!(%{
Expand All @@ -378,9 +400,157 @@ defmodule Mix.Tasks.Lightning.MergeProjectsTest do
Jason.encode!(%{"id" => "t", "name" => "T", "workflows" => []})
)

assert_raise Mix.Error, ~r/Failed to merge projects/, fn ->
# The merge will fail with Protocol.UndefinedError when trying to
# enumerate workflows (which is a string instead of a list)
# This is expected behavior - we let the merge algorithm fail naturally
assert_raise Protocol.UndefinedError, fn ->
Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file])
end
end
end

describe "run/1 - flexibility for testing (Joe's requirements)" do
test "allows non-UUID IDs for testing purposes", %{tmp_dir: tmp_dir} do
source_state =
build_simple_project(
id: "test-source-1",
name: "source-project",
workflow_name: "Test Workflow",
job_name: "Job 1",
job_body: "console.log('updated')"
)

target_state =
build_simple_project(
id: "test-target-1",
name: "target-project",
workflow_name: "Test Workflow",
job_name: "Job 1",
job_body: "console.log('old')"
)

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")

File.write!(source_file, Jason.encode!(source_state))
File.write!(target_file, Jason.encode!(target_state))

output =
capture_io(fn ->
Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file])
end)

{:ok, result} = Jason.decode(output)

assert result["id"] == "test-target-1"
assert result["name"] == "target-project"
end

test "handles projects with simple numeric IDs", %{tmp_dir: tmp_dir} do
source_state =
build_project_state(
id: "1",
name: "source-project",
workflows: []
)

target_state =
build_project_state(
id: "2",
name: "target-project",
workflows: []
)

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")

File.write!(source_file, Jason.encode!(source_state))
File.write!(target_file, Jason.encode!(target_state))

output =
capture_io(fn ->
Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file])
end)

{:ok, result} = Jason.decode(output)

assert result["id"] == "2"
assert result["name"] == "target-project"
end

test "successfully merges projects with deeply nested structures", %{
tmp_dir: tmp_dir
} do
source_state =
build_simple_project(
id: "source",
name: "Source Project",
workflow_name: "Workflow 1",
job_name: "Job 1",
job_body: "console.log('updated')"
)

target_state =
build_simple_project(
id: "target",
name: "Target Project",
workflow_name: "Workflow 1",
job_name: "Job 1",
job_body: "console.log('old')"
)

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")

File.write!(source_file, Jason.encode!(source_state))
File.write!(target_file, Jason.encode!(target_state))

output =
capture_io(fn ->
Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file])
end)

{:ok, result} = Jason.decode(output)

assert result["id"] == "target"

workflow = hd(result["workflows"])
assert workflow["name"] == "Workflow 1"

job = hd(workflow["jobs"])
assert job["body"] == "console.log('updated')"
assert job["name"] == "Job 1"
end

test "works offline without database access", %{tmp_dir: tmp_dir} do
source_state =
build_project_state(
id: "offline-source",
name: "source-project",
workflows: []
)

target_state =
build_project_state(
id: "offline-target",
name: "target-project",
workflows: []
)

source_file = Path.join(tmp_dir, "source.json")
target_file = Path.join(tmp_dir, "target.json")

File.write!(source_file, Jason.encode!(source_state))
File.write!(target_file, Jason.encode!(target_state))

output =
capture_io(fn ->
Mix.Tasks.Lightning.MergeProjects.run([source_file, target_file])
end)

{:ok, result} = Jason.decode(output)

assert result["id"] == "offline-target"
end
end
end