# 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