# ABOUTME: Mix task implementing `mix chug.new` for creating changelog entry files.
# ABOUTME: Reads chug.config.yml from the project root and writes a timestamped YAML file to changes/.
defmodule Mix.Tasks.Chug.New do
@shortdoc "Create a new Chug changelog entry"
@moduledoc """
Creates a new pending changelog entry in the `changes/` directory.
## Usage
mix chug.new --description "Fix session timeout" --category bug
mix chug.new --description "Add export endpoint" --category feature --stories sc-1234,sc-5678
## Options
* `--description` - (required) User-facing description of the change
* `--category` - (required) Category for the change; must be one configured in `chug.config.yml`
* `--stories` - (optional) Comma-separated story/ticket references
## Configuration
Reads `chug.config.yml` from the current working directory. The `categories` key
determines which category values are valid.
"""
use Mix.Task
@config_file "chug.config.yml"
@changes_dir "changes"
@impl Mix.Task
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
strict: [description: :string, category: :string, stories: :string]
)
description = Keyword.get(opts, :description) || error_exit("--description is required")
category = Keyword.get(opts, :category) || error_exit("--category is required")
stories = parse_stories(Keyword.get(opts, :stories))
config = load_config()
valid_categories = Map.get(config, "categories", [])
unless category in valid_categories do
error_exit(
"Category '#{category}' is not in chug.config.yml. Valid categories: #{Enum.join(valid_categories, ", ")}"
)
end
File.mkdir_p!(@changes_dir)
path = Path.join(@changes_dir, filename_for(description))
content = build_entry(description, category, stories)
File.write!(path, content)
Mix.shell().info("Created changelog entry: #{path}")
end
defp load_config do
unless File.exists?(@config_file) do
error_exit("#{@config_file} not found. Run `chug init` to set up Chug in this repository.")
end
Application.ensure_all_started(:yaml_elixir)
case YamlElixir.read_from_file(@config_file) do
{:ok, config} -> config
{:error, reason} -> error_exit("Failed to read #{@config_file}: #{inspect(reason)}")
end
end
defp parse_stories(nil), do: []
defp parse_stories(stories_str) do
stories_str
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp filename_for(description) do
timestamp = DateTime.utc_now() |> Calendar.strftime("%Y-%m-%dT%H%M%S")
slug = description |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-") |> String.trim("-")
slug = if slug == "", do: "change", else: slug
"#{timestamp}-#{slug}.yml"
end
defp build_entry(description, category, stories) do
doc =
%{"description" => description, "category" => category}
|> maybe_put("stories", stories)
|> Map.put("authors", detect_authors())
Ymlr.document!(doc)
end
defp maybe_put(map, _key, []), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp detect_authors do
name = git_config("user.name")
github = git_config("github.user")
if name == nil and github == nil do
[]
else
author =
[name && {"name", name}, github && {"github", github}]
|> Enum.reject(&is_nil/1)
|> Map.new()
[author]
end
end
defp git_config(key) do
case System.cmd("git", ["config", key], stderr_to_stdout: false) do
{value, 0} ->
value = String.trim(value)
if value == "", do: nil, else: value
_ ->
nil
end
end
defp error_exit(message) do
Mix.raise(message)
end
end