//! Issue and pull request templates, read from the repository's default branch. //! //! Issue templates live in `ISSUE_TEMPLATE/*.md` or `ISSUE_TEMPLATE.md`; the pull request //! template is `PULL_REQUEST_TEMPLATE.md`. Each is looked up under `.forge/`, then `.github/`, //! then `.gitea/`, and the first location that yields a template wins. A file may start with //! front matter between two `---` lines holding simple `key: value` lines (`name`, `about`, //! `title`, `labels`); everything after it is the body. use std::path::{Path, PathBuf}; use forge_git::browse::{self, EntryKind, TreeEntry}; /// Most issue templates offered per repository. pub const MAX_TEMPLATES: usize = 20; /// Largest template file read. Bigger files are skipped. pub const MAX_TEMPLATE_BYTES: usize = 64 * 1024; /// Directories searched for templates, in priority order. const ROOTS: [&str; 3] = [".forge", ".github", ".gitea"]; const ISSUE_DIR: &str = "ISSUE_TEMPLATE"; const ISSUE_FILE: &str = "ISSUE_TEMPLATE.md"; const PULL_FILE: &str = "PULL_REQUEST_TEMPLATE.md"; /// One parsed template file. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Template { /// File name without `.md`. This is the `template` query value that selects it. pub stem: String, /// Display name: the `name` front matter, or the stem. pub name: String, pub about: String, /// Suggested title. Empty when the template sets none. pub title: String, /// Suggested label names, in file order. pub labels: Vec, pub body: String, } /// Parses one template file. `stem` is the file name without `.md`. pub fn parse_template(stem: &str, text: &str) -> Template { let (front, body) = split_front_matter(text); let mut template = Template { stem: stem.to_owned(), body: body.to_owned(), ..Template::default() }; if let Some(front) = front { for line in front.lines() { let Some((key, value)) = line.split_once(':') else { continue; }; let value = unquote(value); match key.trim() { "name" => template.name = value.to_owned(), "about" => template.about = value.to_owned(), "title" => template.title = value.to_owned(), "labels" => { template.labels = value .split(',') .map(|label| unquote(label).to_owned()) .filter(|label| !label.is_empty()) .collect(); } _ => {} } } } if template.name.trim().is_empty() { template.name = stem.to_owned(); } template } /// Splits front matter from the body. Text without a closed front matter block is all body. fn split_front_matter(text: &str) -> (Option<&str>, &str) { let text = text.strip_prefix('\u{feff}').unwrap_or(text); let Some(first_end) = text.find('\n') else { return (None, text); }; if text[..first_end].trim_end_matches('\r') != "---" { return (None, text); } let rest = &text[first_end + 1..]; let mut offset = 0; for line in rest.split_inclusive('\n') { if line.trim_end_matches(['\r', '\n']) == "---" { let front = &rest[..offset]; let body = rest[offset + line.len()..].trim_start_matches(['\r', '\n']); return (Some(front), body); } offset += line.len(); } (None, text) } /// A value with one pair of matching surrounding quotes removed, and whitespace trimmed. fn unquote(value: &str) -> &str { let value = value.trim(); for quote in ['"', '\''] { if let Some(inner) = value .strip_prefix(quote) .and_then(|rest| rest.strip_suffix(quote)) { return inner.trim(); } } value } /// Templates from the first root in `ROOTS` that has any. `dir` names a directory of `.md` files /// (tried first under each root); `file` is the single-file form. fn read_templates( repo: &Path, commit: &str, dir: Option<&str>, file: &str, ) -> forge_git::Result> { for root in ROOTS { if let Some(dir) = dir { let found = read_dir_templates(repo, commit, &format!("{root}/{dir}"))?; if !found.is_empty() { return Ok(found); } } let path = format!("{root}/{file}"); if let Some(text) = browse::read_text_blob(repo, commit, &path, MAX_TEMPLATE_BYTES)? { let stem = file.strip_suffix(".md").unwrap_or(file); return Ok(vec![parse_template(stem, &text)]); } } Ok(Vec::new()) } /// The `.md` files directly in `path`, read in name order, at most `MAX_TEMPLATES`. fn read_dir_templates(repo: &Path, commit: &str, path: &str) -> forge_git::Result> { let Some(entries) = browse::read_tree(repo, commit, path)? else { return Ok(Vec::new()); }; let mut files: Vec<&TreeEntry> = entries .iter() .filter(|e| matches!(e.kind, EntryKind::Blob | EntryKind::Executable)) .filter(|e| e.name.ends_with(".md") && e.name.len() > ".md".len()) .collect(); files.sort_by(|a, b| a.name.cmp(&b.name)); let mut templates = Vec::new(); for entry in files.into_iter().take(MAX_TEMPLATES) { let full = format!("{path}/{}", entry.name); if let Some(text) = browse::read_text_blob(repo, commit, &full, MAX_TEMPLATE_BYTES)? { let stem = entry.name.strip_suffix(".md").unwrap_or(&entry.name); templates.push(parse_template(stem, &text)); } } Ok(templates) } /// The issue templates on `branch`. Empty when the branch does not exist or has none. pub fn issue_templates(repo: &Path, branch: &str) -> forge_git::Result> { let Some(commit) = browse::default_branch_commit(repo, branch)? else { return Ok(Vec::new()); }; read_templates(repo, &commit, Some(ISSUE_DIR), ISSUE_FILE) } /// The pull request template on `branch`, if any. pub fn pull_template(repo: &Path, branch: &str) -> forge_git::Result> { let Some(commit) = browse::default_branch_commit(repo, branch)? else { return Ok(None); }; Ok(read_templates(repo, &commit, None, PULL_FILE)? .into_iter() .next()) } /// `issue_templates` on a blocking thread. A read failure is logged and gives no templates, so /// the page still renders. pub async fn load_issue_templates(repo: PathBuf, branch: String) -> topcoat::Result> { let result = tokio::task::spawn_blocking(move || issue_templates(&repo, &branch)).await?; Ok(result.unwrap_or_else(|e| { tracing::warn!(error = %e, "issue templates: read failed"); Vec::new() })) } /// `pull_template` on a blocking thread. A read failure is logged and gives no template. pub async fn load_pull_template( repo: PathBuf, branch: String, ) -> topcoat::Result> { let result = tokio::task::spawn_blocking(move || pull_template(&repo, &branch)).await?; Ok(result.unwrap_or_else(|e| { tracing::warn!(error = %e, "pull request template: read failed"); None })) } #[cfg(test)] mod tests { use super::*; #[test] fn no_front_matter_is_all_body_and_name_is_stem() { let t = parse_template("bug", "Steps:\n1. click\n"); assert_eq!(t.stem, "bug"); assert_eq!(t.name, "bug"); assert_eq!(t.about, ""); assert_eq!(t.title, ""); assert!(t.labels.is_empty()); assert_eq!(t.body, "Steps:\n1. click\n"); } #[test] fn front_matter_fields_are_parsed_and_removed_from_body() { let text = "---\nname: Bug report\nabout: Something broke\ntitle: \"[Bug]: \"\nlabels: bug, 'needs triage',, \n---\n\n## Steps\n"; let t = parse_template("bug", text); assert_eq!(t.name, "Bug report"); assert_eq!(t.about, "Something broke"); assert_eq!(t.title, "[Bug]:"); assert_eq!(t.labels, vec!["bug", "needs triage"]); assert_eq!(t.body, "## Steps\n"); } #[test] fn unknown_and_malformed_lines_are_ignored() { let text = "---\nassignees: someone\nno colon here\nname: Feature\n---\nbody"; let t = parse_template("feat", text); assert_eq!(t.name, "Feature"); assert_eq!(t.body, "body"); } #[test] fn blank_name_falls_back_to_stem() { let t = parse_template("docs", "---\nname: \n---\nx"); assert_eq!(t.name, "docs"); } #[test] fn unclosed_front_matter_is_body() { let text = "---\nname: Oops\nno closing marker\n"; let t = parse_template("oops", text); assert_eq!(t.name, "oops"); assert_eq!(t.body, text); } #[test] fn crlf_line_endings_are_handled() { let text = "---\r\nname: Crlf\r\nlabels: a,b\r\n---\r\n\r\nBody\r\n"; let t = parse_template("crlf", text); assert_eq!(t.name, "Crlf"); assert_eq!(t.labels, vec!["a", "b"]); assert_eq!(t.body, "Body\r\n"); } #[test] fn a_dash_line_later_in_the_body_is_not_front_matter() { let text = "Intro\n---\nname: no\n---\n"; let t = parse_template("x", text); assert_eq!(t.name, "x"); assert_eq!(t.body, text); } } #[cfg(test)] mod route_tests { use forge_core::Visibility; use crate::pages::commits::test_support::{Fixture, git}; use crate::pages::issues_list::test_util::{CSRF, OWNER, REPO, TOKEN, send}; const BUG: &str = "---\nname: Bug report\nabout: Something broke\ntitle: \"[Bug]: \"\nlabels: bug, needs triage\n---\n## Steps to reproduce\n\n1. Do the thing\n"; const FEATURE: &str = "---\nname: Feature request\nabout: Suggest an idea\n---\n## Idea\n"; /// A repo whose default branch gets `files` committed on top of the fixture's history. /// Alice is signed in. async fn setup(files: &[(&str, &str)]) -> Fixture { let fx = Fixture::new(Visibility::Public).await; for (path, text) in files { let full = fx.work.join(path); std::fs::create_dir_all(full.parent().unwrap()).unwrap(); std::fs::write(full, text).unwrap(); } git(&fx.work, &["add", "-A"]); git(&fx.work, &["commit", "-q", "-m", "Add templates"]); git( &fx.work, &[ "push", "-q", &fx.bare.to_string_lossy(), "HEAD:refs/heads/main", ], ); let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); crate::test_support::create_session(&fx.store, &owner.id, TOKEN, CSRF).await; fx } fn new_issue_uri(query: &str) -> String { format!("/{OWNER}/{REPO}/issues/new{query}") } /// The text of the tag that starts at `needle`, up to its closing `>`. fn tag_at(html: &str, needle: &str) -> String { let at = html.find(needle).expect("needle in page"); let end = html[at..].find('>').expect("tag end") + at; html[at..end].to_owned() } #[tokio::test] async fn several_templates_show_a_chooser() { let fx = setup(&[ (".forge/ISSUE_TEMPLATE/bug.md", BUG), (".forge/ISSUE_TEMPLATE/feature.md", FEATURE), ]) .await; let reply = send(&fx.router, "GET", &new_issue_uri(""), true, None, &[]).await; assert_eq!(reply.status, 200); let html = reply.text(); assert!(html.contains("Bug report")); assert!(html.contains("Something broke")); assert!(html.contains("issues/new?template=bug\"")); assert!(html.contains("issues/new?template=feature\"")); assert!(html.contains("Open a blank issue")); assert!(html.contains("issues/new?blank=1\"")); assert!(!html.contains("name=\"title\"")); } #[tokio::test] async fn chosen_template_prefills_and_preselects_existing_labels() { let fx = setup(&[ (".forge/ISSUE_TEMPLATE/bug.md", BUG), (".forge/ISSUE_TEMPLATE/feature.md", FEATURE), ]) .await; let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap(); fx.store .create_label(&repo.id, "bug", "#d73a4a", "") .await .unwrap(); fx.store .create_label(&repo.id, "docs", "#0075ca", "") .await .unwrap(); let bug_id = fx .store .list_labels(&repo.id) .await .unwrap() .into_iter() .find(|l| l.name == "bug") .unwrap() .id; let reply = send( &fx.router, "GET", &new_issue_uri("?template=bug"), true, None, &[], ) .await; assert_eq!(reply.status, 200); let html = reply.text(); assert!(html.contains("name=\"title\"")); assert!(html.contains("value=\"[Bug]:\"")); assert!(html.contains("## Steps to reproduce")); assert!(!html.contains("Choose a template")); assert!(tag_at(&html, &format!("value=\"{bug_id}\"")).contains("checked")); assert!(!html.contains("value=\"needs triage\"")); assert_eq!(html.matches("name=\"label\"").count(), 2); let docs_id = fx .store .list_labels(&repo.id) .await .unwrap() .into_iter() .find(|l| l.name == "docs") .unwrap() .id; assert!(!tag_at(&html, &format!("value=\"{docs_id}\"")).contains("checked")); } #[tokio::test] async fn blank_issue_from_chooser_is_empty() { let fx = setup(&[ (".forge/ISSUE_TEMPLATE/bug.md", BUG), (".forge/ISSUE_TEMPLATE/feature.md", FEATURE), ]) .await; let reply = send( &fx.router, "GET", &new_issue_uri("?blank=1"), true, None, &[], ) .await; let html = reply.text(); assert!(html.contains("name=\"title\"")); assert!(!html.contains("Steps to reproduce")); assert!(!html.contains("Choose a template")); } #[tokio::test] async fn single_template_prefills_without_chooser() { let fx = setup(&[(".forge/ISSUE_TEMPLATE/bug.md", BUG)]).await; let html = send(&fx.router, "GET", &new_issue_uri(""), true, None, &[]) .await .text(); assert!(html.contains("value=\"[Bug]:\"")); assert!(html.contains("## Steps to reproduce")); assert!(!html.contains("Open a blank issue")); } #[tokio::test] async fn single_file_under_github_is_used_as_is() { let fx = setup(&[(".github/ISSUE_TEMPLATE.md", "Plain body text\n")]).await; let html = send(&fx.router, "GET", &new_issue_uri(""), true, None, &[]) .await .text(); assert!(html.contains("Plain body text")); } #[tokio::test] async fn chooser_lists_at_most_twenty_templates() { let names: Vec = (0..21).map(|n| format!("t{n:02}")).collect(); let files: Vec<(String, String)> = names .iter() .map(|n| { ( format!(".forge/ISSUE_TEMPLATE/{n}.md"), format!("body {n}\n"), ) }) .collect(); let refs: Vec<(&str, &str)> = files .iter() .map(|(p, t)| (p.as_str(), t.as_str())) .collect(); let fx = setup(&refs).await; let html = send(&fx.router, "GET", &new_issue_uri(""), true, None, &[]) .await .text(); assert!(html.contains("issues/new?template=t19\"")); assert!(!html.contains("issues/new?template=t20\"")); } #[tokio::test] async fn submitting_with_a_label_applies_it_to_the_issue() { let fx = setup(&[(".forge/ISSUE_TEMPLATE/bug.md", BUG)]).await; let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap(); fx.store .create_label(&repo.id, "bug", "#d73a4a", "") .await .unwrap(); let bug_id = fx .store .list_labels(&repo.id) .await .unwrap() .into_iter() .next() .unwrap() .id; let form = format!("csrf={CSRF}&title=Crash+on+start&body=details&label={bug_id}"); let reply = send( &fx.router, "POST", &new_issue_uri(""), true, Some(&form), &[], ) .await; assert_eq!(reply.status, 303); let issue = fx .store .issue_by_number(&repo.id, 1) .await .unwrap() .unwrap(); let labels = fx.store.issue_labels(&issue.id).await.unwrap(); assert_eq!(labels.len(), 1); assert_eq!(labels[0].name, "bug"); } #[tokio::test] async fn compare_form_starts_with_the_pull_request_template() { let fx = setup(&[( ".forge/PULL_REQUEST_TEMPLATE.md", "## Summary\n\nWhat changed?\n", )]) .await; git(&fx.work, &["checkout", "-q", "-b", "feature"]); std::fs::write(fx.work.join("feature.txt"), "feature\n").unwrap(); git(&fx.work, &["add", "-A"]); git(&fx.work, &["commit", "-q", "-m", "Add feature file"]); git( &fx.work, &[ "push", "-q", &fx.bare.to_string_lossy(), "feature:refs/heads/feature", ], ); git(&fx.work, &["checkout", "-q", "main"]); let reply = send( &fx.router, "GET", &format!("/{OWNER}/{REPO}/compare/main...feature"), true, None, &[], ) .await; assert_eq!(reply.status, 200); assert!(reply.text().contains("What changed?")); } }