use crate::server::app::AppState;
use crate::server::errors::error;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use nomoreide_core::git_manager::{GitCompareSummary, GitManager, GitStatus};
use nomoreide_core::github_context::require_github_context;
use nomoreide_core::github_manager::GithubManager;
use serde_json::{json, Map, Value};
const BODY_COMMITS: usize = 10;
const BODY_FILES: usize = 20;
const FALLBACK_BASE: &str = "main";
pub(super) fn routes() -> Router<AppState> {
Router::new().route("/api/github/pr-template", get(pr_template))
}
async fn pr_template(State(state): State<AppState>) -> Response {
let cwd = state.workspace_cwd().await;
let context = match require_github_context(&state.config_store, &cwd).await {
Ok(context) => context,
Err(reason) => return error(StatusCode::BAD_REQUEST, &reason.to_string()),
};
let template = build(&cwd, &context.manager).await;
Json(json!({ "ok": true, "template": template })).into_response()
}
async fn build(cwd: &str, manager: &GithubManager) -> Value {
let mut warnings: Vec<String> = Vec::new();
let status = match GitManager::status(cwd).await {
Ok(status) => Some(status),
Err(reason) => {
warnings.push(format!("Could not read local Git status: {reason}"));
None
}
};
let repository = match manager.repo_info().await {
Ok(repository) => Some(repository),
Err(reason) => {
warnings.push(format!(
"Could not read GitHub repository metadata: {}",
reason.message
));
None
}
};
let head = status
.as_ref()
.map(|status| status.branch.clone())
.unwrap_or_default();
if head.is_empty() {
warnings.push(
"Current branch could not be detected. Enter the head branch manually.".to_string(),
);
}
let base = base_branch(repository.as_ref(), status.as_ref());
let mut compare = empty_compare(&base, &head);
if !base.is_empty() && !head.is_empty() && base != head {
compare = compare_summary(cwd, manager, &base, &head, &mut warnings).await;
} else if !head.is_empty() && base == head {
warnings.push(
"The current branch matches the base branch. Choose a feature branch before creating a PR."
.to_string(),
);
}
let head_sha = compare
.get("headSha")
.and_then(Value::as_str)
.filter(|sha| !sha.is_empty())
.map(str::to_string);
if let Some(sha) = head_sha {
match manager.commit_checks(Some(&sha)).await {
Ok(ci) => {
compare.insert("ciStatus".into(), json!(ci));
}
Err(reason) => {
warnings.push(format!("Could not read head CI status: {}", reason.message))
}
}
}
let commits = compare
.get("commits")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
json!({
"repository": repository,
"currentBranch": (!head.is_empty()).then(|| head.clone()),
"suggestedBase": base,
"base": base,
"head": head,
"title": suggest_title(&head, &commits),
"body": suggest_body(&compare),
"draft": false,
"compare": Value::Object(compare),
"warnings": warnings,
})
}
fn base_branch(repository: Option<&Value>, status: Option<&GitStatus>) -> String {
if let Some(default) = repository
.and_then(|repository| repository.get("default_branch"))
.filter(|value| !value.is_null())
{
return default.as_str().unwrap_or_default().to_string();
}
status
.and_then(|status| status.upstream.as_deref())
.filter(|upstream| !upstream.is_empty())
.map(|upstream| upstream.strip_prefix("origin/").unwrap_or(upstream))
.filter(|base| !base.is_empty())
.unwrap_or(FALLBACK_BASE)
.to_string()
}
async fn compare_summary(
cwd: &str,
manager: &GithubManager,
base: &str,
head: &str,
warnings: &mut Vec<String>,
) -> Map<String, Value> {
match manager.compare_branches(base, head).await {
Ok(summary) => {
let mut compare = Map::new();
compare.insert("base".into(), json!(base));
compare.insert("head".into(), json!(head));
if let Some(ahead_by) = summary.ahead_by {
compare.insert("aheadBy".into(), ahead_by);
}
compare.insert("headSha".into(), json!(summary.head_sha));
compare.insert("commits".into(), json!(summary.commits));
compare.insert("files".into(), json!(summary.files));
return compare;
}
Err(reason) => warnings.push(format!(
"Could not compare pushed GitHub branches: {}",
reason.message
)),
}
let remote = format!("origin/{base}");
for reference in [base, remote.as_str()] {
match GitManager::compare_with_base(cwd, reference).await {
Ok(summary) => return local_compare(base, head, &summary),
Err(reason) => {
if reference == remote {
warnings.push(format!(
"Could not compare local branch with {base}: {reason}"
));
}
}
}
}
empty_compare(base, head)
}
fn local_compare(base: &str, head: &str, summary: &GitCompareSummary) -> Map<String, Value> {
let mut compare = Map::new();
compare.insert("base".into(), json!(base));
compare.insert("head".into(), json!(head));
compare.insert("aheadBy".into(), json!(summary.ahead_by));
compare.insert("headSha".into(), json!(summary.head_sha));
compare.insert("commits".into(), json!(summary.commits));
compare.insert("files".into(), json!(summary.files));
compare
}
fn empty_compare(base: &str, head: &str) -> Map<String, Value> {
let mut compare = Map::new();
compare.insert("base".into(), json!(base));
compare.insert("head".into(), json!(head));
compare.insert("aheadBy".into(), json!(0));
compare.insert("headSha".into(), Value::Null);
compare.insert("commits".into(), json!([]));
compare.insert("files".into(), json!([]));
compare
}
fn suggest_title(head: &str, commits: &[Value]) -> String {
let latest = commits
.last()
.and_then(|commit| commit.get("message"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty());
match latest {
Some(message) => message.to_string(),
None => branch_title(head),
}
}
fn branch_title(head: &str) -> String {
let leaf = head
.split('/')
.rfind(|segment| !segment.is_empty())
.unwrap_or(head);
let spaced: String = leaf
.chars()
.map(|character| {
if character == '-' || character == '_' {
' '
} else {
character
}
})
.collect();
let collapsed = spaced.split_whitespace().collect::<Vec<_>>().join(" ");
let mut characters = collapsed.chars();
match characters.next() {
Some(first) if first.is_ascii_alphanumeric() || first == '_' => {
first.to_uppercase().collect::<String>() + characters.as_str()
}
_ => collapsed,
}
}
fn suggest_body(compare: &Map<String, Value>) -> String {
let commits = compare
.get("commits")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let files = compare
.get("files")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let mut lines: Vec<String> = Vec::new();
if !commits.is_empty() {
lines.push("## Commits".to_string());
for commit in commits
.iter()
.skip(commits.len().saturating_sub(BODY_COMMITS))
{
lines.push(format!("- {}", text(commit.get("message"))));
}
}
if !files.is_empty() {
if !lines.is_empty() {
lines.push(String::new());
}
lines.push("## Changed files".to_string());
for file in files.iter().take(BODY_FILES) {
lines.push(format!(
"- {} {}",
text(file.get("status")),
text(file.get("path"))
));
}
if files.len() > BODY_FILES {
lines.push(format!("- {} more files", files.len() - BODY_FILES));
}
}
lines.join("\n")
}
fn text(value: Option<&Value>) -> String {
match value {
Some(Value::String(text)) => text.clone(),
Some(value) => value.to_string(),
None => "undefined".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_branch_name_becomes_a_sentence() {
assert_eq!(branch_title("feat/add_the-thing"), "Add the thing");
assert_eq!(branch_title("123-fix"), "123 fix");
assert_eq!(branch_title("+odd-name"), "+odd name");
assert_eq!(branch_title(""), "");
assert_eq!(branch_title("a//b"), "B");
}
}