forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
18.0 KiB
1//! Issue and pull request templates, read from the repository's default branch.
2//!
3//! Issue templates live in `ISSUE_TEMPLATE/*.md` or `ISSUE_TEMPLATE.md`; the pull request
4//! template is `PULL_REQUEST_TEMPLATE.md`. Each is looked up under `.forge/`, then `.github/`,
5//! then `.gitea/`, and the first location that yields a template wins. A file may start with
6//! front matter between two `---` lines holding simple `key: value` lines (`name`, `about`,
7//! `title`, `labels`); everything after it is the body.
8
9use std::path::{Path, PathBuf};
10
11use forge_git::browse::{self, EntryKind, TreeEntry};
12
13/// Most issue templates offered per repository.
14pub const MAX_TEMPLATES: usize = 20;
15/// Largest template file read. Bigger files are skipped.
16pub const MAX_TEMPLATE_BYTES: usize = 64 * 1024;
17
18/// Directories searched for templates, in priority order.
19const ROOTS: [&str; 3] = [".forge", ".github", ".gitea"];
20const ISSUE_DIR: &str = "ISSUE_TEMPLATE";
21const ISSUE_FILE: &str = "ISSUE_TEMPLATE.md";
22const PULL_FILE: &str = "PULL_REQUEST_TEMPLATE.md";
23
24/// One parsed template file.
25#[derive(Debug, Clone, PartialEq, Eq, Default)]
26pub struct Template {
27 /// File name without `.md`. This is the `template` query value that selects it.
28 pub stem: String,
29 /// Display name: the `name` front matter, or the stem.
30 pub name: String,
31 pub about: String,
32 /// Suggested title. Empty when the template sets none.
33 pub title: String,
34 /// Suggested label names, in file order.
35 pub labels: Vec<String>,
36 pub body: String,
37}
38
39/// Parses one template file. `stem` is the file name without `.md`.
40pub 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/// Splits front matter from the body. Text without a closed front matter block is all body.
75fn 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/// A value with one pair of matching surrounding quotes removed, and whitespace trimmed.
97fn 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/// Templates from the first root in `ROOTS` that has any. `dir` names a directory of `.md` files
111/// (tried first under each root); `file` is the single-file form.
112fn 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/// The `.md` files directly in `path`, read in name order, at most `MAX_TEMPLATES`.
135fn 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/// The issue templates on `branch`. Empty when the branch does not exist or has none.
157pub 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/// The pull request template on `branch`, if any.
165pub 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/// `issue_templates` on a blocking thread. A read failure is logged and gives no templates, so
175/// the page still renders.
176pub 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/// `pull_template` on a blocking thread. A read failure is logged and gives no template.
185pub 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)]
197mod 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)]
263mod 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 /// A repo whose default branch gets `files` committed on top of the fixture's history.
273 /// Alice is signed in.
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 /// The text of the tag that starts at `needle`, up to its closing `>`.
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}