Packages
Capture dep's CHANGELOG updates during `mix deps.update` and collect in new file
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/deps.changelog.ex
defmodule Mix.Tasks.Deps.Changelog do
@changelog_filename "deps.CHANGELOG.md"
@snapshot_filename "deps.CHANGELOG.before.bin"
@shortdoc "Collect additions to top-level dependency's CHANGELOG files"
@moduledoc """
Find additions to top-level dependency's CHANGELOG files upon update and accumulate them in a new
`#{@changelog_filename}`. Any task can be specified to run to perform the update.
## Examples
```bash
mix deps.changelog deps.update --all
mix deps.changelog igniter.upgrade --all
```
```bash
mix deps.changelog --before
mix deps.unlock assent
mix deps.get
mix deps.changelog --after
```
## Command line options
Instead of wrapping a specified task, you can also run the before and after actions manually:
* `--before` - make a snapshot of the dependency's changelogs and store to file `#{@snapshot_filename}`
* `--after` - create another snapshot, process diff and update `#{@changelog_filename}`
"""
use Mix.Task
defmodule Changelogs do
@moduledoc false
@enforce_keys [:mix_dep]
defstruct [:mix_dep, changelog_before: nil, changelog_after: nil]
@type t :: %__MODULE__{
mix_dep: %Mix.Dep{},
changelog_before: String.t() | nil,
changelog_after: String.t() | nil
}
end
def run(["--before"]) do
try do
# Compile dependencies to ensure status information is available
Mix.Task.run("deps.compile")
original_deps_info = Mix.Dep.load_and_cache()
changelogs_before = before_update(original_deps_info)
record = {original_deps_info, changelogs_before}
case File.write(@snapshot_filename, :erlang.term_to_binary(record)) do
:ok ->
Mix.shell().info("Changelog snapshot created successfully")
{:error, reason} ->
Mix.shell().error(
"Failed to write #{@snapshot_filename}: #{:file.format_error(reason)}"
)
end
rescue
e ->
Mix.shell().error("Error creating changelog snapshot: #{Exception.message(e)}")
Mix.shell().error("Stacktrace: #{Exception.format_stacktrace(__STACKTRACE__)}")
end
end
def run(["--after"]) do
case File.read(@snapshot_filename) do
{:ok, binary} ->
try do
{original_deps_info, changelogs_before} = :erlang.binary_to_term(binary)
Mix.Dep.clear_cached()
new_deps_info = Mix.Dep.load_and_cache()
dep_changes = dep_changes_in_order(original_deps_info, new_deps_info)
case after_update(changelogs_before, dep_changes) do
{_, :updated} ->
Mix.shell().info("#{@changelog_filename} updated successfully")
{_, :no_changes} ->
Mix.shell().info(
"#{@changelog_filename} not updated (no changelog changes detected)"
)
end
rescue
e ->
Mix.shell().error("Error processing changelog: #{Exception.message(e)}")
Mix.shell().error("Stacktrace: #{Exception.format_stacktrace(__STACKTRACE__)}")
after
# Always cleanup, even if processing fails
case File.rm(@snapshot_filename) do
:ok ->
:ok
{:error, reason} ->
Mix.shell().error(
"Failed to remove #{@snapshot_filename}: #{:file.format_error(reason)}"
)
end
end
{:error, reason} ->
Mix.shell().error("Failed to read #{@snapshot_filename}: #{:file.format_error(reason)}")
end
end
def run([embedded_task | task_args]) do
Mix.Task.reenable("deps.changelog")
Mix.Task.run("deps.changelog", ["--before"])
# Not sure _why_ this is necessary, getting otherwise – sometimes:
# (UndefinedFunctionError) function Hex.Mix.overridden_deps/1 is undefined (module Hex.Mix is not available)
Mix.ensure_application!(:hex)
Mix.Task.run(embedded_task, task_args)
# Compile dependencies after update to ensure proper status information
Mix.Task.run("deps.compile")
Mix.Task.reenable("deps.changelog")
Mix.Task.run("deps.changelog", ["--after"])
end
@doc false
def before_update(original_deps_info) do
Enum.filter(original_deps_info, & &1.top_level)
|> Enum.map(fn dep ->
changelog_before = read_changelog(dep)
%Changelogs{
mix_dep: dep,
changelog_before: changelog_before
}
end)
end
@doc false
def after_update(changelogs, dep_changes, timestamp \\ nil) do
timestamp = timestamp || :calendar.local_time()
process_deps =
Enum.flat_map(changelogs, fn dep ->
if List.keyfind(dep_changes, dep.mix_dep.app, 0) do
[%{dep | changelog_after: read_changelog(dep.mix_dep)}]
else
[]
end
end)
|> Enum.sort_by(& &1.mix_dep.app)
summary_text =
Enum.reduce(process_deps, "", fn dep, acc ->
package_name = dep.mix_dep.app
{_name, old_version, new_version} = List.keyfind(dep_changes, package_name, 0)
diff =
List.myers_difference(
(dep.changelog_before || "") |> String.split("\n"),
(dep.changelog_after || "") |> String.split("\n")
)
insert_text =
Enum.filter(diff, fn {op, _} -> op == :ins end)
|> Enum.flat_map(&elem(&1, 1))
|> limit_vertical_whitespace(2)
|> shift_md_headings(2)
|> Enum.join("\n")
old_display = if old_version, do: old_version.display, else: "new"
new_display = new_version.display
acc <>
String.trim_trailing("""
### `#{package_name}` (#{old_display} âžž #{new_display})
#{insert_text}
""") <> "\n\n\n"
end)
|> String.trim_trailing()
if summary_text != "" do
update_summary_file(summary_text, timestamp)
{summary_text, :updated}
else
{summary_text, :no_changes}
end
end
defp read_changelog(dep) do
dest_path = dep.opts[:dest]
read_changelog =
File.read("#{dest_path}/CHANGELOG.md")
|> case do
{:error, _reason} -> File.read("#{dest_path}/CHANGELOG")
{:ok, _contents} = result -> result
end
case read_changelog do
{:ok, changelog_body} -> changelog_body
{:error, _reason} -> nil
end
end
defp update_summary_file(summary_text, timestamp) do
marker = "<!-- changelog -->"
default_header = """
# Dependencies Change Log
Auto-updated by `deps_changelog`. 💪
Feel free to edit this file by hand. Updates will be inserted below the following marker:
#{marker}
"""
months =
~w(January February March April May June July August September October November December)
{{year, month, day}, _time} = timestamp
local_date = "#{day}. #{Enum.at(months, month - 1)} #{year}"
date_header = underlined_md_heading("_#{local_date}_", 2) <> "\n\n"
updated_summary =
case File.read(@changelog_filename) do
{:ok, content} ->
String.replace(
content,
marker,
marker <> "\n\n" <> date_header <> summary_text <> "\n\n"
)
_ ->
default_header <> "\n" <> date_header <> summary_text <> "\n"
end
case File.write(@changelog_filename, updated_summary) do
:ok ->
:ok
{:error, reason} ->
Mix.shell().error("Failed to write #{@changelog_filename}: #{:file.format_error(reason)}")
end
end
defp underlined_md_heading(text, level) do
symbol =
case level do
1 -> "="
2 -> "-"
_ -> raise ArgumentError, "Unsupported heading level: #{level}"
end
underlines = String.duplicate(symbol, String.length(text))
"#{text}\n#{underlines}"
end
defp shift_md_headings(lines, shift) do
Enum.map(lines, fn line ->
case Regex.run(~r/^(#+) /, line) do
[_, old_prefix] ->
new_level = min(6, String.length(old_prefix) + shift)
new_prefix = String.duplicate("#", new_level)
String.replace_prefix(line, old_prefix, new_prefix)
_ ->
line
end
end)
end
defp limit_vertical_whitespace(lines, max) do
Enum.reduce(lines, {0, []}, fn line, {empty_count, acc} ->
if String.trim(line) == "" do
{empty_count + 1, acc}
else
{0, acc ++ Enum.take(List.duplicate("", empty_count), max) ++ [line]}
end
end)
|> elem(1)
end
# Version wrapper to handle both semantic versions and git hashes
defmodule UnifiedVersion do
@moduledoc false
defstruct [:type, :value, :display]
@type t :: %__MODULE__{
type: :semantic | :git_hash,
value: Version.t() | String.t(),
display: String.t()
}
def parse!(version_string) when is_binary(version_string) do
cond do
git_hash?(version_string) ->
%__MODULE__{
type: :git_hash,
value: version_string,
display: String.slice(version_string, 0, 8)
}
semantic_version?(version_string) ->
case Version.parse(version_string) do
{:ok, version} ->
%__MODULE__{
type: :semantic,
value: version,
display: version_string
}
:error ->
# Treat as git reference if not a valid semantic version
%__MODULE__{
type: :git_hash,
value: version_string,
display: version_string
}
end
true ->
# Default to treating as git reference
%__MODULE__{
type: :git_hash,
value: version_string,
display: version_string
}
end
end
def parse!(nil), do: nil
defp git_hash?(version_string) do
String.length(version_string) == 40 and
String.match?(version_string, ~r/^[a-f0-9]+$/i)
end
defp semantic_version?(version_string) do
# Check if it looks like a semantic version (with or without 'v' prefix)
cleaned = String.replace_prefix(version_string, "v", "")
String.match?(cleaned, ~r/^\d+\.\d+(\.\d+)?/)
end
end
# Helper function to extract version from dependency, trying multiple sources
defp get_dep_version(dep) do
case dep.status do
{_status, version} when is_binary(version) ->
version
_ ->
# Try to get version from lock info in opts
case Keyword.get(dep.opts, :lock) do
{_scm, _name, version, _hash, _build_tools, _deps, _repo, _checksum}
when is_binary(version) ->
version
{_scm, _name, version, _hash} when is_binary(version) ->
version
_ ->
nil
end
end
end
# from Igniter
@doc false
def dep_changes_in_order(old_deps_info, new_deps_info) do
new_deps_info
|> sort_deps()
|> Enum.flat_map(fn dep ->
case get_dep_version(dep) do
nil ->
# Skip dependencies without any version information
[]
new_version ->
case Enum.find(old_deps_info, &(&1.app == dep.app)) do
nil ->
[{dep.app, nil, UnifiedVersion.parse!(new_version)}]
old_dep ->
case get_dep_version(old_dep) do
nil ->
[]
old_version ->
[
{dep.app, UnifiedVersion.parse!(old_version),
UnifiedVersion.parse!(new_version)}
]
end
end
end
end)
|> Enum.reject(fn {_app, old, new} ->
old == new
end)
end
# from Igniter
defp sort_deps([]), do: []
defp sort_deps(deps) do
free_dep_name =
Enum.find_value(deps, fn %{app: app, deps: children} ->
if !Enum.any?(children, fn child ->
Enum.any?(deps, &(&1.app == child))
end) do
app
end
end)
next_dep_name = free_dep_name || elem(Enum.min_by(deps, &length(&1.deps)), 0)
{[next_dep], others} = Enum.split_with(deps, &(&1.app == next_dep_name))
[
next_dep
| sort_deps(
Enum.map(others, fn dep ->
%{dep | deps: Enum.reject(dep.deps, &(&1.app == next_dep_name))}
end)
)
]
end
end