use crate::openrouter::{complete_user_prompt, DEFAULT_MODEL};
use regex::Regex;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
#[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,
}
pub(crate) struct ReleaseNotesRequest<'a> {
pub(crate) project_root: &'a Path,
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) 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) async fn generate_release_notes(
request: &ReleaseNotesRequest<'_>,
) -> Result<String, String> {
let previous_tag = super::previous_release_tag(request.project_root, request.current_tag_name)?;
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,
}))
}
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(),
"## What's Changed".to_string(),
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,
));
} 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());
}
}
lines.push("---".to_string());
lines.push(String::new());
lines.push(format!(
"Release: [{}]({})",
render_input.current_tag_name, release_url
));
lines.join("\n")
}
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 response =
complete_user_prompt(api_key, DEFAULT_MODEL, &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!(
"You are generating structured release notes for a GitHub release.\n\nReturn strict JSON only. No markdown, no code fences, no explanation.\n\nSchema:\n{{\n \"sections\": [\n {{\n \"title\": \"string\",\n \"summary\": \"one short paragraph for the section\",\n \"bullets\": [\n {{\n \"commit_shas\": [\"shortsha1\", \"shortsha2\"],\n \"summary\": \"one concise user-facing bullet\"\n }}\n ]\n }}\n ]\n}}\n\nTarget rendered markdown shape:\n# [version](release-url) - [repo](repository-url)\n## What's Changed\nComparing changes since [previous-tag](previous-release-url).\n### Section Title\nShort paragraph summary for the section.\n- [sha1](commit-url), [sha2](commit-url) Concise user-facing summary\n- [#123](pull-url) Pull request title\n- [SUI-1234](linear-url) Linear issue title\n\nRules:\n- Focus on what changed for users or operators, not on mirroring the git log.\n- Combine thematically similar commits into one bullet. Do not create one bullet per commit when several commits describe the same user-visible change.\n- Specifically collapse repeated chat-related commits, deleted-message persistence commits, switch-component commits, upload UTF-8 fix commits, and Athena migration commits into one bullet each when they appear.\n- Prefer section titles like Cases & Communication, Reliability, Athena Migration, Authentication & Security, Forms Platform, Administration, User Interface, and Documentation & Tooling when they fit.\n- Use every provided short SHA exactly once across all bullets unless a commit is obviously trivial maintenance.\n- Use only short SHAs from the provided list. Never invent SHAs.\n- Keep sections readable: usually 3 to 7 sections, with 1 to 4 bullets each.\n- Keep section summaries concise and high-level. Keep bullet summaries concise and user-facing.\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_release_url(owner: &str, repo: &str, release_tag: &str) -> String {
format!(
"{}/releases/tag/{}",
github_repository_url(owner, repo),
release_tag
)
}
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()
}