Skip to main content

gwm/
issue_templates.rs

1use crate::config::{Config, IssueTemplateTypeConfig};
2use crate::error::{GwmError, Result};
3use crate::templating::{self, FormDefaults, TemplateContext};
4use crate::worktree;
5use git2::Repository;
6use std::collections::BTreeMap;
7use std::io::Write;
8use std::path::{Component, Path, PathBuf};
9
10#[derive(Debug)]
11pub struct IssueDraft {
12  pub title: String,
13  pub labels: Vec<String>,
14  pub body_file: tempfile::NamedTempFile,
15}
16
17pub fn render_issue_draft(repo: &Repository, config: &Config, branch_type: &str, desc: &str) -> Result<IssueDraft> {
18  let workdir = repo.workdir().ok_or(GwmError::NotInGitRepo)?;
19  let type_config = config.issue_template.by_type.get(branch_type);
20  let template_name = type_config
21    .and_then(|cfg| cfg.template.as_deref())
22    .or(config.issue_template.default.as_deref())
23    .ok_or_else(|| {
24      GwmError::Config(format!(
25        "no issue template configured for branch type '{}' (set [issue_template].default or [issue_template.by_type.{}].template)",
26        branch_type, branch_type
27      ))
28    })?;
29  let template_path = resolve_template_path(workdir, template_name)?;
30  let raw = std::fs::read_to_string(&template_path)?;
31  let meta = templating::issue_form_metadata(&raw)?;
32  let ctx = TemplateContext::from_pairs([
33    ("type", branch_type),
34    ("issue", ""),
35    ("desc", desc),
36    ("repo", &worktree::repo_name(repo)),
37  ]);
38  let defaults = defaults_for(type_config);
39  let body = templating::render_form_markdown(&raw, &ctx, &defaults)?;
40  let mut body_file = tempfile::NamedTempFile::new()?;
41  body_file.write_all(body.as_bytes())?;
42  body_file.flush()?;
43
44  let title_prefix = type_config
45    .and_then(|cfg| cfg.title_prefix.as_deref())
46    .or(meta.title.as_deref())
47    .unwrap_or_default();
48  let mut labels = meta.labels;
49  if let Some(cfg) = type_config {
50    labels.extend(cfg.labels.clone());
51  }
52  labels.sort();
53  labels.dedup();
54
55  Ok(IssueDraft {
56    title: format!("{}{}", title_prefix, desc),
57    labels,
58    body_file,
59  })
60}
61
62fn defaults_for(type_config: Option<&IssueTemplateTypeConfig>) -> FormDefaults {
63  let mut fields = BTreeMap::new();
64  if let Some(surface) = type_config.and_then(|cfg| cfg.surface.as_deref()) {
65    fields.insert("surface".to_string(), surface.to_string());
66  }
67  FormDefaults { fields }
68}
69
70fn resolve_template_path(workdir: &Path, template_name: &str) -> Result<PathBuf> {
71  let rel = Path::new(template_name);
72  // Reject anything that could escape the worktree root or the
73  // `.github/ISSUE_TEMPLATE` base:
74  //   - absolute paths (Unix `/etc/passwd`, Windows `C:\Windows\…`)
75  //   - parent traversals (`..`)
76  //   - Windows drive prefixes on relative paths (`C:foo.yml` parses as a
77  //     relative path with a `Prefix` component but joining it onto `workdir`
78  //     can ignore the base)
79  //   - root-only segments (`\foo.yml` is not absolute on Windows but has a
80  //     `RootDir` component that resets the joined path)
81  let suspicious = rel.is_absolute()
82    || rel
83      .components()
84      .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_) | Component::RootDir));
85  if suspicious {
86    return Err(GwmError::Config(format!(
87      "issue template path '{}' must be relative and stay inside .github/ISSUE_TEMPLATE",
88      template_name
89    )));
90  }
91  let joined = if rel.starts_with(".github") {
92    workdir.join(rel)
93  } else {
94    workdir.join(".github").join("ISSUE_TEMPLATE").join(rel)
95  };
96  if joined.strip_prefix(workdir).is_err() {
97    return Err(GwmError::Config(format!(
98      "issue template path '{}' escapes the worktree root",
99      template_name
100    )));
101  }
102  Ok(joined)
103}
104
105#[cfg(test)]
106mod tests {
107  use super::*;
108
109  #[test]
110  fn resolve_rejects_parent_traversal() {
111    let workdir = Path::new("/tmp/wd");
112    let err = resolve_template_path(workdir, "../etc/passwd.yml").unwrap_err();
113    assert!(matches!(err, GwmError::Config(_)), "got {err:?}");
114  }
115
116  #[test]
117  fn resolve_rejects_absolute_paths() {
118    let workdir = Path::new("/tmp/wd");
119    let err = resolve_template_path(workdir, "/etc/passwd.yml").unwrap_err();
120    assert!(matches!(err, GwmError::Config(_)), "got {err:?}");
121  }
122
123  #[cfg(windows)]
124  #[test]
125  fn resolve_rejects_windows_drive_prefix() {
126    let workdir = Path::new(r"C:\tmp\wd");
127    let err = resolve_template_path(workdir, "C:foo.yml").unwrap_err();
128    assert!(matches!(err, GwmError::Config(_)), "got {err:?}");
129  }
130
131  #[cfg(windows)]
132  #[test]
133  fn resolve_rejects_windows_rootdir_prefix() {
134    let workdir = Path::new(r"C:\tmp\wd");
135    let err = resolve_template_path(workdir, r"\Windows\System32\config").unwrap_err();
136    assert!(matches!(err, GwmError::Config(_)), "got {err:?}");
137  }
138
139  #[test]
140  fn resolve_accepts_plain_template_name() {
141    let workdir = Path::new("/tmp/wd");
142    let path = resolve_template_path(workdir, "feature_request.yml").unwrap();
143    assert_eq!(
144      path,
145      workdir
146        .join(".github")
147        .join("ISSUE_TEMPLATE")
148        .join("feature_request.yml")
149    );
150  }
151
152  #[test]
153  fn resolve_accepts_explicit_dot_github_prefix() {
154    let workdir = Path::new("/tmp/wd");
155    let path = resolve_template_path(workdir, ".github/ISSUE_TEMPLATE/bug.yml").unwrap();
156    assert_eq!(path, workdir.join(".github").join("ISSUE_TEMPLATE").join("bug.yml"));
157  }
158}