Changelog
Conventional Commits read: each message's type, scope and breaking change, the next semantic version, release notes in Markdown, and a lint for commit messages.
Version 1.0.0 · 5 functions · 0 crates
Functions
What use changelog.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use changelog_parse, changelog_next_version from changelog (Quillo 0.6).
changelog_parse(message: str) → anyOne commit message read: { type, scope, breaking, breaking_note, description, body, footers, issues, conventional }. breaking from a ! before the colon or a BREAKING CHANGE: footer; issues from Closes, Fixes, Refs footers. A message that is not a Conventional Commit has conventional false, type null and its first line as the description.
changelog_next_version(current: str, commits: list) → anyor an errorThe next version after current ("1.4.2", a leading "v" kept) for these commits (messages, or maps with message): { version, bump }, bump "major" for a breaking change, "minor" for a feature, "patch" for a fix, a perf change or a revert, "none" (version unchanged) for the rest. Below 1.0.0 a breaking change bumps the minor and a feature the patch, as semver's 0.x versions do.
changelog_group(commits: list) → anyor an errorThe commits grouped for release notes: { breaking, feat, fix, perf, revert, refactor, docs, other }, each a list of parsed commits (as changelog_parse gives them, with id when the commit had one). other holds the rest, chores and tests included.
changelog_markdown(version: str, date: str, commits: list, repo_url: str) → stror an errorRelease notes in Markdown for version on date ("2026-10-29"): ## version (date), then Breaking Changes, Features, Bug Fixes, Performance, Reverts, Code Refactoring and Documentation, scopes in bold. With repo_url ("https://github.com/acme/shop", or "") commit ids become links. Chores, tests, CI, style and non-conventional commits are left out.
changelog_lint(message: str) → listWhat is wrong with a commit message against Conventional Commits (empty when it is fine), as commitlint's usual rules say it: a missing or unknown type, an empty description, one ending with a full stop, a header over 100 characters, no blank line before the body.
Get it
$ quillo install changelog # changelog.qwer into this folder$ quillo install changelog --example # and its example, changelog.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/changelog.qwer$ curl -O https://quillolang.org/plugins/files/changelog.qweThen, in your program, all of it or only what you call:
use changelog.qweruse changelog_parse, changelog_next_version from changelog // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// A release from the commits since the last tag: the next version, the release notes, and// a check of a commit message as a pre-commit hook would make it.//// quillo run changelog.qwe then open http://localhost:8080/releaseuse "changelog.qwer"let COMMITS = [ { id: "a1b2c3d4e5", message: "feat(cart): keep the cart for 30 days\n\nCarts were kept for a session only.\n\nCloses #412" }, { id: "b2c3d4e5f6", message: "fix(checkout): round VAT per line, not per order" }, { id: "c3d4e5f6a7", message: "feat(api)!: orders list paged by cursor\n\nBREAKING CHANGE: `page` is gone; pass `after` from the last answer." }, { id: "d4e5f6a7b8", message: "perf: index orders by customer" }, { id: "e5f6a7b8c9", message: "chore: bump dependencies" }, { id: "f6a7b8c9d0", message: "Merge branch 'main' into cart" },]route GET /release { let next = changelog_next_version("v2.3.1", COMMITS)? respond(200, changelog_markdown(next.version, "2026-10-29", COMMITS, "https://github.com/acme/shop")?, { "content-type": "text/markdown; charset=utf-8" })}route GET /next = { after_2_3_1: changelog_next_version("v2.3.1", COMMITS)?, fixes_only: changelog_next_version("2.3.1", ["fix: a typo in the receipt", "docs: the README"])?, before_1_0: changelog_next_version("0.4.0", ["feat!: new config file"])?,}route GET /parse = changelog_parse(COMMITS[0].message)route GET /groups { let g = changelog_group(COMMITS)? { breaking: g.breaking | map((c) => c.breaking_note), feat: g.feat | map((c) => c.description), other: g.other | map((c) => c.description) }}route GET /lint = { good: changelog_lint("fix(auth): refresh the token before it expires"), bad: changelog_lint("Fixed the login.\nIt was broken"), unknown: changelog_lint("feature: dark mode"),}$ quillo run changelog.qwe$ curl 'localhost:8080/release'$ curl 'localhost:8080/next'$ curl 'localhost:8080/parse'$ curl 'localhost:8080/lint'Source
//! Conventional Commits read: each message's type, scope and breaking change, the next semantic version, and the release notes in Markdown.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Value};const TYPES: [&str; 11] = ["feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore", "revert"];struct Commit { kind: Option<String>, scope: Option<String>, breaking: Option<String>, description: String, body: String, footers: Vec<(String, String)>, id: Option<String>,}fn is_footer_start(line: &str) -> Option<(String, String)> { if let Some(rest) = line.strip_prefix("BREAKING CHANGE: ").or_else(|| line.strip_prefix("BREAKING-CHANGE: ")) { return Some(("BREAKING CHANGE".into(), rest.to_string())); } let (tok, sep_len) = if let Some(i) = line.find(": ") { (&line[..i], (i, 2)) } else if let Some(i) = line.find(" #") { (&line[..i], (i, 1)) } else { return None; }; if tok.is_empty() || !tok.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { return None; } Some((tok.to_string(), line[sep_len.0 + sep_len.1..].trim().to_string()))}fn parse(message: &str, id: Option<String>) -> Commit { let message = message.replace("\r\n", "\n"); let mut lines = message.trim().lines(); let header = lines.next().unwrap_or("").trim().to_string(); let rest: Vec<&str> = lines.collect(); // Footers: the last paragraph, when its lines are `Token: value` or `Token #value` (with continuation lines). let mut footers: Vec<(String, String)> = vec![]; let mut body_lines: Vec<&str> = rest.clone(); if let Some(start) = rest.iter().rposition(|l| l.trim().is_empty()).map(|i| i + 1).or(Some(0)) { let para = &rest[start..]; if para.first().is_some_and(|l| is_footer_start(l).is_some()) { for l in para { match is_footer_start(l) { Some(f) => footers.push(f), None => { if let Some(last) = footers.last_mut() { last.1.push('\n'); last.1.push_str(l); } } } } body_lines = rest[..start].to_vec(); } } let body = body_lines.join("\n").trim().to_string(); let mut c = Commit { kind: None, scope: None, breaking: None, description: header.clone(), body, footers, id }; if let Some(colon) = header.find(": ") { let head = &header[..colon]; let (head, bang) = match head.strip_suffix('!') { Some(h) => (h, true), None => (head, false), }; let (kind, scope) = match head.find('(') { Some(p) if head.ends_with(')') => (&head[..p], Some(head[p + 1..head.len() - 1].to_string())), _ => (head, None), }; if !kind.is_empty() && kind.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { c.kind = Some(kind.to_lowercase()); c.scope = scope.filter(|s| !s.is_empty()); c.description = header[colon + 2..].trim().to_string(); if bang { c.breaking = Some(c.description.clone()); } } } if let Some((_, v)) = c.footers.iter().find(|(k, _)| k == "BREAKING CHANGE") { c.breaking = Some(v.clone()); } c}fn commit_of(v: &Value) -> Result<Commit, String> { match v { Value::String(s) => Ok(parse(s, None)), Value::Object(o) => { let m = o.get("message").or_else(|| o.get("summary")).and_then(Value::as_str).ok_or("a commit is its message, or a map with `message`")?; let id = o.get("short").or_else(|| o.get("id")).or_else(|| o.get("hash")).and_then(Value::as_str).map(|s| s.chars().take(7).collect()); Ok(parse(m, id)) } _ => Err("a commit is its message, or a map with `message`".into()), }}fn to_value(c: &Commit) -> Value { let issues: Vec<String> = c .footers .iter() .filter(|(k, _)| matches!(k.to_lowercase().as_str(), "closes" | "fixes" | "refs" | "resolves" | "close" | "fix")) .flat_map(|(_, v)| v.split([',', ' ']).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect::<Vec<_>>()) .collect(); let footers: serde_json::Map<String, Value> = c.footers.iter().map(|(k, v)| (k.clone(), json!(v))).collect(); json!({ "type": c.kind, "scope": c.scope, "breaking": c.breaking.is_some(), "breaking_note": c.breaking, "description": c.description, "body": c.body, "footers": footers, "issues": issues, "conventional": c.kind.is_some(), "id": c.id, })}/// One commit message read: `{ type, scope, breaking, breaking_note, description, body, footers, issues, conventional }`. `breaking` from a `!`/// before the colon or a `BREAKING CHANGE:` footer; `issues` from `Closes`, `Fixes`, `Refs` footers. A message that is not a Conventional/// Commit has `conventional` false, `type` null and its first line as the description.pub fn changelog_parse(message: &str) -> Value { to_value(&parse(message, None))}fn bump_of(cs: &[Commit]) -> &'static str { if cs.iter().any(|c| c.breaking.is_some()) { "major" } else if cs.iter().any(|c| c.kind.as_deref() == Some("feat")) { "minor" } else if cs.iter().any(|c| matches!(c.kind.as_deref(), Some("fix" | "perf" | "revert"))) { "patch" } else { "none" }}/// The next version after `current` ("1.4.2", a leading "v" kept) for these commits (messages, or maps with `message`): `{ version, bump }`,/// bump "major" for a breaking change, "minor" for a feature, "patch" for a fix, a perf change or a revert, "none" (version unchanged) for the rest./// Below 1.0.0 a breaking change bumps the minor and a feature the patch, as semver's 0.x versions do.pub fn changelog_next_version(current: &str, commits: Vec<serde_json::Value>) -> Result<Value, String> { let cs = commits.iter().map(commit_of).collect::<Result<Vec<_>, _>>()?; let v = current.trim(); let (prefix, bare) = match v.strip_prefix('v') { Some(b) => ("v", b), None => ("", v), }; let core = bare.split(['-', '+']).next().unwrap_or(""); let n: Vec<u64> = core.split('.').map(|p| p.parse::<u64>()).collect::<Result<_, _>>().map_err(|_| format!("`{}` is not a version like 1.4.2", current))?; if n.len() != 3 { return Err(format!("`{}` is not a version like 1.4.2", current)); } let (mut ma, mut mi, mut pa) = (n[0], n[1], n[2]); let mut bump = bump_of(&cs); if ma == 0 { bump = match bump { "major" => "minor", "minor" => "patch", b => b, }; } match bump { "major" => (ma, mi, pa) = (ma + 1, 0, 0), "minor" => (mi, pa) = (mi + 1, 0), "patch" => pa += 1, _ => {} } let version = if bump == "none" { v.to_string() } else { format!("{}{}.{}.{}", prefix, ma, mi, pa) }; Ok(json!({ "version": version, "bump": bump }))}fn section(kind: &str) -> Option<&'static str> { Some(match kind { "feat" => "Features", "fix" => "Bug Fixes", "perf" => "Performance", "revert" => "Reverts", "refactor" => "Code Refactoring", "docs" => "Documentation", _ => return None, })}fn line(c: &Commit, text: &str, repo_url: &str) -> String { let scope = c.scope.as_ref().map(|s| format!("**{}:** ", s)).unwrap_or_default(); let id = match (&c.id, repo_url.is_empty()) { (Some(id), true) => format!(" ({})", id), (Some(id), false) => format!(" ([{}]({}/commit/{}))", id, repo_url.trim_end_matches('/'), id), _ => String::new(), }; format!("* {}{}{}\n", scope, text.lines().next().unwrap_or(""), id)}/// The commits grouped for release notes: `{ breaking, feat, fix, perf, revert, refactor, docs, other }`, each a list of parsed commits/// (as `changelog_parse` gives them, with `id` when the commit had one). `other` holds the rest, chores and tests included.pub fn changelog_group(commits: Vec<serde_json::Value>) -> Result<Value, String> { let cs = commits.iter().map(commit_of).collect::<Result<Vec<_>, _>>()?; let mut out = serde_json::Map::new(); for k in ["breaking", "feat", "fix", "perf", "revert", "refactor", "docs", "other"] { out.insert(k.into(), json!([])); } for c in &cs { if c.breaking.is_some() { out["breaking"].as_array_mut().unwrap().push(to_value(c)); } let k = c.kind.as_deref().filter(|k| section(k).is_some()).unwrap_or("other"); out[k].as_array_mut().unwrap().push(to_value(c)); } Ok(Value::Object(out))}/// Release notes in Markdown for `version` on `date` ("2026-10-29"): `## version (date)`, then Breaking Changes, Features, Bug Fixes,/// Performance, Reverts, Code Refactoring and Documentation, scopes in bold. With `repo_url` ("https://github.com/acme/shop", or "")/// commit ids become links. Chores, tests, CI, style and non-conventional commits are left out.pub fn changelog_markdown(version: &str, date: &str, commits: Vec<serde_json::Value>, repo_url: &str) -> Result<String, String> { let cs = commits.iter().map(commit_of).collect::<Result<Vec<_>, _>>()?; let mut md = if date.is_empty() { format!("## {}\n", version) } else { format!("## {} ({})\n", version, date) }; let breaking: Vec<&Commit> = cs.iter().filter(|c| c.breaking.is_some()).collect(); if !breaking.is_empty() { md.push_str("\n### ⚠ BREAKING CHANGES\n\n"); for c in breaking { md.push_str(&line(c, c.breaking.as_deref().unwrap_or(""), repo_url)); } } for k in ["feat", "fix", "perf", "revert", "refactor", "docs"] { let these: Vec<&Commit> = cs.iter().filter(|c| c.kind.as_deref() == Some(k)).collect(); if these.is_empty() { continue; } md.push_str(&format!("\n### {}\n\n", section(k).unwrap())); for c in these { md.push_str(&line(c, &c.description, repo_url)); } } Ok(md)}/// What is wrong with a commit message against Conventional Commits (empty when it is fine), as commitlint's usual rules say it:/// a missing or unknown type, an empty description, one ending with a full stop, a header over 100 characters, no blank line before the body.pub fn changelog_lint(message: &str) -> Vec<String> { let mut out = vec![]; let c = parse(message, None); let header = message.trim().lines().next().unwrap_or(""); match &c.kind { None => out.push("the header is not `type(scope): description`".to_string()), Some(k) if !TYPES.contains(&k.as_str()) => out.push(format!("unknown type `{}`: {}", k, TYPES.join(", "))), _ => {} } if c.kind.is_some() && c.description.trim().is_empty() { out.push("the description is empty".into()); } if c.description.trim_end().ends_with('.') { out.push("the description ends with a full stop".into()); } if header.chars().count() > 100 { out.push(format!("the header is {} characters, over 100", header.chars().count())); } if message.trim().lines().nth(1).is_some_and(|l| !l.trim().is_empty()) { out.push("the body must start after a blank line".into()); } out}Related
git_log.qwer · 1.0.0Read a git repository without git installed: recent commits and their authors, the files each changed, and any file as it was at a commit.7 views · 7 downloadsSemantic versions semver.qwer · 1.0.0Versions compared, matched against ranges as Cargo and npm write them, the newest in a range, and bumped.7 views · 9 downloads.gitignore matching gitignore.qwer · 1.0.0.gitignore rules matched by a port of git's own wildmatch and checked against `git check-ignore` on thousands of random cases: negation, anchoring, `**`, directories, nested files with a deeper one winning, and folders that cannot come back once excluded. Says which rule decided, and walks a real tree as git would.4 views · 9 downloadsFeature flags feature_flags.qwer · 1.0.0Feature flags kept as data: kill switches, users named outright, rules on their attributes, and percentage roll-outs and variants that stay the same for each user.7 views · 10 downloadsGit commands git_cli.qwer · 1.0.0The git command driven through argument lists and parsed to maps: status --porcelain=v2 with staged, unstaged, untracked and conflicted files, branches with upstreams, tags, diff --numstat with renames and binaries, blame --porcelain, show, and a file at a revision. Unlike git_log, it reads working trees; a repository's config cannot run commands through it.2 views · 6 downloadsJupyter notebooks ipynb.qwer · 1.0.0Jupyter notebooks read and written with Jupyter's own layout: cells, outputs, tracebacks without their colour codes, nbformat 3 brought up to 4. Outputs and counts stripped for commits, tagged cells kept, Markdown with fences that survive backticks, a script with magics commented out, and image outputs pulled out to files.3 views · 8 downloadsThread 0 posts
On Changelog: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.