1use std::collections::BTreeMap;
11
12use anyhow::{Context, Result};
13
14use crate::atlassian::adf_validated::ValidatedAdfDocument;
15use crate::atlassian::client::{AtlassianClient, JiraCreatedIssue};
16use crate::atlassian::convert::markdown_to_adf;
17use crate::atlassian::custom_fields::{merge_set_field_overrides, resolve_custom_fields};
18use crate::atlassian::document::{
19 split_custom_sections, split_frontmatter, CustomFieldSection, JfmDocument, JfmFrontmatter,
20 JiraCreateFrontmatter,
21};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ShadowedField {
30 pub field: &'static str,
32 pub frontmatter_value: String,
34 pub override_value: String,
36}
37
38impl ShadowedField {
39 pub fn warning_line(&self) -> String {
42 format!(
43 "warning: frontmatter `{}` (\"{}\") was overridden by an explicit `{}` argument (\"{}\")",
44 self.field, self.frontmatter_value, self.field, self.override_value
45 )
46 }
47}
48
49fn resolve_override(
53 field: &'static str,
54 override_value: Option<&str>,
55 frontmatter_value: Option<String>,
56 shadowed: &mut Vec<ShadowedField>,
57) -> Option<String> {
58 match (override_value, frontmatter_value) {
59 (Some(ov), Some(fm)) => {
60 if ov != fm {
61 shadowed.push(ShadowedField {
62 field,
63 frontmatter_value: fm,
64 override_value: ov.to_string(),
65 });
66 }
67 Some(ov.to_string())
68 }
69 (Some(ov), None) => Some(ov.to_string()),
70 (None, fm) => fm,
71 }
72}
73
74pub fn prepend_warnings(shadowed: &[ShadowedField], body: String) -> String {
77 if shadowed.is_empty() {
78 return body;
79 }
80 let mut out = String::new();
81 for s in shadowed {
82 out.push_str(&s.warning_line());
83 out.push('\n');
84 }
85 out.push_str(&body);
86 out
87}
88
89#[derive(Debug)]
93pub struct ResolvedJiraCreate {
94 pub project: String,
96 pub issue_type: String,
98 pub summary: String,
100 pub labels: Vec<String>,
102 pub adf: ValidatedAdfDocument,
104 pub custom_scalars: BTreeMap<String, serde_yaml::Value>,
106 pub custom_sections: Vec<CustomFieldSection>,
108 pub shadowed: Vec<ShadowedField>,
110}
111
112pub fn resolve_jira_create(
119 document: &str,
120 override_project: Option<&str>,
121 override_summary: Option<&str>,
122 override_issue_type: Option<&str>,
123 set_field_overrides: Vec<(String, serde_yaml::Value)>,
124) -> Result<ResolvedJiraCreate> {
125 let (fm_yaml, raw_body) = split_frontmatter(document)?;
126
127 let fm: JiraCreateFrontmatter = match fm_yaml {
128 Some(yaml) => serde_yaml::from_str(yaml).context("Failed to parse JFM frontmatter YAML")?,
129 None => JiraCreateFrontmatter::default(),
130 };
131
132 if fm.r#type.as_deref() == Some("confluence") {
133 anyhow::bail!("Cannot create a JIRA issue from Confluence frontmatter");
134 }
135
136 let (body_md, custom_sections) = split_custom_sections(&raw_body);
137 let adf = ValidatedAdfDocument::try_new(markdown_to_adf(&body_md)?)?;
138
139 let mut shadowed = Vec::new();
140
141 let fm_project = fm.project.clone().or_else(|| {
143 if fm.key.is_empty() {
144 None
145 } else {
146 fm.key.split('-').next().map(String::from)
147 }
148 });
149 let project = resolve_override("project", override_project, fm_project, &mut shadowed)
150 .ok_or_else(|| {
151 anyhow::anyhow!(
152 "Project key is required: set `project:` or `key:` in the frontmatter, or pass an \
153 explicit project"
154 )
155 })?;
156
157 let fm_summary = fm.summary.clone().filter(|s| !s.is_empty());
158 let summary =
159 resolve_override("summary", override_summary, fm_summary, &mut shadowed).ok_or_else(
160 || anyhow::anyhow!("Summary is required: set `summary:` in the frontmatter, or pass an explicit summary"),
161 )?;
162
163 let issue_type = resolve_override(
164 "issue_type",
165 override_issue_type,
166 fm.issue_type,
167 &mut shadowed,
168 )
169 .unwrap_or_else(|| "Task".to_string());
170
171 let custom_scalars = merge_set_field_overrides(fm.custom_fields, set_field_overrides);
172
173 Ok(ResolvedJiraCreate {
174 project,
175 issue_type,
176 summary,
177 labels: fm.labels,
178 adf,
179 custom_scalars,
180 custom_sections,
181 shadowed,
182 })
183}
184
185#[allow(clippy::too_many_arguments)]
191pub async fn create_resolved_jira_issue(
192 client: &AtlassianClient,
193 project: &str,
194 issue_type: &str,
195 summary: &str,
196 adf: &ValidatedAdfDocument,
197 labels: &[String],
198 custom_scalars: &BTreeMap<String, serde_yaml::Value>,
199 custom_sections: &[CustomFieldSection],
200) -> Result<JiraCreatedIssue> {
201 let custom_fields = if custom_scalars.is_empty() && custom_sections.is_empty() {
202 BTreeMap::new()
203 } else {
204 let createmeta = client.get_createmeta(project, issue_type).await?;
205 resolve_custom_fields(custom_scalars, custom_sections, &createmeta)?
206 };
207
208 client
209 .create_issue_with_custom_fields(
210 project,
211 issue_type,
212 summary,
213 Some(adf),
214 labels,
215 &custom_fields,
216 )
217 .await
218}
219
220#[derive(Debug)]
224pub struct ResolvedConfluenceCreate {
225 pub space_key: String,
227 pub title: String,
229 pub parent_id: Option<String>,
231 pub adf: ValidatedAdfDocument,
233 pub shadowed: Vec<ShadowedField>,
235}
236
237pub fn resolve_confluence_create(
242 document: &str,
243 override_space: Option<&str>,
244 override_title: Option<&str>,
245 override_parent: Option<&str>,
246) -> Result<ResolvedConfluenceCreate> {
247 let doc = JfmDocument::parse(document)?;
248
249 let fm = match &doc.frontmatter {
250 JfmFrontmatter::Confluence(fm) => fm,
251 JfmFrontmatter::Jira(_) => {
252 anyhow::bail!("Cannot create a Confluence page from JIRA frontmatter");
253 }
254 };
255
256 let adf = ValidatedAdfDocument::try_new(markdown_to_adf(&doc.body)?)?;
257
258 let mut shadowed = Vec::new();
259
260 let fm_space = (!fm.space_key.is_empty()).then(|| fm.space_key.clone());
261 let space_key = resolve_override("space_key", override_space, fm_space, &mut shadowed)
262 .ok_or_else(|| {
263 anyhow::anyhow!(
264 "Space key is required: set `space_key:` in the frontmatter, or pass an explicit \
265 space"
266 )
267 })?;
268
269 let fm_title = (!fm.title.is_empty()).then(|| fm.title.clone());
270 let title =
271 resolve_override("title", override_title, fm_title, &mut shadowed).ok_or_else(|| {
272 anyhow::anyhow!(
273 "Title is required: set `title:` in the frontmatter, or pass an explicit title"
274 )
275 })?;
276
277 let parent_id = resolve_override(
278 "parent_id",
279 override_parent,
280 fm.parent_id.clone(),
281 &mut shadowed,
282 );
283
284 Ok(ResolvedConfluenceCreate {
285 space_key,
286 title,
287 parent_id,
288 adf,
289 shadowed,
290 })
291}
292
293#[cfg(test)]
294#[allow(clippy::unwrap_used, clippy::expect_used)]
295mod tests {
296 use super::*;
297
298 const JIRA_DOC: &str =
299 "---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-123\nsummary: Title\n---\n\nBody\n";
300
301 #[test]
302 fn jira_derives_project_from_key() {
303 let r = resolve_jira_create(JIRA_DOC, None, None, None, vec![]).unwrap();
304 assert_eq!(r.project, "PROJ");
305 assert_eq!(r.summary, "Title");
306 assert_eq!(r.issue_type, "Task");
307 assert!(r.shadowed.is_empty());
308 }
309
310 #[test]
311 fn jira_explicit_project_field_wins_over_key() {
312 let doc = "---\ntype: jira\nkey: ABC-1\nproject: XYZ\nsummary: T\n---\n\nB\n";
313 let r = resolve_jira_create(doc, None, None, None, vec![]).unwrap();
314 assert_eq!(r.project, "XYZ");
315 }
316
317 #[test]
318 fn jira_override_records_shadowed() {
319 let r = resolve_jira_create(JIRA_DOC, Some("NEW"), None, None, vec![]).unwrap();
320 assert_eq!(r.project, "NEW");
321 assert_eq!(r.shadowed.len(), 1);
322 assert_eq!(r.shadowed[0].field, "project");
323 assert_eq!(r.shadowed[0].frontmatter_value, "PROJ");
324 assert_eq!(r.shadowed[0].override_value, "NEW");
325 assert!(r.shadowed[0].warning_line().contains("PROJ"));
326 assert!(r.shadowed[0].warning_line().contains("NEW"));
327 }
328
329 #[test]
330 fn jira_equal_override_is_not_shadowed() {
331 let r = resolve_jira_create(JIRA_DOC, Some("PROJ"), None, None, vec![]).unwrap();
332 assert_eq!(r.project, "PROJ");
333 assert!(r.shadowed.is_empty());
334 }
335
336 #[test]
337 fn jira_no_frontmatter_uses_overrides() {
338 let r =
339 resolve_jira_create("just a body\n", Some("PROJ"), Some("S"), None, vec![]).unwrap();
340 assert_eq!(r.project, "PROJ");
341 assert_eq!(r.summary, "S");
342 assert!(r.shadowed.is_empty());
343 }
344
345 #[test]
346 fn jira_missing_project_errors() {
347 let doc = "---\ntype: jira\nsummary: No project\n---\n\nB\n";
348 let err = resolve_jira_create(doc, None, None, None, vec![]).unwrap_err();
349 assert!(err.to_string().contains("Project key is required"));
350 }
351
352 #[test]
353 fn jira_missing_summary_errors() {
354 let doc = "---\ntype: jira\nproject: PROJ\n---\n\nB\n";
355 let err = resolve_jira_create(doc, None, None, None, vec![]).unwrap_err();
356 assert!(err.to_string().contains("Summary is required"));
357 }
358
359 #[test]
360 fn jira_rejects_confluence_frontmatter() {
361 let doc = "---\ntype: confluence\nspace_key: ENG\ntitle: T\n---\n\nB\n";
362 let err = resolve_jira_create(doc, None, None, None, vec![]).unwrap_err();
363 assert!(err.to_string().contains("Confluence"));
364 }
365
366 const CONF_DOC: &str =
367 "---\ntype: confluence\ninstance: https://org.atlassian.net\npage_id: '7'\ntitle: Page\nspace_key: ENG\n---\n\nBody\n";
368
369 #[test]
370 fn confluence_resolves_from_frontmatter() {
371 let r = resolve_confluence_create(CONF_DOC, None, None, None).unwrap();
372 assert_eq!(r.space_key, "ENG");
373 assert_eq!(r.title, "Page");
374 assert!(r.shadowed.is_empty());
375 }
376
377 #[test]
378 fn confluence_override_records_shadowed() {
379 let r = resolve_confluence_create(CONF_DOC, Some("NEW"), None, None).unwrap();
380 assert_eq!(r.space_key, "NEW");
381 assert_eq!(r.shadowed.len(), 1);
382 assert_eq!(r.shadowed[0].field, "space_key");
383 }
384
385 #[test]
386 fn confluence_rejects_jira_frontmatter() {
387 let doc = "---\ntype: jira\nkey: PROJ-1\nsummary: T\ninstance: https://o.net\n---\n\nB\n";
388 let err = resolve_confluence_create(doc, None, None, None).unwrap_err();
389 assert!(err.to_string().contains("JIRA"));
390 }
391
392 #[test]
393 fn confluence_missing_space_errors() {
394 let doc =
396 "---\ntype: confluence\ninstance: https://o.net\ntitle: T\nspace_key: ''\n---\n\nB\n";
397 let err = resolve_confluence_create(doc, None, None, None).unwrap_err();
398 assert!(err.to_string().contains("Space key is required"));
399 }
400
401 #[test]
402 fn confluence_missing_title_errors() {
403 let doc =
405 "---\ntype: confluence\ninstance: https://o.net\ntitle: ''\nspace_key: ENG\n---\n\nB\n";
406 let err = resolve_confluence_create(doc, None, None, None).unwrap_err();
407 assert!(err.to_string().contains("Title is required"));
408 }
409
410 #[test]
411 fn prepend_warnings_noop_when_empty() {
412 assert_eq!(prepend_warnings(&[], "body".to_string()), "body");
413 }
414}