use std::collections::BTreeMap;
use anyhow::{Context, Result};
use crate::atlassian::adf_validated::{markdown_to_validated_adf, ValidatedAdfDocument};
use crate::atlassian::client::AtlassianClient;
use crate::atlassian::custom_fields::{merge_set_field_overrides, resolve_custom_fields};
use crate::atlassian::document::{
split_custom_sections, split_frontmatter, CustomFieldSection, JfmDocument, JfmFrontmatter,
JiraCreateFrontmatter,
};
use crate::atlassian::jira_types::JiraCreatedIssue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShadowedField {
pub field: &'static str,
pub frontmatter_value: String,
pub override_value: String,
}
impl ShadowedField {
pub fn warning_line(&self) -> String {
format!(
"warning: frontmatter `{}` (\"{}\") was overridden by an explicit `{}` argument (\"{}\")",
self.field, self.frontmatter_value, self.field, self.override_value
)
}
}
fn resolve_override(
field: &'static str,
override_value: Option<&str>,
frontmatter_value: Option<String>,
shadowed: &mut Vec<ShadowedField>,
) -> Option<String> {
match (override_value, frontmatter_value) {
(Some(ov), Some(fm)) => {
if ov != fm {
shadowed.push(ShadowedField {
field,
frontmatter_value: fm,
override_value: ov.to_string(),
});
}
Some(ov.to_string())
}
(Some(ov), None) => Some(ov.to_string()),
(None, fm) => fm,
}
}
pub fn prepend_warnings(shadowed: &[ShadowedField], body: String) -> String {
if shadowed.is_empty() {
return body;
}
let mut out = String::new();
for s in shadowed {
out.push_str(&s.warning_line());
out.push('\n');
}
out.push_str(&body);
out
}
#[derive(Debug)]
pub struct ResolvedJiraCreate {
pub project: String,
pub issue_type: String,
pub summary: String,
pub labels: Vec<String>,
pub adf: ValidatedAdfDocument,
pub custom_scalars: BTreeMap<String, serde_yaml::Value>,
pub custom_sections: Vec<CustomFieldSection>,
pub shadowed: Vec<ShadowedField>,
}
pub fn resolve_jira_create(
document: &str,
override_project: Option<&str>,
override_summary: Option<&str>,
override_issue_type: Option<&str>,
set_field_overrides: Vec<(String, serde_yaml::Value)>,
) -> Result<ResolvedJiraCreate> {
let (fm_yaml, raw_body) = split_frontmatter(document)?;
let fm: JiraCreateFrontmatter = match fm_yaml {
Some(yaml) => serde_yaml::from_str(yaml).context("Failed to parse JFM frontmatter YAML")?,
None => JiraCreateFrontmatter::default(),
};
if fm.r#type.as_deref() == Some("confluence") {
anyhow::bail!("Cannot create a JIRA issue from Confluence frontmatter");
}
let (body_md, custom_sections) = split_custom_sections(&raw_body);
let adf = markdown_to_validated_adf(&body_md)?;
let mut shadowed = Vec::new();
let fm_project = fm.project.clone().or_else(|| {
if fm.key.is_empty() {
None
} else {
fm.key.split('-').next().map(String::from)
}
});
let project = resolve_override("project", override_project, fm_project, &mut shadowed)
.ok_or_else(|| {
anyhow::anyhow!(
"Project key is required: set `project:` or `key:` in the frontmatter, or pass an \
explicit project"
)
})?;
let fm_summary = fm.summary.clone().filter(|s| !s.is_empty());
let summary =
resolve_override("summary", override_summary, fm_summary, &mut shadowed).ok_or_else(
|| anyhow::anyhow!("Summary is required: set `summary:` in the frontmatter, or pass an explicit summary"),
)?;
let issue_type = resolve_override(
"issue_type",
override_issue_type,
fm.issue_type,
&mut shadowed,
)
.unwrap_or_else(|| "Task".to_string());
let custom_scalars = merge_set_field_overrides(fm.custom_fields, set_field_overrides);
Ok(ResolvedJiraCreate {
project,
issue_type,
summary,
labels: fm.labels,
adf,
custom_scalars,
custom_sections,
shadowed,
})
}
#[allow(clippy::too_many_arguments)]
pub async fn create_resolved_jira_issue(
client: &AtlassianClient,
project: &str,
issue_type: &str,
summary: &str,
adf: &ValidatedAdfDocument,
labels: &[String],
custom_scalars: &BTreeMap<String, serde_yaml::Value>,
custom_sections: &[CustomFieldSection],
) -> Result<JiraCreatedIssue> {
let custom_fields = if custom_scalars.is_empty() && custom_sections.is_empty() {
BTreeMap::new()
} else {
let createmeta = client.get_createmeta(project, issue_type).await?;
resolve_custom_fields(custom_scalars, custom_sections, &createmeta)?
};
client
.create_issue_with_custom_fields(
project,
issue_type,
summary,
Some(adf),
labels,
&custom_fields,
)
.await
}
#[derive(Debug)]
pub struct ResolvedConfluenceCreate {
pub space_key: String,
pub title: String,
pub parent_id: Option<String>,
pub adf: ValidatedAdfDocument,
pub shadowed: Vec<ShadowedField>,
}
pub fn resolve_confluence_create(
document: &str,
override_space: Option<&str>,
override_title: Option<&str>,
override_parent: Option<&str>,
) -> Result<ResolvedConfluenceCreate> {
let doc = JfmDocument::parse(document)?;
let fm = match &doc.frontmatter {
JfmFrontmatter::Confluence(fm) => fm,
JfmFrontmatter::Jira(_) => {
anyhow::bail!("Cannot create a Confluence page from JIRA frontmatter");
}
};
let adf = markdown_to_validated_adf(&doc.body)?;
let mut shadowed = Vec::new();
let fm_space = (!fm.space_key.is_empty()).then(|| fm.space_key.clone());
let space_key = resolve_override("space_key", override_space, fm_space, &mut shadowed)
.ok_or_else(|| {
anyhow::anyhow!(
"Space key is required: set `space_key:` in the frontmatter, or pass an explicit \
space"
)
})?;
let fm_title = (!fm.title.is_empty()).then(|| fm.title.clone());
let title =
resolve_override("title", override_title, fm_title, &mut shadowed).ok_or_else(|| {
anyhow::anyhow!(
"Title is required: set `title:` in the frontmatter, or pass an explicit title"
)
})?;
let parent_id = resolve_override(
"parent_id",
override_parent,
fm.parent_id.clone(),
&mut shadowed,
);
Ok(ResolvedConfluenceCreate {
space_key,
title,
parent_id,
adf,
shadowed,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const JIRA_DOC: &str =
"---\ntype: jira\ninstance: https://org.atlassian.net\nkey: PROJ-123\nsummary: Title\n---\n\nBody\n";
#[test]
fn jira_derives_project_from_key() {
let r = resolve_jira_create(JIRA_DOC, None, None, None, vec![]).unwrap();
assert_eq!(r.project, "PROJ");
assert_eq!(r.summary, "Title");
assert_eq!(r.issue_type, "Task");
assert!(r.shadowed.is_empty());
}
#[test]
fn jira_explicit_project_field_wins_over_key() {
let doc = "---\ntype: jira\nkey: ABC-1\nproject: XYZ\nsummary: T\n---\n\nB\n";
let r = resolve_jira_create(doc, None, None, None, vec![]).unwrap();
assert_eq!(r.project, "XYZ");
}
#[test]
fn jira_override_records_shadowed() {
let r = resolve_jira_create(JIRA_DOC, Some("NEW"), None, None, vec![]).unwrap();
assert_eq!(r.project, "NEW");
assert_eq!(r.shadowed.len(), 1);
assert_eq!(r.shadowed[0].field, "project");
assert_eq!(r.shadowed[0].frontmatter_value, "PROJ");
assert_eq!(r.shadowed[0].override_value, "NEW");
assert!(r.shadowed[0].warning_line().contains("PROJ"));
assert!(r.shadowed[0].warning_line().contains("NEW"));
}
#[test]
fn jira_equal_override_is_not_shadowed() {
let r = resolve_jira_create(JIRA_DOC, Some("PROJ"), None, None, vec![]).unwrap();
assert_eq!(r.project, "PROJ");
assert!(r.shadowed.is_empty());
}
#[test]
fn jira_no_frontmatter_uses_overrides() {
let r =
resolve_jira_create("just a body\n", Some("PROJ"), Some("S"), None, vec![]).unwrap();
assert_eq!(r.project, "PROJ");
assert_eq!(r.summary, "S");
assert!(r.shadowed.is_empty());
}
#[test]
fn jira_missing_project_errors() {
let doc = "---\ntype: jira\nsummary: No project\n---\n\nB\n";
let err = resolve_jira_create(doc, None, None, None, vec![]).unwrap_err();
assert!(err.to_string().contains("Project key is required"));
}
#[test]
fn jira_missing_summary_errors() {
let doc = "---\ntype: jira\nproject: PROJ\n---\n\nB\n";
let err = resolve_jira_create(doc, None, None, None, vec![]).unwrap_err();
assert!(err.to_string().contains("Summary is required"));
}
#[test]
fn jira_rejects_confluence_frontmatter() {
let doc = "---\ntype: confluence\nspace_key: ENG\ntitle: T\n---\n\nB\n";
let err = resolve_jira_create(doc, None, None, None, vec![]).unwrap_err();
assert!(err.to_string().contains("Confluence"));
}
const CONF_DOC: &str =
"---\ntype: confluence\ninstance: https://org.atlassian.net\npage_id: '7'\ntitle: Page\nspace_key: ENG\n---\n\nBody\n";
#[test]
fn confluence_resolves_from_frontmatter() {
let r = resolve_confluence_create(CONF_DOC, None, None, None).unwrap();
assert_eq!(r.space_key, "ENG");
assert_eq!(r.title, "Page");
assert!(r.shadowed.is_empty());
}
#[test]
fn confluence_override_records_shadowed() {
let r = resolve_confluence_create(CONF_DOC, Some("NEW"), None, None).unwrap();
assert_eq!(r.space_key, "NEW");
assert_eq!(r.shadowed.len(), 1);
assert_eq!(r.shadowed[0].field, "space_key");
}
#[test]
fn confluence_rejects_jira_frontmatter() {
let doc = "---\ntype: jira\nkey: PROJ-1\nsummary: T\ninstance: https://o.net\n---\n\nB\n";
let err = resolve_confluence_create(doc, None, None, None).unwrap_err();
assert!(err.to_string().contains("JIRA"));
}
#[test]
fn confluence_missing_space_errors() {
let doc =
"---\ntype: confluence\ninstance: https://o.net\ntitle: T\nspace_key: ''\n---\n\nB\n";
let err = resolve_confluence_create(doc, None, None, None).unwrap_err();
assert!(err.to_string().contains("Space key is required"));
}
#[test]
fn confluence_missing_title_errors() {
let doc =
"---\ntype: confluence\ninstance: https://o.net\ntitle: ''\nspace_key: ENG\n---\n\nB\n";
let err = resolve_confluence_create(doc, None, None, None).unwrap_err();
assert!(err.to_string().contains("Title is required"));
}
#[test]
fn prepend_warnings_noop_when_empty() {
assert_eq!(prepend_warnings(&[], "body".to_string()), "body");
}
}