| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | use std::path::{Path, PathBuf}; |
| 10 | |
| 11 | use forge_git::browse::{self, EntryKind, TreeEntry}; |
| 12 | |
| 13 | |
| 14 | pub const MAX_TEMPLATES: usize = 20; |
| 15 | |
| 16 | pub const MAX_TEMPLATE_BYTES: usize = 64 * 1024; |
| 17 | |
| 18 | |
| 19 | const ROOTS: [&str; 3] = [".forge", ".github", ".gitea"]; |
| 20 | const ISSUE_DIR: &str = "ISSUE_TEMPLATE"; |
| 21 | const ISSUE_FILE: &str = "ISSUE_TEMPLATE.md"; |
| 22 | const PULL_FILE: &str = "PULL_REQUEST_TEMPLATE.md"; |
| 23 | |
| 24 | |
| 25 | #[derive(Debug, Clone, PartialEq, Eq, Default)] |
| 26 | pub struct Template { |
| 27 | |
| 28 | pub stem: String, |
| 29 | |
| 30 | pub name: String, |
| 31 | pub about: String, |
| 32 | |
| 33 | pub title: String, |
| 34 | |
| 35 | pub labels: Vec<String>, |
| 36 | pub body: String, |
| 37 | } |
| 38 | |
| 39 | |
| 40 | pub fn parse_template(stem: &str, text: &str) -> Template { |
| 41 | let (front, body) = split_front_matter(text); |
| 42 | let mut template = Template { |
| 43 | stem: stem.to_owned(), |
| 44 | body: body.to_owned(), |
| 45 | ..Template::default() |
| 46 | }; |
| 47 | if let Some(front) = front { |
| 48 | for line in front.lines() { |
| 49 | let Some((key, value)) = line.split_once(':') else { |
| 50 | continue; |
| 51 | }; |
| 52 | let value = unquote(value); |
| 53 | match key.trim() { |
| 54 | "name" => template.name = value.to_owned(), |
| 55 | "about" => template.about = value.to_owned(), |
| 56 | "title" => template.title = value.to_owned(), |
| 57 | "labels" => { |
| 58 | template.labels = value |
| 59 | .split(',') |
| 60 | .map(|label| unquote(label).to_owned()) |
| 61 | .filter(|label| !label.is_empty()) |
| 62 | .collect(); |
| 63 | } |
| 64 | _ => {} |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | if template.name.trim().is_empty() { |
| 69 | template.name = stem.to_owned(); |
| 70 | } |
| 71 | template |
| 72 | } |
| 73 | |
| 74 | |
| 75 | fn split_front_matter(text: &str) -> (Option<&str>, &str) { |
| 76 | let text = text.strip_prefix('\u{feff}').unwrap_or(text); |
| 77 | let Some(first_end) = text.find('\n') else { |
| 78 | return (None, text); |
| 79 | }; |
| 80 | if text[..first_end].trim_end_matches('\r') != "---" { |
| 81 | return (None, text); |
| 82 | } |
| 83 | let rest = &text[first_end + 1..]; |
| 84 | let mut offset = 0; |
| 85 | for line in rest.split_inclusive('\n') { |
| 86 | if line.trim_end_matches(['\r', '\n']) == "---" { |
| 87 | let front = &rest[..offset]; |
| 88 | let body = rest[offset + line.len()..].trim_start_matches(['\r', '\n']); |
| 89 | return (Some(front), body); |
| 90 | } |
| 91 | offset += line.len(); |
| 92 | } |
| 93 | (None, text) |
| 94 | } |
| 95 | |
| 96 | |
| 97 | fn unquote(value: &str) -> &str { |
| 98 | let value = value.trim(); |
| 99 | for quote in ['"', '\''] { |
| 100 | if let Some(inner) = value |
| 101 | .strip_prefix(quote) |
| 102 | .and_then(|rest| rest.strip_suffix(quote)) |
| 103 | { |
| 104 | return inner.trim(); |
| 105 | } |
| 106 | } |
| 107 | value |
| 108 | } |
| 109 | |
| 110 | |
| 111 | |
| 112 | fn read_templates( |
| 113 | repo: &Path, |
| 114 | commit: &str, |
| 115 | dir: Option<&str>, |
| 116 | file: &str, |
| 117 | ) -> forge_git::Result<Vec<Template>> { |
| 118 | for root in ROOTS { |
| 119 | if let Some(dir) = dir { |
| 120 | let found = read_dir_templates(repo, commit, &format!("{root}/{dir}"))?; |
| 121 | if !found.is_empty() { |
| 122 | return Ok(found); |
| 123 | } |
| 124 | } |
| 125 | let path = format!("{root}/{file}"); |
| 126 | if let Some(text) = browse::read_text_blob(repo, commit, &path, MAX_TEMPLATE_BYTES)? { |
| 127 | let stem = file.strip_suffix(".md").unwrap_or(file); |
| 128 | return Ok(vec![parse_template(stem, &text)]); |
| 129 | } |
| 130 | } |
| 131 | Ok(Vec::new()) |
| 132 | } |
| 133 | |
| 134 | |
| 135 | fn read_dir_templates(repo: &Path, commit: &str, path: &str) -> forge_git::Result<Vec<Template>> { |
| 136 | let Some(entries) = browse::read_tree(repo, commit, path)? else { |
| 137 | return Ok(Vec::new()); |
| 138 | }; |
| 139 | let mut files: Vec<&TreeEntry> = entries |
| 140 | .iter() |
| 141 | .filter(|e| matches!(e.kind, EntryKind::Blob | EntryKind::Executable)) |
| 142 | .filter(|e| e.name.ends_with(".md") && e.name.len() > ".md".len()) |
| 143 | .collect(); |
| 144 | files.sort_by(|a, b| a.name.cmp(&b.name)); |
| 145 | let mut templates = Vec::new(); |
| 146 | for entry in files.into_iter().take(MAX_TEMPLATES) { |
| 147 | let full = format!("{path}/{}", entry.name); |
| 148 | if let Some(text) = browse::read_text_blob(repo, commit, &full, MAX_TEMPLATE_BYTES)? { |
| 149 | let stem = entry.name.strip_suffix(".md").unwrap_or(&entry.name); |
| 150 | templates.push(parse_template(stem, &text)); |
| 151 | } |
| 152 | } |
| 153 | Ok(templates) |
| 154 | } |
| 155 | |
| 156 | |
| 157 | pub fn issue_templates(repo: &Path, branch: &str) -> forge_git::Result<Vec<Template>> { |
| 158 | let Some(commit) = browse::default_branch_commit(repo, branch)? else { |
| 159 | return Ok(Vec::new()); |
| 160 | }; |
| 161 | read_templates(repo, &commit, Some(ISSUE_DIR), ISSUE_FILE) |
| 162 | } |
| 163 | |
| 164 | |
| 165 | pub fn pull_template(repo: &Path, branch: &str) -> forge_git::Result<Option<Template>> { |
| 166 | let Some(commit) = browse::default_branch_commit(repo, branch)? else { |
| 167 | return Ok(None); |
| 168 | }; |
| 169 | Ok(read_templates(repo, &commit, None, PULL_FILE)? |
| 170 | .into_iter() |
| 171 | .next()) |
| 172 | } |
| 173 | |
| 174 | |
| 175 | |
| 176 | pub async fn load_issue_templates(repo: PathBuf, branch: String) -> topcoat::Result<Vec<Template>> { |
| 177 | let result = tokio::task::spawn_blocking(move || issue_templates(&repo, &branch)).await?; |
| 178 | Ok(result.unwrap_or_else(|e| { |
| 179 | tracing::warn!(error = %e, "issue templates: read failed"); |
| 180 | Vec::new() |
| 181 | })) |
| 182 | } |
| 183 | |
| 184 | |
| 185 | pub async fn load_pull_template( |
| 186 | repo: PathBuf, |
| 187 | branch: String, |
| 188 | ) -> topcoat::Result<Option<Template>> { |
| 189 | let result = tokio::task::spawn_blocking(move || pull_template(&repo, &branch)).await?; |
| 190 | Ok(result.unwrap_or_else(|e| { |
| 191 | tracing::warn!(error = %e, "pull request template: read failed"); |
| 192 | None |
| 193 | })) |
| 194 | } |
| 195 | |
| 196 | #[cfg(test)] |
| 197 | mod tests { |
| 198 | use super::*; |
| 199 | |
| 200 | #[test] |
| 201 | fn no_front_matter_is_all_body_and_name_is_stem() { |
| 202 | let t = parse_template("bug", "Steps:\n1. click\n"); |
| 203 | assert_eq!(t.stem, "bug"); |
| 204 | assert_eq!(t.name, "bug"); |
| 205 | assert_eq!(t.about, ""); |
| 206 | assert_eq!(t.title, ""); |
| 207 | assert!(t.labels.is_empty()); |
| 208 | assert_eq!(t.body, "Steps:\n1. click\n"); |
| 209 | } |
| 210 | |
| 211 | #[test] |
| 212 | fn front_matter_fields_are_parsed_and_removed_from_body() { |
| 213 | let text = "---\nname: Bug report\nabout: Something broke\ntitle: \"[Bug]: \"\nlabels: bug, 'needs triage',, \n---\n\n## Steps\n"; |
| 214 | let t = parse_template("bug", text); |
| 215 | assert_eq!(t.name, "Bug report"); |
| 216 | assert_eq!(t.about, "Something broke"); |
| 217 | assert_eq!(t.title, "[Bug]:"); |
| 218 | assert_eq!(t.labels, vec!["bug", "needs triage"]); |
| 219 | assert_eq!(t.body, "## Steps\n"); |
| 220 | } |
| 221 | |
| 222 | #[test] |
| 223 | fn unknown_and_malformed_lines_are_ignored() { |
| 224 | let text = "---\nassignees: someone\nno colon here\nname: Feature\n---\nbody"; |
| 225 | let t = parse_template("feat", text); |
| 226 | assert_eq!(t.name, "Feature"); |
| 227 | assert_eq!(t.body, "body"); |
| 228 | } |
| 229 | |
| 230 | #[test] |
| 231 | fn blank_name_falls_back_to_stem() { |
| 232 | let t = parse_template("docs", "---\nname: \n---\nx"); |
| 233 | assert_eq!(t.name, "docs"); |
| 234 | } |
| 235 | |
| 236 | #[test] |
| 237 | fn unclosed_front_matter_is_body() { |
| 238 | let text = "---\nname: Oops\nno closing marker\n"; |
| 239 | let t = parse_template("oops", text); |
| 240 | assert_eq!(t.name, "oops"); |
| 241 | assert_eq!(t.body, text); |
| 242 | } |
| 243 | |
| 244 | #[test] |
| 245 | fn crlf_line_endings_are_handled() { |
| 246 | let text = "---\r\nname: Crlf\r\nlabels: a,b\r\n---\r\n\r\nBody\r\n"; |
| 247 | let t = parse_template("crlf", text); |
| 248 | assert_eq!(t.name, "Crlf"); |
| 249 | assert_eq!(t.labels, vec!["a", "b"]); |
| 250 | assert_eq!(t.body, "Body\r\n"); |
| 251 | } |
| 252 | |
| 253 | #[test] |
| 254 | fn a_dash_line_later_in_the_body_is_not_front_matter() { |
| 255 | let text = "Intro\n---\nname: no\n---\n"; |
| 256 | let t = parse_template("x", text); |
| 257 | assert_eq!(t.name, "x"); |
| 258 | assert_eq!(t.body, text); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | #[cfg(test)] |
| 263 | mod route_tests { |
| 264 | use forge_core::Visibility; |
| 265 | |
| 266 | use crate::pages::commits::test_support::{Fixture, git}; |
| 267 | use crate::pages::issues_list::test_util::{CSRF, OWNER, REPO, TOKEN, send}; |
| 268 | |
| 269 | 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"; |
| 270 | const FEATURE: &str = "---\nname: Feature request\nabout: Suggest an idea\n---\n## Idea\n"; |
| 271 | |
| 272 | |
| 273 | |
| 274 | async fn setup(files: &[(&str, &str)]) -> Fixture { |
| 275 | let fx = Fixture::new(Visibility::Public).await; |
| 276 | for (path, text) in files { |
| 277 | let full = fx.work.join(path); |
| 278 | std::fs::create_dir_all(full.parent().unwrap()).unwrap(); |
| 279 | std::fs::write(full, text).unwrap(); |
| 280 | } |
| 281 | git(&fx.work, &["add", "-A"]); |
| 282 | git(&fx.work, &["commit", "-q", "-m", "Add templates"]); |
| 283 | git( |
| 284 | &fx.work, |
| 285 | &[ |
| 286 | "push", |
| 287 | "-q", |
| 288 | &fx.bare.to_string_lossy(), |
| 289 | "HEAD:refs/heads/main", |
| 290 | ], |
| 291 | ); |
| 292 | let owner = fx.store.user_by_username(OWNER).await.unwrap().unwrap(); |
| 293 | crate::test_support::create_session(&fx.store, &owner.id, TOKEN, CSRF).await; |
| 294 | fx |
| 295 | } |
| 296 | |
| 297 | fn new_issue_uri(query: &str) -> String { |
| 298 | format!("/{OWNER}/{REPO}/issues/new{query}") |
| 299 | } |
| 300 | |
| 301 | |
| 302 | fn tag_at(html: &str, needle: &str) -> String { |
| 303 | let at = html.find(needle).expect("needle in page"); |
| 304 | let end = html[at..].find('>').expect("tag end") + at; |
| 305 | html[at..end].to_owned() |
| 306 | } |
| 307 | |
| 308 | #[tokio::test] |
| 309 | async fn several_templates_show_a_chooser() { |
| 310 | let fx = setup(&[ |
| 311 | (".forge/ISSUE_TEMPLATE/bug.md", BUG), |
| 312 | (".forge/ISSUE_TEMPLATE/feature.md", FEATURE), |
| 313 | ]) |
| 314 | .await; |
| 315 | let reply = send(&fx.router, "GET", &new_issue_uri(""), true, None, &[]).await; |
| 316 | assert_eq!(reply.status, 200); |
| 317 | let html = reply.text(); |
| 318 | assert!(html.contains("Bug report")); |
| 319 | assert!(html.contains("Something broke")); |
| 320 | assert!(html.contains("issues/new?template=bug\"")); |
| 321 | assert!(html.contains("issues/new?template=feature\"")); |
| 322 | assert!(html.contains("Open a blank issue")); |
| 323 | assert!(html.contains("issues/new?blank=1\"")); |
| 324 | assert!(!html.contains("name=\"title\"")); |
| 325 | } |
| 326 | |
| 327 | #[tokio::test] |
| 328 | async fn chosen_template_prefills_and_preselects_existing_labels() { |
| 329 | let fx = setup(&[ |
| 330 | (".forge/ISSUE_TEMPLATE/bug.md", BUG), |
| 331 | (".forge/ISSUE_TEMPLATE/feature.md", FEATURE), |
| 332 | ]) |
| 333 | .await; |
| 334 | let repo = fx |
| 335 | .store |
| 336 | .repo_by_owner_and_name(OWNER, REPO) |
| 337 | .await |
| 338 | .unwrap() |
| 339 | .unwrap(); |
| 340 | fx.store |
| 341 | .create_label(&repo.id, "bug", "#d73a4a", "") |
| 342 | .await |
| 343 | .unwrap(); |
| 344 | fx.store |
| 345 | .create_label(&repo.id, "docs", "#0075ca", "") |
| 346 | .await |
| 347 | .unwrap(); |
| 348 | let bug_id = fx |
| 349 | .store |
| 350 | .list_labels(&repo.id) |
| 351 | .await |
| 352 | .unwrap() |
| 353 | .into_iter() |
| 354 | .find(|l| l.name == "bug") |
| 355 | .unwrap() |
| 356 | .id; |
| 357 | |
| 358 | let reply = send( |
| 359 | &fx.router, |
| 360 | "GET", |
| 361 | &new_issue_uri("?template=bug"), |
| 362 | true, |
| 363 | None, |
| 364 | &[], |
| 365 | ) |
| 366 | .await; |
| 367 | assert_eq!(reply.status, 200); |
| 368 | let html = reply.text(); |
| 369 | assert!(html.contains("name=\"title\"")); |
| 370 | assert!(html.contains("value=\"[Bug]:\"")); |
| 371 | assert!(html.contains("## Steps to reproduce")); |
| 372 | assert!(!html.contains("Choose a template")); |
| 373 | assert!(tag_at(&html, &format!("value=\"{bug_id}\"")).contains("checked")); |
| 374 | assert!(!html.contains("value=\"needs triage\"")); |
| 375 | assert_eq!(html.matches("name=\"label\"").count(), 2); |
| 376 | let docs_id = fx |
| 377 | .store |
| 378 | .list_labels(&repo.id) |
| 379 | .await |
| 380 | .unwrap() |
| 381 | .into_iter() |
| 382 | .find(|l| l.name == "docs") |
| 383 | .unwrap() |
| 384 | .id; |
| 385 | assert!(!tag_at(&html, &format!("value=\"{docs_id}\"")).contains("checked")); |
| 386 | } |
| 387 | |
| 388 | #[tokio::test] |
| 389 | async fn blank_issue_from_chooser_is_empty() { |
| 390 | let fx = setup(&[ |
| 391 | (".forge/ISSUE_TEMPLATE/bug.md", BUG), |
| 392 | (".forge/ISSUE_TEMPLATE/feature.md", FEATURE), |
| 393 | ]) |
| 394 | .await; |
| 395 | let reply = send( |
| 396 | &fx.router, |
| 397 | "GET", |
| 398 | &new_issue_uri("?blank=1"), |
| 399 | true, |
| 400 | None, |
| 401 | &[], |
| 402 | ) |
| 403 | .await; |
| 404 | let html = reply.text(); |
| 405 | assert!(html.contains("name=\"title\"")); |
| 406 | assert!(!html.contains("Steps to reproduce")); |
| 407 | assert!(!html.contains("Choose a template")); |
| 408 | } |
| 409 | |
| 410 | #[tokio::test] |
| 411 | async fn single_template_prefills_without_chooser() { |
| 412 | let fx = setup(&[(".forge/ISSUE_TEMPLATE/bug.md", BUG)]).await; |
| 413 | let html = send(&fx.router, "GET", &new_issue_uri(""), true, None, &[]) |
| 414 | .await |
| 415 | .text(); |
| 416 | assert!(html.contains("value=\"[Bug]:\"")); |
| 417 | assert!(html.contains("## Steps to reproduce")); |
| 418 | assert!(!html.contains("Open a blank issue")); |
| 419 | } |
| 420 | |
| 421 | #[tokio::test] |
| 422 | async fn single_file_under_github_is_used_as_is() { |
| 423 | let fx = setup(&[(".github/ISSUE_TEMPLATE.md", "Plain body text\n")]).await; |
| 424 | let html = send(&fx.router, "GET", &new_issue_uri(""), true, None, &[]) |
| 425 | .await |
| 426 | .text(); |
| 427 | assert!(html.contains("Plain body text")); |
| 428 | } |
| 429 | |
| 430 | #[tokio::test] |
| 431 | async fn chooser_lists_at_most_twenty_templates() { |
| 432 | let names: Vec<String> = (0..21).map(|n| format!("t{n:02}")).collect(); |
| 433 | let files: Vec<(String, String)> = names |
| 434 | .iter() |
| 435 | .map(|n| { |
| 436 | ( |
| 437 | format!(".forge/ISSUE_TEMPLATE/{n}.md"), |
| 438 | format!("body {n}\n"), |
| 439 | ) |
| 440 | }) |
| 441 | .collect(); |
| 442 | let refs: Vec<(&str, &str)> = files |
| 443 | .iter() |
| 444 | .map(|(p, t)| (p.as_str(), t.as_str())) |
| 445 | .collect(); |
| 446 | let fx = setup(&refs).await; |
| 447 | let html = send(&fx.router, "GET", &new_issue_uri(""), true, None, &[]) |
| 448 | .await |
| 449 | .text(); |
| 450 | assert!(html.contains("issues/new?template=t19\"")); |
| 451 | assert!(!html.contains("issues/new?template=t20\"")); |
| 452 | } |
| 453 | |
| 454 | #[tokio::test] |
| 455 | async fn submitting_with_a_label_applies_it_to_the_issue() { |
| 456 | let fx = setup(&[(".forge/ISSUE_TEMPLATE/bug.md", BUG)]).await; |
| 457 | let repo = fx |
| 458 | .store |
| 459 | .repo_by_owner_and_name(OWNER, REPO) |
| 460 | .await |
| 461 | .unwrap() |
| 462 | .unwrap(); |
| 463 | fx.store |
| 464 | .create_label(&repo.id, "bug", "#d73a4a", "") |
| 465 | .await |
| 466 | .unwrap(); |
| 467 | let bug_id = fx |
| 468 | .store |
| 469 | .list_labels(&repo.id) |
| 470 | .await |
| 471 | .unwrap() |
| 472 | .into_iter() |
| 473 | .next() |
| 474 | .unwrap() |
| 475 | .id; |
| 476 | let form = format!("csrf={CSRF}&title=Crash+on+start&body=details&label={bug_id}"); |
| 477 | let reply = send( |
| 478 | &fx.router, |
| 479 | "POST", |
| 480 | &new_issue_uri(""), |
| 481 | true, |
| 482 | Some(&form), |
| 483 | &[], |
| 484 | ) |
| 485 | .await; |
| 486 | assert_eq!(reply.status, 303); |
| 487 | let issue = fx |
| 488 | .store |
| 489 | .issue_by_number(&repo.id, 1) |
| 490 | .await |
| 491 | .unwrap() |
| 492 | .unwrap(); |
| 493 | let labels = fx.store.issue_labels(&issue.id).await.unwrap(); |
| 494 | assert_eq!(labels.len(), 1); |
| 495 | assert_eq!(labels[0].name, "bug"); |
| 496 | } |
| 497 | |
| 498 | #[tokio::test] |
| 499 | async fn compare_form_starts_with_the_pull_request_template() { |
| 500 | let fx = setup(&[( |
| 501 | ".forge/PULL_REQUEST_TEMPLATE.md", |
| 502 | "## Summary\n\nWhat changed?\n", |
| 503 | )]) |
| 504 | .await; |
| 505 | git(&fx.work, &["checkout", "-q", "-b", "feature"]); |
| 506 | std::fs::write(fx.work.join("feature.txt"), "feature\n").unwrap(); |
| 507 | git(&fx.work, &["add", "-A"]); |
| 508 | git(&fx.work, &["commit", "-q", "-m", "Add feature file"]); |
| 509 | git( |
| 510 | &fx.work, |
| 511 | &[ |
| 512 | "push", |
| 513 | "-q", |
| 514 | &fx.bare.to_string_lossy(), |
| 515 | "feature:refs/heads/feature", |
| 516 | ], |
| 517 | ); |
| 518 | git(&fx.work, &["checkout", "-q", "main"]); |
| 519 | |
| 520 | let reply = send( |
| 521 | &fx.router, |
| 522 | "GET", |
| 523 | &format!("/{OWNER}/{REPO}/compare/main...feature"), |
| 524 | true, |
| 525 | None, |
| 526 | &[], |
| 527 | ) |
| 528 | .await; |
| 529 | assert_eq!(reply.status, 200); |
| 530 | assert!(reply.text().contains("What changed?")); |
| 531 | } |
| 532 | } |