use crate::config::{
resolve_openrouter_release_notes_model, resolve_openrouter_release_notes_system_prompt,
};
use crate::openrouter::complete_prompt;
use regex::Regex;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use toml::Value as TomlValue;
use super::release_ledger::ReleasePublishTarget;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ReleaseCommitEntry {
pub(crate) full_sha: String,
pub(crate) short_sha: String,
pub(crate) subject: String,
pub(crate) date: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ReleaseBullet {
pub(crate) commit_shas: Vec<String>,
pub(crate) summary: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ReleaseSection {
pub(crate) title: String,
pub(crate) summary: String,
pub(crate) bullets: Vec<ReleaseBullet>,
}
#[derive(Clone, Copy)]
struct TopicDescriptor {
title: &'static str,
topic_key: &'static str,
lead_in: &'static str,
}
struct TopicAccumulator {
key: &'static str,
lead_in: &'static str,
commit_shas: Vec<String>,
fragments: Vec<String>,
}
struct SectionAccumulator {
title: &'static str,
topics: Vec<TopicAccumulator>,
}
#[derive(Debug, Deserialize)]
struct AiReleaseNotes {
#[serde(default)]
sections: Vec<AiReleaseSection>,
}
#[derive(Debug, Deserialize)]
struct AiReleaseSection {
title: String,
#[serde(default)]
summary: String,
#[serde(default)]
bullets: Vec<AiReleaseBullet>,
}
#[derive(Debug, Deserialize)]
struct AiReleaseBullet {
#[serde(default)]
commit_shas: Vec<String>,
summary: String,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct GithubPullRequestInfo {
pub(crate) title: String,
pub(crate) url: String,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct LinearIssueInfo {
pub(crate) title: String,
pub(crate) url: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ReleaseInstallMethod {
pub(crate) title: String,
pub(crate) commands: Vec<String>,
pub(crate) language: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ReleaseInstallSnippet {
pub(crate) kind: String,
pub(crate) package_name: String,
pub(crate) version: String,
pub(crate) methods: Vec<ReleaseInstallMethod>,
pub(crate) registry_url: Option<String>,
}
pub(crate) struct ReleaseNotesRequest<'a> {
pub(crate) project_root: &'a Path,
pub(crate) version_scope: &'a super::VersionScope,
pub(crate) release_title: &'a str,
pub(crate) current_tag_name: &'a str,
pub(crate) owner: &'a str,
pub(crate) repo: &'a str,
pub(crate) github_token: &'a str,
pub(crate) linear_api_key: Option<&'a str>,
pub(crate) openrouter_api_key: Option<&'a str>,
pub(crate) path_filter: Option<&'a str>,
pub(crate) install_snippets: &'a [ReleaseInstallSnippet],
}
pub(crate) struct ReleaseNotesRenderInput<'a> {
pub(crate) release_title: &'a str,
pub(crate) current_tag_name: &'a str,
pub(crate) owner: &'a str,
pub(crate) repo: &'a str,
pub(crate) previous_tag: Option<&'a str>,
pub(crate) sections: &'a [ReleaseSection],
pub(crate) commit_entries: &'a [ReleaseCommitEntry],
pub(crate) pull_request_infos: &'a BTreeMap<String, GithubPullRequestInfo>,
pub(crate) linear_issue_infos: &'a BTreeMap<String, LinearIssueInfo>,
pub(crate) install_snippets: &'a [ReleaseInstallSnippet],
}
pub(crate) async fn generate_release_notes(
request: &ReleaseNotesRequest<'_>,
) -> Result<String, String> {
let previous_tag = super::previous_release_tag(
request.project_root,
request.current_tag_name,
request.version_scope,
)?;
if let Some(tag) = previous_tag.as_deref() {
super::ensure_git_tag_available_for_log(request.project_root, tag)?;
}
let mut args: Vec<&str> = vec![
"log",
"--pretty=format:%H%x1f%h%x1f%s%x1f%ad",
"--date=short",
"--no-merges",
];
let range;
if let Some(tag) = &previous_tag {
range = format!("{}..HEAD", tag);
args.push(&range);
} else {
args.extend(["-n", "30"]);
}
let filtered_path = request
.path_filter
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(filtered_path) = filtered_path {
args.push("--");
args.push(filtered_path);
}
let commits = super::run_git_command(request.project_root, &args)?;
let parsed_commits: Vec<ReleaseCommitEntry> = commits
.lines()
.filter_map(|line| parse_release_commit_entry(line.trim()))
.collect();
let deduplicated_commits = deduplicate_release_commit_entries(&parsed_commits);
let pull_request_infos = resolve_release_pull_request_infos(
&deduplicated_commits,
request.owner,
request.repo,
request.github_token,
)
.await;
let linear_issue_infos =
resolve_release_linear_issue_infos(&deduplicated_commits, request.linear_api_key).await;
let sections = try_generate_release_sections_with_openrouter(
request.release_title,
request.current_tag_name,
request.owner,
request.repo,
previous_tag.as_deref(),
&deduplicated_commits,
request.openrouter_api_key,
)
.await
.unwrap_or_else(|| build_fallback_sections(&deduplicated_commits));
Ok(render_release_notes(&ReleaseNotesRenderInput {
release_title: request.release_title,
current_tag_name: request.current_tag_name,
owner: request.owner,
repo: request.repo,
previous_tag: previous_tag.as_deref(),
sections: §ions,
commit_entries: &deduplicated_commits,
pull_request_infos: &pull_request_infos,
linear_issue_infos: &linear_issue_infos,
install_snippets: request.install_snippets,
}))
}
pub(crate) fn build_release_install_snippets(
project_root: &Path,
publish_targets: &[ReleasePublishTarget],
version: &str,
) -> Vec<ReleaseInstallSnippet> {
let version = version.trim();
if version.is_empty() {
return Vec::new();
}
let mut snippets = Vec::new();
let mut seen = BTreeSet::new();
for target in publish_targets {
let kind = target.kind.trim().to_ascii_lowercase();
if kind != "npm" && kind != "crates" {
continue;
}
let manifest_path = project_root.join(&target.manifest_path);
let package_name = target
.package_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| detect_package_name_for_install(&kind, &manifest_path));
let Some(package_name) = package_name else {
continue;
};
let dedupe_key = format!("{kind}:{package_name}");
if !seen.insert(dedupe_key) {
continue;
}
let methods =
install_methods_for_package(&kind, &package_name, version, &manifest_path, project_root);
if methods.is_empty() {
continue;
}
snippets.push(ReleaseInstallSnippet {
registry_url: registry_url_for_package(&kind, &package_name),
kind,
package_name: package_name.clone(),
version: version.to_string(),
methods,
});
}
snippets
}
fn detect_package_name_for_install(kind: &str, manifest_path: &Path) -> Option<String> {
match kind {
"npm" => {
let content = fs::read_to_string(manifest_path).ok()?;
let json: JsonValue = serde_json::from_str(&content).ok()?;
json.get("name")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
"crates" => {
let content = fs::read_to_string(manifest_path).ok()?;
let value: TomlValue = toml::from_str(&content).ok()?;
value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("name"))
.and_then(TomlValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
_ => None,
}
}
fn install_methods_for_package(
kind: &str,
package_name: &str,
version: &str,
manifest_path: &Path,
project_root: &Path,
) -> Vec<ReleaseInstallMethod> {
match kind {
"crates" => {
if cargo_manifest_is_installable(manifest_path) {
vec![ReleaseInstallMethod {
title: "Install".to_string(),
commands: vec![format!("cargo install {package_name}@{version}")],
language: "bash".to_string(),
}]
} else {
vec![ReleaseInstallMethod {
title: "Install".to_string(),
commands: vec![format!("cargo add {package_name}@{version}")],
language: "bash".to_string(),
}]
}
}
"npm" => {
let manager = detect_js_package_manager(manifest_path, project_root);
let command = match manager {
"pnpm" => format!("pnpm add {package_name}@{version}"),
"yarn" => format!("yarn add {package_name}@{version}"),
_ => format!("npm install {package_name}@{version}"),
};
vec![ReleaseInstallMethod {
title: "Install".to_string(),
commands: vec![command],
language: "bash".to_string(),
}]
}
_ => Vec::new(),
}
}
fn detect_js_package_manager(manifest_path: &Path, project_root: &Path) -> &'static str {
let stop = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.to_path_buf());
let mut dir = manifest_path
.parent()
.map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
.unwrap_or_else(|| stop.clone());
loop {
if dir.join("pnpm-lock.yaml").is_file() {
return "pnpm";
}
if dir.join("yarn.lock").is_file() {
return "yarn";
}
if dir.join("package-lock.json").is_file() {
return "npm";
}
if dir.join("bun.lockb").is_file() || dir.join("bun.lock").is_file() {
return "npm";
}
if dir == stop {
break;
}
let Some(parent) = dir.parent() else {
break;
};
if parent == dir {
break;
}
if !parent.starts_with(&stop) && parent != stop {
break;
}
dir = parent.to_path_buf();
}
"npm"
}
fn registry_url_for_package(kind: &str, package_name: &str) -> Option<String> {
match kind {
"crates" => Some(format!("https://crates.io/crates/{package_name}")),
"npm" => {
let path = package_name
.split('/')
.map(|segment| {
segment.to_string()
})
.collect::<Vec<_>>()
.join("/");
Some(format!("https://www.npmjs.com/package/{path}"))
}
_ => None,
}
}
fn cargo_manifest_is_installable(manifest_path: &Path) -> bool {
let Ok(content) = fs::read_to_string(manifest_path) else {
return false;
};
let Ok(value) = toml::from_str::<TomlValue>(&content) else {
return false;
};
if value.get("bin").is_some() {
return true;
}
let crate_dir = manifest_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
if crate_dir.join("src").join("main.rs").is_file() {
return true;
}
let bin_dir = crate_dir.join("src").join("bin");
if bin_dir.is_dir() {
if let Ok(entries) = fs::read_dir(&bin_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
return true;
}
if path.is_dir() && path.join("main.rs").is_file() {
return true;
}
}
}
}
false
}
fn parse_release_commit_entry(raw_line: &str) -> Option<ReleaseCommitEntry> {
let mut parts = raw_line.splitn(4, '\u{1f}');
let full_sha = parts.next()?.trim();
let short_sha = parts.next()?.trim();
let subject = parts.next()?.trim();
let date = parts.next()?.trim();
if full_sha.is_empty() || short_sha.is_empty() || subject.is_empty() {
return None;
}
Some(ReleaseCommitEntry {
full_sha: full_sha.to_string(),
short_sha: short_sha.to_string(),
subject: subject.to_string(),
date: date.to_string(),
})
}
pub(crate) fn deduplicate_release_commit_entries(
commit_entries: &[ReleaseCommitEntry],
) -> Vec<ReleaseCommitEntry> {
let mut seen_subjects = BTreeSet::new();
let mut deduplicated = Vec::new();
for commit_entry in commit_entries {
let subject_key = commit_entry.subject.trim();
if subject_key.is_empty() || !seen_subjects.insert(subject_key.to_string()) {
continue;
}
deduplicated.push(commit_entry.clone());
}
deduplicated
}
pub(crate) fn render_release_notes(render_input: &ReleaseNotesRenderInput<'_>) -> String {
let repository_url = github_repository_url(render_input.owner, render_input.repo);
let release_url = github_release_url(
render_input.owner,
render_input.repo,
render_input.current_tag_name,
);
let mut lines = vec![
render_release_heading(
render_input.release_title,
&release_url,
render_input.repo,
&repository_url,
),
String::new(),
];
lines.push("## What's Changed".to_string());
lines.push(String::new());
if let Some(tag) = render_input.previous_tag {
lines.push(format!(
"Comparing changes since [{}]({}).",
tag,
github_release_url(render_input.owner, render_input.repo, tag)
));
lines.push(String::new());
}
if render_input.sections.is_empty() {
lines.push(render_unstructured_commit_fallback(
render_input.commit_entries,
render_input.owner,
render_input.repo,
render_input.linear_issue_infos,
));
lines.push(String::new());
} else {
for section in render_input.sections {
lines.push(format!("### {}", section.title));
lines.push(String::new());
if !section.summary.trim().is_empty() {
lines.push(section.summary.trim().to_string());
lines.push(String::new());
}
for bullet in §ion.bullets {
let commit_links = bullet
.commit_shas
.iter()
.filter_map(|sha| {
render_input
.commit_entries
.iter()
.find(|entry| entry.short_sha == *sha)
.map(|entry| {
format!(
"[{}]({})",
entry.short_sha,
github_commit_url(
render_input.owner,
render_input.repo,
&entry.full_sha,
)
)
})
})
.collect::<Vec<_>>();
let summary = bullet.summary.trim();
if commit_links.is_empty() {
lines.push(format!("- {}", summary));
} else {
lines.push(format!("- {} {}", commit_links.join(", "), summary));
}
}
let section_commits = section_commit_entries(section, render_input.commit_entries);
for pull_request_number in collect_pull_request_numbers_from_entries(§ion_commits) {
let url = render_input
.pull_request_infos
.get(&pull_request_number)
.map(|info| info.url.as_str())
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
github_pull_request_url(
render_input.owner,
render_input.repo,
&pull_request_number,
)
});
let title = render_input
.pull_request_infos
.get(&pull_request_number)
.map(|info| info.title.trim())
.filter(|value| !value.is_empty());
match title {
Some(title) => {
lines.push(format!("- [#{}]({}) {}", pull_request_number, url, title))
}
None => lines.push(format!("- [#{}]({})", pull_request_number, url)),
}
}
for issue_id in collect_linear_issue_identifiers_from_entries(§ion_commits) {
match render_input.linear_issue_infos.get(&issue_id) {
Some(info) if !info.url.trim().is_empty() && !info.title.trim().is_empty() => {
lines.push(format!(
"- [{}]({}) {}",
issue_id,
info.url,
info.title.trim()
))
}
Some(info) if !info.url.trim().is_empty() => {
lines.push(format!("- [{}]({})", issue_id, info.url))
}
Some(info) if !info.title.trim().is_empty() => {
lines.push(format!("- {} {}", issue_id, info.title.trim()))
}
_ => lines.push(format!("- {}", issue_id)),
}
}
lines.push(String::new());
}
}
if !render_input.install_snippets.is_empty() {
lines.extend(render_install_section(render_input.install_snippets));
lines.push(String::new());
}
lines.extend(render_release_notes_footer(
render_input.release_title,
render_input.current_tag_name,
&release_url,
));
lines.join("\n")
}
pub(crate) fn render_release_notes_footer(
release_title: &str,
release_tag: &str,
release_url: &str,
) -> Vec<String> {
let mut lines = vec!["---".to_string(), String::new()];
lines.push(format!(
"Release: [{}]({})",
release_title, release_url
));
lines.push(format!("Tag: [{}]({})", release_tag, release_url));
lines
}
pub(crate) fn notes_mention_release_tag(notes: &str, release_tag: &str) -> bool {
notes.contains(&format!("[{release_tag}]"))
}
pub(crate) fn github_release_url(owner: &str, repo: &str, release_tag: &str) -> String {
format!(
"{}/releases/tag/{}",
github_repository_url(owner, repo),
release_tag
)
}
fn render_install_section(snippets: &[ReleaseInstallSnippet]) -> Vec<String> {
let mut lines = Vec::new();
for (index, snippet) in snippets.iter().enumerate() {
if index > 0 {
lines.push(String::new());
}
match snippet.registry_url.as_deref() {
Some(url) if !url.trim().is_empty() => {
lines.push(format!(
"## Install [`{}`]({}) {}",
snippet.package_name, url, snippet.version
));
}
_ => {
lines.push(format!(
"## Install `{}` {}",
snippet.package_name, snippet.version
));
}
}
lines.push(String::new());
let mut commands = Vec::new();
let mut fence_lang = "bash".to_string();
for method in &snippet.methods {
if !method.language.trim().is_empty() {
fence_lang = method.language.trim().to_string();
}
for command in &method.commands {
let trimmed = command.trim();
if !trimmed.is_empty() && !commands.iter().any(|existing: &String| existing == trimmed)
{
commands.push(trimmed.to_string());
}
}
}
if commands.is_empty() {
continue;
}
lines.push(format!("```{fence_lang}"));
for command in commands {
lines.push(command);
}
lines.push("```".to_string());
}
lines
}
fn render_unstructured_commit_fallback(
commit_entries: &[ReleaseCommitEntry],
owner: &str,
repo: &str,
linear_issue_infos: &BTreeMap<String, LinearIssueInfo>,
) -> String {
if commit_entries.is_empty() {
return "No commits found in range.".to_string();
}
commit_entries
.iter()
.map(|entry| {
format!(
"- [{}]({}) {} ({})",
entry.short_sha,
github_commit_url(owner, repo, &entry.full_sha),
link_release_subject_mentions(&entry.subject, owner, repo, linear_issue_infos),
entry.date
)
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn build_fallback_sections(
commit_entries: &[ReleaseCommitEntry],
) -> Vec<ReleaseSection> {
let mut sections: Vec<SectionAccumulator> = Vec::new();
for commit_entry in commit_entries {
let descriptor = classify_release_topic(&commit_entry.subject);
let section_index = sections
.iter()
.position(|section| section.title == descriptor.title)
.unwrap_or_else(|| {
sections.push(SectionAccumulator {
title: descriptor.title,
topics: Vec::new(),
});
sections.len() - 1
});
let section = &mut sections[section_index];
let topic_index = section
.topics
.iter()
.position(|topic| topic.key == descriptor.topic_key)
.unwrap_or_else(|| {
section.topics.push(TopicAccumulator {
key: descriptor.topic_key,
lead_in: descriptor.lead_in,
commit_shas: Vec::new(),
fragments: Vec::new(),
});
section.topics.len() - 1
});
let topic = &mut section.topics[topic_index];
topic.commit_shas.push(commit_entry.short_sha.clone());
topic
.fragments
.push(clean_subject_fragment(&commit_entry.subject));
}
sections
.into_iter()
.map(|section| ReleaseSection {
title: section.title.to_string(),
summary: default_section_summary(section.title).to_string(),
bullets: section
.topics
.into_iter()
.map(|topic| ReleaseBullet {
commit_shas: topic.commit_shas,
summary: summarize_fragments(topic.lead_in, &topic.fragments),
})
.collect(),
})
.collect()
}
fn default_section_summary(title: &str) -> &'static str {
match title {
"Cases & Communication" => {
"Chat and case communication changes grouped into the main user-facing improvements."
}
"Reliability" => {
"Reliability changes grouped around uploads, file handling, and retry behavior."
}
"Athena Migration" => {
"Athena migration changes grouped around data, schema, and integration updates."
}
"Authentication & Security" => {
"Authentication and compatibility changes grouped around session, middleware, and proxy behavior."
}
"Forms Platform" => {
"Forms work grouped around submission flow, builder behavior, and checkout-related updates."
}
"Administration" => {
"Administration changes grouped around workflow tooling, dashboards, and operator-facing surfaces."
}
"User Interface" => {
"Interface changes grouped around shared components and page-level interaction updates."
}
"Documentation & Tooling" => {
"Documentation and tooling changes grouped around dependency updates and developer guidance."
}
_ => "General maintenance changes grouped into the main release summary.",
}
}
fn render_release_heading(
release_title: &str,
release_url: &str,
repo: &str,
repository_url: &str,
) -> String {
let repo_suffix = format!(" - {}", repo);
if let Some(prefix) = release_title.strip_suffix(&repo_suffix) {
let prefix = prefix.trim();
if !prefix.is_empty() {
return format!(
"# [{}]({}) - [{}]({})",
prefix, release_url, repo, repository_url
);
}
}
format!("# [{}]({})", release_title, release_url)
}
fn classify_release_topic(subject: &str) -> TopicDescriptor {
let lower = subject.to_ascii_lowercase();
if contains_any(&lower, &["deleted-message", "deleted message"]) {
return TopicDescriptor {
title: "Cases & Communication",
topic_key: "deleted-message-persistence",
lead_in: "Improved deleted-message persistence through",
};
}
if contains_any(
&lower,
&["chat", "message", "reply", "room", "timeline", "optimistic"],
) {
return TopicDescriptor {
title: "Cases & Communication",
topic_key: "chat-reliability",
lead_in: "Improved chat reliability through",
};
}
if contains_any(&lower, &["upload", "utf-8", "utf8", "audit"]) {
return TopicDescriptor {
title: "Reliability",
topic_key: "upload-utf8",
lead_in: "Improved upload reliability through",
};
}
if contains_any(&lower, &["attachment", "file", "media", "presigned"]) {
return TopicDescriptor {
title: "Reliability",
topic_key: "file-handling",
lead_in: "Improved file handling through",
};
}
if contains_any(
&lower,
&[
"athena",
"migration",
"migrat",
"schema",
"model",
"bootstrap",
],
) {
return TopicDescriptor {
title: "Athena Migration",
topic_key: "athena-migration",
lead_in: "Advanced the Athena migration through",
};
}
if contains_any(
&lower,
&[
"auth",
"oauth",
"session",
"middleware",
"rewrite",
"proxy",
"passkey",
"/api/auth",
],
) {
return TopicDescriptor {
title: "Authentication & Security",
topic_key: "auth-compatibility",
lead_in: "Improved auth compatibility through",
};
}
if contains_any(
&lower,
&["checkout", "submission", "autofill", "builder", "form"],
) {
return TopicDescriptor {
title: "Forms Platform",
topic_key: "forms-platform",
lead_in: "Expanded the forms platform through",
};
}
if contains_any(&lower, &["workflow", "task"]) {
return TopicDescriptor {
title: "Administration",
topic_key: "workflow-operations",
lead_in: "Expanded workflow operations through",
};
}
if contains_any(&lower, &["admin", "dashboard", "drill-down", "console"]) {
return TopicDescriptor {
title: "Administration",
topic_key: "admin-tooling",
lead_in: "Expanded administration tooling through",
};
}
if contains_any(&lower, &["switch"]) {
return TopicDescriptor {
title: "User Interface",
topic_key: "switch-components",
lead_in: "Refined shared switch components through",
};
}
if contains_any(
&lower,
&["component", "table", "page", "layout", "modal", "view"],
) {
return TopicDescriptor {
title: "User Interface",
topic_key: "ui-components",
lead_in: "Refined the user interface through",
};
}
if contains_any(&lower, &["doc", "readme", "guide", "binding", "provider"]) {
return TopicDescriptor {
title: "Documentation & Tooling",
topic_key: "documentation",
lead_in: "Updated documentation around",
};
}
if contains_any(&lower, &["bump", "next.js", "dependency"]) {
return TopicDescriptor {
title: "Documentation & Tooling",
topic_key: "dependencies",
lead_in: "Updated dependencies and tooling around",
};
}
TopicDescriptor {
title: "Maintenance",
topic_key: "maintenance",
lead_in: "Improved general behavior through",
}
}
#[cfg(test)]
pub(crate) fn format_release_commit_line(
raw_line: &str,
owner: &str,
repo: &str,
linear_issue_infos: &BTreeMap<String, LinearIssueInfo>,
) -> Option<String> {
let entry = parse_release_commit_entry(raw_line)?;
Some(format!(
"[{}]({}) {} ({})",
entry.short_sha,
github_commit_url(owner, repo, &entry.full_sha),
link_release_subject_mentions(&entry.subject, owner, repo, linear_issue_infos),
entry.date
))
}
fn contains_any(value: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| value.contains(needle))
}
fn clean_subject_fragment(subject: &str) -> String {
let trimmed = subject.trim();
let cleaned = Regex::new(
r"(?i)^(add|added|fix|fixed|implement|implemented|improve|improved|refactor|refactored|migrate|migrated|update|updated|normalize|normalized|harden|hardened|prevent|prevented|document|documented|expand|expanded|consolidate|consolidated|bump)\s+",
)
.expect("valid cleanup regex")
.replace(trimmed, "")
.into_owned();
let cleaned = Regex::new(r"\s*\(#\d+\)")
.expect("valid pull request cleanup regex")
.replace_all(&cleaned, "")
.into_owned();
let cleaned = Regex::new(r"\b[A-Z][A-Z0-9]+-\d+\b")
.expect("valid linear issue cleanup regex")
.replace_all(&cleaned, "")
.into_owned();
let cleaned = Regex::new(r"(?i)\b(for|with|and|to|from|in|on|into|across)\s*$")
.expect("valid trailing connector cleanup regex")
.replace(&cleaned, "")
.into_owned();
cleaned
.trim_matches('.')
.trim_matches('-')
.trim_matches(':')
.trim_matches('(')
.trim_matches(')')
.trim()
.trim_start_matches("the ")
.to_string()
}
fn summarize_fragments(lead_in: &str, fragments: &[String]) -> String {
let mut seen = BTreeSet::new();
let unique_fragments = fragments
.iter()
.map(|fragment| fragment.trim())
.filter(|fragment| !fragment.is_empty())
.filter(|fragment| seen.insert(fragment.to_ascii_lowercase()))
.take(5)
.map(|fragment| fragment.to_string())
.collect::<Vec<_>>();
if unique_fragments.is_empty() {
return "Improved project behavior.".to_string();
}
format!("{} {}.", lead_in, join_human_list(&unique_fragments))
}
fn join_human_list(items: &[String]) -> String {
match items.len() {
0 => String::new(),
1 => items[0].clone(),
2 => format!("{} and {}", items[0], items[1]),
_ => {
let mut parts = items[..items.len() - 1].join(", ");
parts.push_str(", and ");
parts.push_str(&items[items.len() - 1]);
parts
}
}
}
async fn try_generate_release_sections_with_openrouter(
release_title: &str,
current_tag_name: &str,
owner: &str,
repo: &str,
previous_tag: Option<&str>,
commit_entries: &[ReleaseCommitEntry],
openrouter_api_key: Option<&str>,
) -> Option<Vec<ReleaseSection>> {
let api_key = openrouter_api_key
.map(str::trim)
.filter(|value| !value.is_empty())?;
if commit_entries.is_empty() {
return Some(Vec::new());
}
let prompt = build_openrouter_release_prompt(
release_title,
current_tag_name,
owner,
repo,
previous_tag,
commit_entries,
);
let model = resolve_openrouter_release_notes_model();
let system_prompt = resolve_openrouter_release_notes_system_prompt();
let response = complete_prompt(
api_key,
&model,
Some(&system_prompt),
&prompt,
Some("XBP Release Notes"),
)
.await?;
let ai_release_notes = parse_ai_release_notes(&response)?;
Some(normalize_ai_sections(ai_release_notes, commit_entries))
}
fn build_openrouter_release_prompt(
release_title: &str,
current_tag_name: &str,
owner: &str,
repo: &str,
previous_tag: Option<&str>,
commit_entries: &[ReleaseCommitEntry],
) -> String {
let previous_line = previous_tag.unwrap_or("none");
let commit_lines = commit_entries
.iter()
.map(|entry| {
format!(
"- {} | {} | {} | {}",
entry.short_sha,
github_commit_url(owner, repo, &entry.full_sha),
entry.date,
entry.subject
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
"Release note generation context:\n\nRelease title: {release_title}\nRepository: {owner}/{repo}\nRelease tag: {current_tag_name}\nPrevious tag: {previous_line}\n\nCommits:\n{commit_lines}\n",
)
}
fn parse_ai_release_notes(raw: &str) -> Option<AiReleaseNotes> {
let trimmed = raw.trim();
let stripped = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.map(|value| value.trim())
.unwrap_or(trimmed);
let stripped = stripped
.strip_suffix("```")
.map(str::trim)
.unwrap_or(stripped);
serde_json::from_str::<AiReleaseNotes>(stripped).ok()
}
fn normalize_ai_sections(
ai_release_notes: AiReleaseNotes,
commit_entries: &[ReleaseCommitEntry],
) -> Vec<ReleaseSection> {
let valid_shas = commit_entries
.iter()
.map(|entry| entry.short_sha.clone())
.collect::<BTreeSet<_>>();
let mut used_shas = BTreeSet::new();
let mut sections = Vec::new();
for ai_section in ai_release_notes.sections {
let mut bullets = Vec::new();
for ai_bullet in ai_section.bullets {
let commit_shas = ai_bullet
.commit_shas
.into_iter()
.filter(|sha| valid_shas.contains(sha))
.filter(|sha| used_shas.insert(sha.clone()))
.collect::<Vec<_>>();
let summary = ai_bullet.summary.trim();
if summary.is_empty() || commit_shas.is_empty() {
continue;
}
bullets.push(ReleaseBullet {
commit_shas,
summary: summary.to_string(),
});
}
if bullets.is_empty() {
continue;
}
sections.push(ReleaseSection {
title: ai_section.title.trim().to_string(),
summary: ai_section.summary.trim().to_string(),
bullets,
});
}
let unassigned_commits = commit_entries
.iter()
.filter(|entry| !used_shas.contains(&entry.short_sha))
.cloned()
.collect::<Vec<_>>();
if !unassigned_commits.is_empty() {
sections.extend(build_fallback_sections(&unassigned_commits));
}
sections
}
fn section_commit_entries<'a>(
section: &ReleaseSection,
commit_entries: &'a [ReleaseCommitEntry],
) -> Vec<&'a ReleaseCommitEntry> {
let section_shas = section
.bullets
.iter()
.flat_map(|bullet| bullet.commit_shas.iter().cloned())
.collect::<BTreeSet<_>>();
commit_entries
.iter()
.filter(|entry| section_shas.contains(&entry.short_sha))
.collect()
}
pub(crate) fn collect_pull_request_numbers(commit_entries: &[ReleaseCommitEntry]) -> Vec<String> {
collect_pull_request_numbers_from_entries(&commit_entries.iter().collect::<Vec<_>>())
}
fn collect_pull_request_numbers_from_entries(
commit_entries: &[&ReleaseCommitEntry],
) -> Vec<String> {
let pull_request_regex =
Regex::new(r"(?P<prefix>^|[^A-Za-z0-9_/])#(?P<number>\d+)\b").expect("valid pr regex");
let mut pull_request_numbers = BTreeSet::new();
for commit_entry in commit_entries {
for caps in pull_request_regex.captures_iter(&commit_entry.subject) {
let Some(number) = caps.name("number").map(|matched| matched.as_str()) else {
continue;
};
pull_request_numbers.insert(number.to_string());
}
}
pull_request_numbers.into_iter().collect()
}
pub(crate) fn collect_linear_issue_identifiers(
commit_entries: &[ReleaseCommitEntry],
) -> Vec<String> {
collect_linear_issue_identifiers_from_entries(&commit_entries.iter().collect::<Vec<_>>())
}
fn collect_linear_issue_identifiers_from_entries(
commit_entries: &[&ReleaseCommitEntry],
) -> Vec<String> {
let issue_regex = Regex::new(r"\b[A-Z][A-Z0-9]+-\d+\b").expect("valid linear issue regex");
let mut issue_ids = BTreeSet::new();
for commit_entry in commit_entries {
for issue_match in issue_regex.find_iter(&commit_entry.subject) {
issue_ids.insert(issue_match.as_str().to_string());
}
}
issue_ids.into_iter().collect()
}
async fn resolve_release_pull_request_infos(
commit_entries: &[ReleaseCommitEntry],
owner: &str,
repo: &str,
github_token: &str,
) -> BTreeMap<String, GithubPullRequestInfo> {
let pull_request_numbers = collect_pull_request_numbers(commit_entries);
if pull_request_numbers.is_empty() {
return BTreeMap::new();
}
match fetch_pull_request_infos(owner, repo, github_token, &pull_request_numbers).await {
Ok(infos) => infos,
Err(err) => {
eprintln!(
"Warning: failed to resolve GitHub pull request titles: {}",
err
);
BTreeMap::new()
}
}
}
async fn resolve_release_linear_issue_infos(
commit_entries: &[ReleaseCommitEntry],
linear_api_key: Option<&str>,
) -> BTreeMap<String, LinearIssueInfo> {
let Some(linear_api_key) = linear_api_key
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return BTreeMap::new();
};
let issue_ids = collect_linear_issue_identifiers(commit_entries);
if issue_ids.is_empty() {
return BTreeMap::new();
}
match fetch_linear_issue_infos(linear_api_key, &issue_ids).await {
Ok(infos) => infos,
Err(err) => {
eprintln!("Warning: failed to resolve Linear issue links: {}", err);
BTreeMap::new()
}
}
}
async fn fetch_pull_request_infos(
owner: &str,
repo: &str,
github_token: &str,
pull_request_numbers: &[String],
) -> Result<BTreeMap<String, GithubPullRequestInfo>, String> {
let client = reqwest::Client::new();
let mut pull_request_infos = BTreeMap::new();
for pull_request_number in pull_request_numbers {
let response = client
.get(format!(
"https://api.github.com/repos/{}/{}/pulls/{}",
owner, repo, pull_request_number
))
.header("Authorization", format!("Bearer {}", github_token.trim()))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "xbp-cli-release/1.0")
.send()
.await
.map_err(|e| format!("GitHub pull request lookup failed: {}", e))?;
let status = response.status();
let body: JsonValue = response
.json()
.await
.map_err(|e| format!("Failed to decode GitHub pull request response: {}", e))?;
if !status.is_success() {
let detail = body
.get("message")
.and_then(|value| value.as_str())
.unwrap_or("unknown GitHub API error");
return Err(format!("GitHub API returned {}: {}", status, detail));
}
let title = body
.get("title")
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim()
.to_string();
let url = body
.get("html_url")
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim()
.to_string();
if !title.is_empty() || !url.is_empty() {
pull_request_infos.insert(
pull_request_number.clone(),
GithubPullRequestInfo { title, url },
);
}
}
Ok(pull_request_infos)
}
async fn fetch_linear_issue_infos(
linear_api_key: &str,
issue_ids: &[String],
) -> Result<BTreeMap<String, LinearIssueInfo>, String> {
if issue_ids.is_empty() {
return Ok(BTreeMap::new());
}
let mut query = String::from("query ReleaseIssueInfos {\n");
for (index, issue_id) in issue_ids.iter().enumerate() {
query.push_str(&format!(
" issue_{}: issue(id: \"{}\") {{ identifier title url }}\n",
index, issue_id
));
}
query.push('}');
let response = reqwest::Client::new()
.post("https://api.linear.app/graphql")
.header("Authorization", linear_api_key)
.json(&serde_json::json!({ "query": query }))
.send()
.await
.map_err(|e| format!("Linear API request failed: {}", e))?;
let status = response.status();
let body: JsonValue = response
.json()
.await
.map_err(|e| format!("Failed to decode Linear API response: {}", e))?;
if !status.is_success() {
let detail = body
.get("errors")
.and_then(|value| value.as_array())
.and_then(|errors| errors.first())
.and_then(|error| error.get("message"))
.and_then(|message| message.as_str())
.unwrap_or("unknown Linear API error");
return Err(format!("Linear API returned {}: {}", status, detail));
}
let mut issue_infos = BTreeMap::new();
if let Some(data) = body.get("data").and_then(|value| value.as_object()) {
for value in data.values() {
let Some(identifier) = value.get("identifier").and_then(|value| value.as_str()) else {
continue;
};
let title = value
.get("title")
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim()
.to_string();
let url = value
.get("url")
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim()
.to_string();
if !identifier.trim().is_empty() {
issue_infos.insert(
identifier.trim().to_string(),
LinearIssueInfo { title, url },
);
}
}
}
Ok(issue_infos)
}
fn github_repository_url(owner: &str, repo: &str) -> String {
format!("https://github.com/{}/{}", owner, repo)
}
fn github_commit_url(owner: &str, repo: &str, commit_sha: &str) -> String {
format!(
"{}/commit/{}",
github_repository_url(owner, repo),
commit_sha
)
}
fn github_pull_request_url(owner: &str, repo: &str, pull_request_number: &str) -> String {
format!(
"{}/pull/{}",
github_repository_url(owner, repo),
pull_request_number
)
}
fn link_release_subject_mentions(
subject: &str,
owner: &str,
repo: &str,
linear_issue_infos: &BTreeMap<String, LinearIssueInfo>,
) -> String {
let with_pull_requests = link_pull_request_mentions(subject, owner, repo);
if linear_issue_infos.is_empty() {
return with_pull_requests;
}
let linear_issue_regex =
Regex::new(r"(?P<prefix>^|[^A-Za-z0-9_/])(?P<key>[A-Z][A-Z0-9]+-\d+\b)")
.expect("valid linear issue regex");
linear_issue_regex
.replace_all(&with_pull_requests, |caps: ®ex::Captures| {
let prefix = caps.name("prefix").map_or("", |matched| matched.as_str());
let key = caps.name("key").map_or("", |matched| matched.as_str());
match linear_issue_infos.get(key).map(|info| info.url.as_str()) {
Some(url) if !url.trim().is_empty() => format!("{}[{}]({})", prefix, key, url),
None => caps
.get(0)
.map_or(String::new(), |matched| matched.as_str().to_string()),
_ => caps
.get(0)
.map_or(String::new(), |matched| matched.as_str().to_string()),
}
})
.into_owned()
}
fn link_pull_request_mentions(subject: &str, owner: &str, repo: &str) -> String {
let pr_reference_regex = Regex::new(r"(?P<prefix>^|[^A-Za-z0-9_/])#(?P<number>\d+)\b")
.expect("valid pull-request mention regex");
pr_reference_regex
.replace_all(subject, |caps: ®ex::Captures| {
let prefix = caps.name("prefix").map_or("", |matched| matched.as_str());
let number = caps.name("number").map_or("", |matched| matched.as_str());
format!(
"{}[#{}]({})",
prefix,
number,
github_pull_request_url(owner, repo, number)
)
})
.into_owned()
}