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 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}