use crate::commands::linear::{
fetch_available_initiatives, fetch_graphql_type, linear_graphql_request, named_type_name,
};
use crate::config::LinearReleaseConfig;
use serde_json::{json, Map as JsonMap, Value as JsonValue};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedLinearReleaseConfig {
pub(crate) initiative_ids: Vec<String>,
pub(crate) organization_name: Option<String>,
pub(crate) health: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LinearReleaseInitiativeLink {
id: String,
name: String,
}
#[derive(Debug, Clone)]
pub(crate) struct LinearReleasePublishInput {
pub(crate) api_key: String,
pub(crate) initiative_ids: Vec<String>,
pub(crate) organization_name: Option<String>,
pub(crate) health: String,
pub(crate) release_title: String,
pub(crate) release_tag: String,
pub(crate) release_url: String,
pub(crate) release_notes: String,
}
#[derive(Debug, Clone)]
struct InitiativeUpdateMutationInfo {
mutation_name: String,
input_arg_name: String,
input_type_name: String,
initiative_field_name: String,
body_field_name: String,
health_field_name: Option<String>,
health_value: Option<String>,
}
pub(crate) fn resolve_linear_release_config(
global: Option<LinearReleaseConfig>,
project: Option<LinearReleaseConfig>,
) -> Option<ResolvedLinearReleaseConfig> {
let enabled = project
.as_ref()
.and_then(|cfg| cfg.enabled)
.or_else(|| global.as_ref().and_then(|cfg| cfg.enabled))
.unwrap_or(true);
if !enabled {
return None;
}
let initiative_ids = project
.as_ref()
.and_then(|cfg| cfg.initiative_ids.clone())
.or_else(|| global.as_ref().and_then(|cfg| cfg.initiative_ids.clone()))
.unwrap_or_default()
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
if initiative_ids.is_empty() {
return None;
}
let health = project
.as_ref()
.and_then(|cfg| cfg.health.clone())
.or_else(|| global.as_ref().and_then(|cfg| cfg.health.clone()))
.unwrap_or_else(|| "on_track".to_string());
let organization_name = project
.as_ref()
.and_then(|cfg| cfg.organization_name.clone())
.or_else(|| {
global
.as_ref()
.and_then(|cfg| cfg.organization_name.clone())
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Some(ResolvedLinearReleaseConfig {
initiative_ids: dedupe_preserve_order(&initiative_ids),
organization_name,
health: health.trim().to_string(),
})
}
pub(crate) async fn publish_release_to_linear_initiatives(
input: &LinearReleasePublishInput,
) -> Result<Vec<String>, String> {
let mutation_info = discover_initiative_update_mutation(&input.api_key, &input.health).await?;
let linked_initiatives = resolve_linear_release_initiative_links(input).await?;
let body = render_linear_release_update_body(input, &linked_initiatives);
let mut published = Vec::new();
for initiative_id in &input.initiative_ids {
let payload = build_initiative_update_payload(&mutation_info, initiative_id, &body)?;
execute_initiative_update_mutation(&input.api_key, &mutation_info, payload)
.await
.map_err(|err| {
format!(
"Failed to publish release update to Linear initiative `{}`: {}",
initiative_id, err
)
})?;
published.push(initiative_id.clone());
}
Ok(published)
}
async fn resolve_linear_release_initiative_links(
input: &LinearReleasePublishInput,
) -> Result<Vec<LinearReleaseInitiativeLink>, String> {
let Some(organization_name) = input
.organization_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(Vec::new());
};
let initiatives = fetch_available_initiatives(&input.api_key).await?;
let initiative_names = initiatives
.into_iter()
.map(|initiative| (initiative.id, initiative.name))
.collect::<std::collections::BTreeMap<_, _>>();
Ok(input
.initiative_ids
.iter()
.map(|initiative_id| LinearReleaseInitiativeLink {
id: initiative_id.clone(),
name: initiative_names
.get(initiative_id)
.cloned()
.unwrap_or_else(|| initiative_id.clone()),
})
.filter(|initiative| !organization_name.is_empty() && !initiative.name.trim().is_empty())
.collect())
}
fn render_linear_release_update_body(
input: &LinearReleasePublishInput,
linked_initiatives: &[LinearReleaseInitiativeLink],
) -> String {
let mut body = format!(
"# {}\n\nRelease tag: `{}`\nGitHub release: {}",
input.release_title.trim(),
input.release_tag.trim(),
input.release_url.trim(),
);
if let Some(organization_name) = input
.organization_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let links = linked_initiatives
.iter()
.map(|initiative| {
format!(
"[{}](https://linear.app/{}/initiative/{})",
initiative.name.trim(),
organization_name,
initiative.id.trim()
)
})
.collect::<Vec<_>>();
if !links.is_empty() {
body.push_str(&format!("\nLinear initiative(s): {}", links.join(", ")));
}
}
body.push_str(&format!("\n\n{}", input.release_notes.trim()));
body
}
async fn discover_initiative_update_mutation(
api_key: &str,
configured_health: &str,
) -> Result<InitiativeUpdateMutationInfo, String> {
let mutation_type = fetch_graphql_type(api_key, "Mutation").await?;
let fields = mutation_type
.fields
.ok_or_else(|| "Linear schema did not expose mutation fields.".to_string())?;
let field = fields
.iter()
.find(|field| {
let normalized = field.name.to_ascii_lowercase();
normalized.contains("initiative")
&& normalized.contains("update")
&& normalized.contains("create")
})
.or_else(|| {
fields.iter().find(|field| {
let normalized = field.name.to_ascii_lowercase();
normalized.contains("initiative") && normalized.contains("update")
})
})
.cloned();
let Some(field) = field else {
return Err("Linear schema did not expose an initiative update mutation.".to_string());
};
let Some(input_arg) = field.args.iter().find(|arg| arg.name == "input") else {
return Err(format!(
"Linear mutation `{}` does not accept an `input` argument.",
field.name
));
};
let input_type_name = named_type_name(&input_arg.type_ref).ok_or_else(|| {
format!(
"Linear mutation `{}` has an unsupported input argument type.",
field.name
)
})?;
let input_type = fetch_graphql_type(api_key, &input_type_name).await?;
let input_fields = input_type
.input_fields
.ok_or_else(|| format!("Linear input type `{}` had no fields.", input_type_name))?;
let initiative_field_name = input_fields
.iter()
.find(|field| {
let normalized = field.name.to_ascii_lowercase();
normalized.contains("initiative") && normalized.ends_with("id")
})
.map(|field| field.name.clone())
.ok_or_else(|| {
format!(
"Linear input type `{}` has no initiative id field.",
input_type_name
)
})?;
let body_field_name = input_fields
.iter()
.find(|field| {
matches!(
field.name.as_str(),
"body" | "bodyData" | "description" | "descriptionData" | "content"
)
})
.map(|field| field.name.clone())
.ok_or_else(|| {
format!(
"Linear input type `{}` has no supported body field.",
input_type_name
)
})?;
let health_field = input_fields.iter().find(|field| field.name == "health");
let (health_field_name, health_value) = if let Some(field) = health_field {
let enum_type_name = named_type_name(&field.type_ref).ok_or_else(|| {
format!(
"Linear input type `{}` has an unsupported `health` field type.",
input_type_name
)
})?;
let health_type = fetch_graphql_type(api_key, &enum_type_name).await?;
let enum_values = health_type
.enum_values
.ok_or_else(|| format!("Linear health enum `{}` exposed no values.", enum_type_name))?
.into_iter()
.map(|value| value.name)
.collect::<Vec<_>>();
let resolved = resolve_linear_health_value(configured_health, &enum_values).ok_or_else(|| {
format!(
"Configured Linear release health `{}` did not match any supported Linear values: {}",
configured_health,
enum_values.join(", ")
)
})?;
(Some(field.name.clone()), Some(resolved))
} else {
(None, None)
};
Ok(InitiativeUpdateMutationInfo {
mutation_name: field.name,
input_arg_name: input_arg.name.clone(),
input_type_name,
initiative_field_name,
body_field_name,
health_field_name,
health_value,
})
}
async fn execute_initiative_update_mutation(
api_key: &str,
info: &InitiativeUpdateMutationInfo,
payload: JsonValue,
) -> Result<(), String> {
let query = format!(
"mutation XbpCreateInitiativeUpdate(${arg_name}: {input_type}!) {{ {mutation_name}({arg_name}: ${arg_name}) {{ __typename }} }}",
arg_name = info.input_arg_name,
input_type = info.input_type_name,
mutation_name = info.mutation_name,
);
let mut variables = JsonMap::new();
variables.insert(info.input_arg_name.clone(), payload);
let response = linear_graphql_request(
api_key,
&json!({
"query": query,
"variables": JsonValue::Object(variables)
}),
)
.await?;
if let Some(errors) = response.get("errors").and_then(JsonValue::as_array) {
let message = errors
.first()
.and_then(|error| error.get("message"))
.and_then(JsonValue::as_str)
.unwrap_or("unknown Linear API error");
return Err(message.to_string());
}
Ok(())
}
fn build_initiative_update_payload(
info: &InitiativeUpdateMutationInfo,
initiative_id: &str,
body: &str,
) -> Result<JsonValue, String> {
let mut input = JsonMap::new();
input.insert(
info.initiative_field_name.clone(),
JsonValue::String(initiative_id.to_string()),
);
match info.body_field_name.as_str() {
"bodyData" | "descriptionData" => {
input.insert(
info.body_field_name.clone(),
prose_mirror_doc_from_text(body),
);
}
_ => {
input.insert(
info.body_field_name.clone(),
JsonValue::String(body.to_string()),
);
}
}
if let (Some(field_name), Some(health_value)) = (&info.health_field_name, &info.health_value) {
input.insert(field_name.clone(), JsonValue::String(health_value.clone()));
}
serde_json::to_value(input)
.map_err(|e| format!("Failed to encode Linear mutation input: {}", e))
}
fn prose_mirror_doc_from_text(text: &str) -> JsonValue {
let paragraphs = text
.split("\n\n")
.map(|block| {
let content = block
.lines()
.map(|line| {
json!({
"type": "text",
"text": line
})
})
.collect::<Vec<_>>();
json!({
"type": "paragraph",
"content": content
})
})
.collect::<Vec<_>>();
json!({
"type": "doc",
"content": paragraphs
})
}
fn resolve_linear_health_value(configured: &str, candidates: &[String]) -> Option<String> {
let normalized_target = normalize_linear_value(configured);
candidates
.iter()
.find(|candidate| normalize_linear_value(candidate) == normalized_target)
.cloned()
}
fn normalize_linear_value(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.flat_map(|ch| ch.to_lowercase())
.collect()
}
fn dedupe_preserve_order(values: &[String]) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut deduped = Vec::new();
for value in values {
if seen.insert(value.clone()) {
deduped.push(value.clone());
}
}
deduped
}
#[cfg(test)]
mod tests {
use super::{
render_linear_release_update_body, resolve_linear_health_value,
resolve_linear_release_config, LinearReleaseConfig, LinearReleaseInitiativeLink,
LinearReleasePublishInput,
};
#[test]
fn repo_linear_release_config_overrides_global_values() {
let global = LinearReleaseConfig {
enabled: Some(true),
initiative_ids: Some(vec!["global-1".to_string(), "global-2".to_string()]),
organization_name: Some("global-org".to_string()),
health: Some("at_risk".to_string()),
};
let project = LinearReleaseConfig {
enabled: Some(true),
initiative_ids: Some(vec!["repo-1".to_string(), "repo-1".to_string()]),
organization_name: Some("repo-org".to_string()),
health: Some("off_track".to_string()),
};
let resolved =
resolve_linear_release_config(Some(global), Some(project)).expect("resolved config");
assert_eq!(resolved.initiative_ids, vec!["repo-1".to_string()]);
assert_eq!(resolved.organization_name.as_deref(), Some("repo-org"));
assert_eq!(resolved.health, "off_track");
}
#[test]
fn repo_linear_release_config_can_disable_global_publish() {
let global = LinearReleaseConfig {
enabled: Some(true),
initiative_ids: Some(vec!["global-1".to_string()]),
organization_name: None,
health: Some("on_track".to_string()),
};
let project = LinearReleaseConfig {
enabled: Some(false),
initiative_ids: None,
organization_name: None,
health: None,
};
assert!(resolve_linear_release_config(Some(global), Some(project)).is_none());
}
#[test]
fn resolves_linear_health_across_common_enum_shapes() {
let candidates = vec![
"onTrack".to_string(),
"atRisk".to_string(),
"offTrack".to_string(),
];
assert_eq!(
resolve_linear_health_value("on_track", &candidates),
Some("onTrack".to_string())
);
assert_eq!(
resolve_linear_health_value("AT_RISK", &candidates),
Some("atRisk".to_string())
);
assert_eq!(
resolve_linear_health_value("off-track", &candidates),
Some("offTrack".to_string())
);
}
#[test]
fn renders_linear_release_body_with_initiative_markdown_links_only_for_linear() {
let body = render_linear_release_update_body(
&LinearReleasePublishInput {
api_key: "linear-key".to_string(),
initiative_ids: vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()],
organization_name: Some("suits-formations".to_string()),
health: "on_track".to_string(),
release_title: "10.27.0 - xbp".to_string(),
release_tag: "v10.27.0".to_string(),
release_url: "https://github.com/xylex-group/xbp/releases/tag/v10.27.0".to_string(),
release_notes: "Ship it.".to_string(),
},
&[LinearReleaseInitiativeLink {
id: "fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string(),
name: "Formations Release".to_string(),
}],
);
assert!(body.contains(
"Linear initiative(s): [Formations Release](https://linear.app/suits-formations/initiative/fd28f67f-8dc8-44b2-bf14-3821ce389145)"
));
assert!(body
.contains("GitHub release: https://github.com/xylex-group/xbp/releases/tag/v10.27.0"));
assert!(!body.contains("github initiative"));
}
}