Skip to main content

omni_dev/atlassian/
create.rs

1//! Shared create-from-frontmatter resolution for the CLI and MCP create paths.
2//!
3//! Both `omni-dev atlassian {jira,confluence} create` and the MCP
4//! `jira_create` / `confluence_create` tools resolve a JFM document
5//! (frontmatter + body) plus explicit overrides into the fields needed to
6//! create an issue/page. Keeping that logic here — instead of duplicating it
7//! per surface — is what lets the read → edit → create round-trip behave
8//! identically on the CLI and over MCP.
9
10use std::collections::BTreeMap;
11
12use anyhow::{Context, Result};
13
14use crate::atlassian::adf_validated::{markdown_to_validated_adf, ValidatedAdfDocument};
15use crate::atlassian::client::AtlassianClient;
16use crate::atlassian::custom_fields::{merge_set_field_overrides, resolve_custom_fields};
17use crate::atlassian::document::{
18    split_custom_sections, split_frontmatter, CustomFieldSection, JfmDocument, JfmFrontmatter,
19    JiraCreateFrontmatter,
20};
21use crate::atlassian::jira_types::JiraCreatedIssue;
22
23// ── override warnings ───────────────────────────────────────────────────────
24
25/// A frontmatter field that was present but shadowed by an explicit
26/// flag/parameter override. Recorded so callers can warn the user that their
27/// frontmatter value was ignored.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ShadowedField {
30    /// Field name as it appears in frontmatter (e.g. `project`, `space_key`).
31    pub field: &'static str,
32    /// The value present in the frontmatter.
33    pub frontmatter_value: String,
34    /// The overriding value supplied via flag/parameter.
35    pub override_value: String,
36}
37
38impl ShadowedField {
39    /// One-line warning, suitable for stderr (CLI) or in-band prepending (MCP,
40    /// where stderr is not visible to the caller).
41    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
49/// Applies explicit-override-over-frontmatter precedence, recording a
50/// [`ShadowedField`] when a non-empty frontmatter value is replaced by a
51/// *differing* override (equal values are kept silently — no noise).
52fn 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
74/// Prepends one warning line per shadowed field to `body`, for in-band (MCP)
75/// surfacing. Returns `body` unchanged when nothing was shadowed.
76pub 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// ── JIRA ────────────────────────────────────────────────────────────────────
90
91/// Resolved inputs for creating a JIRA issue, plus any shadowed-field records.
92#[derive(Debug)]
93pub struct ResolvedJiraCreate {
94    /// Project key.
95    pub project: String,
96    /// Issue type (defaults to `Task`).
97    pub issue_type: String,
98    /// Issue summary / title.
99    pub summary: String,
100    /// Labels applied to the issue.
101    pub labels: Vec<String>,
102    /// Validated ADF body.
103    pub adf: ValidatedAdfDocument,
104    /// Scalar custom field values keyed by human-readable name.
105    pub custom_scalars: BTreeMap<String, serde_yaml::Value>,
106    /// Rich-text custom field sections extracted from the body.
107    pub custom_sections: Vec<CustomFieldSection>,
108    /// Frontmatter values shadowed by an explicit override.
109    pub shadowed: Vec<ShadowedField>,
110}
111
112/// Resolves a JFM `document` plus explicit overrides into JIRA create fields.
113///
114/// Frontmatter is optional — a body with no `---` block is treated entirely as
115/// the issue body. Precedence is override → frontmatter → derived/default.
116/// `set_field_overrides` are `NAME=VALUE` custom-field overrides (the CLI's
117/// `--set-field`); the MCP passes an empty vec.
118pub 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 = markdown_to_validated_adf(&body_md)?;
138
139    let mut shadowed = Vec::new();
140
141    // Derive project from `key:` when no explicit `project:` is present.
142    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/// Creates a JIRA issue from resolved fields.
186///
187/// Fast path when no custom fields are requested: one POST to
188/// `/rest/api/3/issue`. With custom fields, fetches `createmeta` to resolve
189/// human names to IDs and dispatch values by schema before sending.
190#[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// ── Confluence ──────────────────────────────────────────────────────────────
221
222/// Resolved inputs for creating a Confluence page, plus shadowed-field records.
223#[derive(Debug)]
224pub struct ResolvedConfluenceCreate {
225    /// Target space key.
226    pub space_key: String,
227    /// Page title.
228    pub title: String,
229    /// Optional parent page ID.
230    pub parent_id: Option<String>,
231    /// Validated ADF body.
232    pub adf: ValidatedAdfDocument,
233    /// Frontmatter values shadowed by an explicit override.
234    pub shadowed: Vec<ShadowedField>,
235}
236
237/// Resolves a JFM `document` plus explicit overrides into Confluence create fields.
238///
239/// Expects the strict form `confluence_read` emits. Precedence is
240/// override → frontmatter.
241pub 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 = markdown_to_validated_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        // Empty `space_key:` with no override → space-required error.
395        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        // Space present, empty `title:` with no override → title-required error.
404        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}