ticket: parse item frontmatter as YAML

This commit is contained in:
2026-06-08 08:13:19 +09:00
parent de3a5569da
commit 10d7844fc8
53 changed files with 450 additions and 151 deletions
+359 -102
View File
@@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf};
use chrono::Utc;
use fs4::fs_std::FileExt;
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use thiserror::Error;
pub mod config;
@@ -789,7 +790,7 @@ impl LocalTicketBackend {
let meta = ticket_meta(parsed.frontmatter.clone());
let document = TicketDocument {
body: MarkdownText::new(parsed.body),
raw_frontmatter: parsed.frontmatter,
raw_frontmatter: parsed.frontmatter.raw,
};
let thread_path = dir.join("thread.md");
let events = if thread_path.exists() {
@@ -1021,33 +1022,52 @@ impl TicketBackend for LocalTicketBackend {
fs::create_dir_all(dir.join("artifacts")).map_err(|e| io_err(&dir, e))?;
atomic_write(&dir.join("artifacts/.gitkeep"), b"")?;
let mut fields = Vec::new();
fields.push(("id".to_string(), id.clone()));
fields.push(("slug".to_string(), slug.clone()));
fields.push(("title".to_string(), input.title));
fields.push(("status".to_string(), "open".to_string()));
fields.push(("kind".to_string(), input.kind));
fields.push(("priority".to_string(), input.priority));
fields.push(("id".to_string(), format_yaml_string_scalar(&id)));
fields.push(("slug".to_string(), format_yaml_string_scalar(&slug)));
fields.push((
"title".to_string(),
format_yaml_string_scalar(input.title.as_str()),
));
fields.push(("status".to_string(), format_yaml_string_scalar("open")));
fields.push((
"kind".to_string(),
format_yaml_string_scalar(input.kind.as_str()),
));
fields.push((
"priority".to_string(),
format_yaml_string_scalar(input.priority.as_str()),
));
fields.push(("labels".to_string(), labels_yaml(&input.labels)));
fields.push((
"workflow_state".to_string(),
input
.workflow_state
.unwrap_or(TicketWorkflowState::Intake)
.as_str()
.to_string(),
format_yaml_string_scalar(
input
.workflow_state
.unwrap_or(TicketWorkflowState::Intake)
.as_str(),
),
));
fields.push((
"created_at".to_string(),
format_yaml_string_scalar(&created),
));
fields.push((
"updated_at".to_string(),
format_yaml_string_scalar(&created),
));
fields.push(("created_at".to_string(), created.clone()));
fields.push(("updated_at".to_string(), created.clone()));
fields.push((
"assignee".to_string(),
input.assignee.unwrap_or_else(|| "null".to_string()),
yaml_string_or_null(input.assignee.as_deref()),
));
fields.push((
"legacy_ticket".to_string(),
input.legacy_ticket.unwrap_or_else(|| "null".to_string()),
yaml_string_or_null(input.legacy_ticket.as_deref()),
));
if let Some(readiness) = input.readiness {
fields.push(("readiness".to_string(), readiness));
fields.push((
"readiness".to_string(),
format_yaml_string_scalar(readiness.as_str()),
));
}
if let Some(needs_preflight) = input.needs_preflight {
fields.push(("needs_preflight".to_string(), needs_preflight.to_string()));
@@ -1056,16 +1076,28 @@ impl TicketBackend for LocalTicketBackend {
fields.push(("risk_flags".to_string(), labels_yaml(&input.risk_flags)));
}
if let Some(action_required) = input.action_required {
fields.push(("action_required".to_string(), action_required));
fields.push((
"action_required".to_string(),
format_yaml_string_scalar(action_required.as_str()),
));
}
if let Some(attention_required) = input.attention_required {
fields.push(("attention_required".to_string(), attention_required));
fields.push((
"attention_required".to_string(),
format_yaml_string_scalar(attention_required.as_str()),
));
}
if let Some(queued_by) = input.queued_by {
fields.push(("queued_by".to_string(), queued_by));
fields.push((
"queued_by".to_string(),
format_yaml_string_scalar(queued_by.as_str()),
));
}
if let Some(queued_at) = input.queued_at {
fields.push(("queued_at".to_string(), queued_at));
fields.push((
"queued_at".to_string(),
format_yaml_string_scalar(queued_at.as_str()),
));
}
let item = serialize_item(&fields, input.body.as_str());
atomic_write(&dir.join("item.md"), item.as_bytes())?;
@@ -1520,10 +1552,41 @@ impl Drop for BackendLock {
#[derive(Debug, Clone)]
struct ParsedItem {
frontmatter: BTreeMap<String, String>,
frontmatter: TicketItemFrontmatter,
body: String,
}
#[derive(Debug, Clone, Default)]
struct TicketItemFrontmatter {
id: Option<String>,
slug: Option<String>,
title: Option<String>,
status: Option<String>,
kind: Option<String>,
priority: Option<String>,
labels: Vec<String>,
created_at: Option<String>,
updated_at: Option<String>,
assignee: Option<String>,
legacy_ticket: Option<String>,
readiness: Option<String>,
needs_preflight: Option<bool>,
risk_flags: Vec<String>,
action_required: Option<String>,
workflow_state: Option<TicketWorkflowState>,
workflow_state_explicit: bool,
attention_required: Option<String>,
queued_by: Option<String>,
queued_at: Option<String>,
raw: BTreeMap<String, String>,
}
impl TicketItemFrontmatter {
fn get(&self, key: &str) -> Option<&String> {
self.raw.get(key)
}
}
fn read_item_file(path: &Path) -> Result<ParsedItem> {
let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
parse_item(&content).map_err(|message| TicketError::Parse {
@@ -1540,17 +1603,15 @@ fn parse_item(content: &str) -> std::result::Result<ParsedItem, String> {
if first != "---" {
return Err("item.md missing frontmatter opener".to_string());
}
let mut frontmatter = BTreeMap::new();
let mut found_close = false;
let mut frontmatter_lines = Vec::new();
let mut body = String::new();
for line in &mut lines {
if line == "---" {
found_close = true;
break;
}
if let Some((key, value)) = line.split_once(':') {
frontmatter.insert(key.trim().to_string(), value.trim_start().to_string());
}
frontmatter_lines.push(line);
}
if !found_close {
return Err("item.md missing frontmatter closer".to_string());
@@ -1562,89 +1623,225 @@ fn parse_item(content: &str) -> std::result::Result<ParsedItem, String> {
body.push('\n');
}
}
let frontmatter = parse_ticket_frontmatter(&frontmatter_lines.join("\n"))?;
Ok(ParsedItem { frontmatter, body })
}
fn ticket_meta(frontmatter: BTreeMap<String, String>) -> TicketMeta {
let id = frontmatter.get("id").cloned().unwrap_or_default();
let slug = frontmatter.get("slug").cloned().unwrap_or_default();
let title = frontmatter.get("title").cloned().unwrap_or_default();
let status = frontmatter
.get("status")
.map(|value| ExtensibleTicketStatus::from(value.as_str()))
.unwrap_or_else(|| ExtensibleTicketStatus::Other(String::new()));
let kind = frontmatter.get("kind").cloned().unwrap_or_default();
let priority = frontmatter.get("priority").cloned().unwrap_or_default();
let labels = frontmatter
.get("labels")
.map(|value| parse_yaml_list(value))
.unwrap_or_default();
let risk_flags = frontmatter
.get("risk_flags")
.or_else(|| frontmatter.get("risks"))
.map(|value| parse_yaml_list(value))
.unwrap_or_default();
let workflow_state_explicit = frontmatter.contains_key("workflow_state");
let workflow_state = frontmatter
.get("workflow_state")
.and_then(|value| TicketWorkflowState::parse(value))
.unwrap_or_else(|| TicketWorkflowState::default_for_status(&status));
TicketMeta {
id,
slug,
title,
status,
kind,
priority,
labels,
created_at: frontmatter.get("created_at").cloned(),
updated_at: frontmatter.get("updated_at").cloned(),
assignee: frontmatter.get("assignee").cloned().filter(|v| v != "null"),
legacy_ticket: frontmatter
.get("legacy_ticket")
.cloned()
.filter(|v| v != "null"),
readiness: frontmatter.get("readiness").cloned(),
needs_preflight: frontmatter
.get("needs_preflight")
.or_else(|| frontmatter.get("needs-preflight"))
.and_then(|value| parse_bool(value)),
risk_flags,
action_required: frontmatter.get("action_required").cloned(),
fn parse_ticket_frontmatter(content: &str) -> std::result::Result<TicketItemFrontmatter, String> {
let value: YamlValue =
serde_yaml::from_str(content).map_err(|err| format!("invalid YAML frontmatter: {err}"))?;
let mapping = match value {
YamlValue::Mapping(mapping) => mapping,
YamlValue::Null => YamlMapping::new(),
other => {
return Err(format!(
"frontmatter must be a YAML mapping, found {}",
yaml_kind(&other)
));
}
};
let mut raw = BTreeMap::new();
for (key, value) in &mapping {
let YamlValue::String(key) = key else {
return Err("frontmatter keys must be strings".to_string());
};
raw.insert(key.clone(), raw_frontmatter_value(value)?);
}
let workflow_state_explicit = mapping.contains_key(YamlValue::String("workflow_state".into()));
let workflow_state_value = yaml_string(&mapping, "workflow_state")?;
let workflow_state = match workflow_state_value.as_deref() {
Some(value) => Some(TicketWorkflowState::parse(value).ok_or_else(|| {
format!("invalid workflow_state '{value}': expected intake, ready, queued, inprogress, or done")
})?),
None => None,
};
Ok(TicketItemFrontmatter {
id: yaml_string(&mapping, "id")?,
slug: yaml_string(&mapping, "slug")?,
title: yaml_string(&mapping, "title")?,
status: yaml_string(&mapping, "status")?,
kind: yaml_string(&mapping, "kind")?,
priority: yaml_string(&mapping, "priority")?,
labels: yaml_string_list(&mapping, "labels")?,
created_at: yaml_string(&mapping, "created_at")?,
updated_at: yaml_string(&mapping, "updated_at")?,
assignee: yaml_string(&mapping, "assignee")?,
legacy_ticket: yaml_string(&mapping, "legacy_ticket")?,
readiness: yaml_string(&mapping, "readiness")?,
needs_preflight: yaml_bool(&mapping, "needs_preflight")?,
risk_flags: yaml_string_list(&mapping, "risk_flags")?,
action_required: yaml_string(&mapping, "action_required")?,
workflow_state,
workflow_state_explicit,
attention_required: frontmatter.get("attention_required").cloned(),
queued_by: frontmatter.get("queued_by").cloned(),
queued_at: frontmatter.get("queued_at").cloned(),
raw: frontmatter,
attention_required: yaml_string(&mapping, "attention_required")?,
queued_by: yaml_string(&mapping, "queued_by")?,
queued_at: yaml_string(&mapping, "queued_at")?,
raw,
})
}
fn yaml_key(key: &str) -> YamlValue {
YamlValue::String(key.to_string())
}
fn yaml_get<'a>(mapping: &'a YamlMapping, key: &str) -> Option<&'a YamlValue> {
mapping.get(yaml_key(key))
}
fn yaml_string(mapping: &YamlMapping, key: &str) -> std::result::Result<Option<String>, String> {
match yaml_get(mapping, key) {
Some(YamlValue::Null) | None => Ok(None),
Some(YamlValue::String(value)) => Ok(Some(value.clone())),
Some(value) => Err(format!(
"frontmatter field `{key}` must be a YAML string or null, found {}",
yaml_kind(value)
)),
}
}
fn parse_bool(value: &str) -> Option<bool> {
match value.trim() {
"true" | "yes" | "1" => Some(true),
"false" | "no" | "0" => Some(false),
_ => None,
fn yaml_bool(mapping: &YamlMapping, key: &str) -> std::result::Result<Option<bool>, String> {
match yaml_get(mapping, key) {
Some(YamlValue::Null) | None => Ok(None),
Some(YamlValue::Bool(value)) => Ok(Some(*value)),
Some(value) => Err(format!(
"frontmatter field `{key}` must be a YAML boolean or null, found {}",
yaml_kind(value)
)),
}
}
fn parse_yaml_list(value: &str) -> Vec<String> {
let trimmed = value.trim();
if let Some(inner) = trimmed.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
return inner
.split(',')
.map(|part| part.trim().trim_matches('"').trim_matches('\''))
.filter(|part| !part.is_empty())
.map(ToOwned::to_owned)
.collect();
fn yaml_string_list(mapping: &YamlMapping, key: &str) -> std::result::Result<Vec<String>, String> {
match yaml_get(mapping, key) {
Some(YamlValue::Null) | None => Ok(Vec::new()),
Some(YamlValue::Sequence(values)) => values
.iter()
.enumerate()
.map(|(idx, value)| match value {
YamlValue::String(value) => Ok(value.clone()),
other => Err(format!(
"frontmatter field `{key}` item {idx} must be a YAML string, found {}",
yaml_kind(other)
)),
})
.collect(),
Some(value) => Err(format!(
"frontmatter field `{key}` must be a YAML sequence or null, found {}",
yaml_kind(value)
)),
}
if trimmed.is_empty() || trimmed == "null" {
Vec::new()
} else {
vec![trimmed.to_string()]
}
fn raw_frontmatter_value(value: &YamlValue) -> std::result::Result<String, String> {
match value {
YamlValue::Null => Ok("null".to_string()),
YamlValue::Bool(value) => Ok(value.to_string()),
YamlValue::Number(value) => Ok(value.to_string()),
YamlValue::String(value) => Ok(value.clone()),
YamlValue::Sequence(values) => values
.iter()
.map(|value| match value {
YamlValue::String(value) => Ok(format_yaml_string_scalar(value)),
other => Err(format!(
"frontmatter sequence values must be strings, found {}",
yaml_kind(other)
)),
})
.collect::<std::result::Result<Vec<_>, _>>()
.map(|values| format!("[{}]", values.join(", "))),
YamlValue::Mapping(_) => Err("frontmatter nested mappings are not supported".to_string()),
YamlValue::Tagged(tagged) => raw_frontmatter_value(&tagged.value),
}
}
fn yaml_kind(value: &YamlValue) -> &'static str {
match value {
YamlValue::Null => "null",
YamlValue::Bool(_) => "boolean",
YamlValue::Number(_) => "number",
YamlValue::String(_) => "string",
YamlValue::Sequence(_) => "sequence",
YamlValue::Mapping(_) => "mapping",
YamlValue::Tagged(_) => "tagged value",
}
}
fn ticket_meta(frontmatter: TicketItemFrontmatter) -> TicketMeta {
let status = frontmatter
.status
.as_deref()
.map(ExtensibleTicketStatus::from)
.unwrap_or_else(|| ExtensibleTicketStatus::Other(String::new()));
let workflow_state = frontmatter
.workflow_state
.unwrap_or_else(|| TicketWorkflowState::default_for_status(&status));
TicketMeta {
id: frontmatter.id.unwrap_or_default(),
slug: frontmatter.slug.unwrap_or_default(),
title: frontmatter.title.unwrap_or_default(),
status,
kind: frontmatter.kind.unwrap_or_default(),
priority: frontmatter.priority.unwrap_or_default(),
labels: frontmatter.labels,
created_at: frontmatter.created_at,
updated_at: frontmatter.updated_at,
assignee: frontmatter.assignee,
legacy_ticket: frontmatter.legacy_ticket,
readiness: frontmatter.readiness,
needs_preflight: frontmatter.needs_preflight,
risk_flags: frontmatter.risk_flags,
action_required: frontmatter.action_required,
workflow_state,
workflow_state_explicit: frontmatter.workflow_state_explicit,
attention_required: frontmatter.attention_required,
queued_by: frontmatter.queued_by,
queued_at: frontmatter.queued_at,
raw: frontmatter.raw,
}
}
fn format_yaml_string_scalar(value: &str) -> String {
let reserved = matches!(
value,
"" | "null"
| "Null"
| "NULL"
| "~"
| "true"
| "True"
| "TRUE"
| "false"
| "False"
| "FALSE"
);
let plain_safe = !reserved
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.'));
if plain_safe {
return value.to_string();
}
let mut out = String::from("'");
for ch in value.chars() {
if ch == '\'' {
out.push_str("''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
fn yaml_string_or_null(value: Option<&str>) -> String {
value
.map(format_yaml_string_scalar)
.unwrap_or_else(|| "null".to_string())
}
fn labels_yaml(labels: &[String]) -> String {
if labels.is_empty() {
return "[]".to_string();
@@ -1655,6 +1852,7 @@ fn labels_yaml(labels: &[String]) -> String {
.iter()
.map(|label| label.trim())
.filter(|label| !label.is_empty())
.map(format_yaml_string_scalar)
.collect::<Vec<_>>()
.join(", ")
)
@@ -1697,7 +1895,7 @@ fn replace_frontmatter_fields(
if let Some((key, _)) = line.split_once(':') {
let key = key.trim().to_string();
if let Some((_, value)) = updates.iter().find(|(update_key, _)| *update_key == key) {
*line = format!("{key}: {value}");
*line = format!("{key}: {}", format_yaml_string_scalar(value));
seen.insert(key);
}
}
@@ -1705,7 +1903,10 @@ fn replace_frontmatter_fields(
let mut insert_at = end;
for (key, value) in updates {
if !seen.contains(*key) {
lines.insert(insert_at, format!("{key}: {value}"));
lines.insert(
insert_at,
format!("{key}: {}", format_yaml_string_scalar(value)),
);
insert_at += 1;
}
}
@@ -2237,6 +2438,63 @@ queued_at: 2026-06-05T00:01:00Z
assert_eq!(meta.queued_at.as_deref(), Some("2026-06-05T00:01:00Z"));
}
#[test]
fn yaml_frontmatter_preserves_typed_nulls_lists_bools_and_quoted_strings() {
let frontmatter = parse_ticket_frontmatter(
r#"labels:
- ticket
- backend
risk_flags: [low, local]
assignee: ~
legacy_ticket:
attention_required: null
action_required: "null"
readiness: "~"
needs_preflight: false
workflow_state: intake
"#,
)
.unwrap();
let meta = ticket_meta(frontmatter);
assert_eq!(meta.labels, vec!["ticket", "backend"]);
assert_eq!(meta.risk_flags, vec!["low", "local"]);
assert_eq!(meta.assignee, None);
assert_eq!(meta.legacy_ticket, None);
assert_eq!(meta.attention_required, None);
assert_eq!(meta.action_required.as_deref(), Some("null"));
assert_eq!(meta.readiness.as_deref(), Some("~"));
assert_eq!(meta.needs_preflight, Some(false));
assert_eq!(meta.workflow_state, TicketWorkflowState::Intake);
assert!(meta.workflow_state_explicit);
}
#[test]
fn yaml_frontmatter_rejects_legacy_raw_string_fallbacks() {
let labels_error = parse_ticket_frontmatter("labels: ticket").unwrap_err();
assert!(
labels_error.contains("must be a YAML sequence"),
"{labels_error}"
);
let bool_error = parse_ticket_frontmatter("needs_preflight: 1").unwrap_err();
assert!(
bool_error.contains("must be a YAML boolean"),
"{bool_error}"
);
let workflow_error = parse_ticket_frontmatter("workflow_state: almost").unwrap_err();
assert!(
workflow_error.contains("invalid workflow_state"),
"{workflow_error}"
);
}
#[test]
fn yaml_frontmatter_rejects_invalid_yaml() {
let err = parse_ticket_frontmatter("labels: [ticket").unwrap_err();
assert!(err.contains("invalid YAML frontmatter"), "{err}");
}
#[test]
fn create_writes_local_ticket_layout() {
let tmp = TempDir::new().unwrap();
@@ -2468,15 +2726,14 @@ queued_at: 2026-06-05T00:01:00Z
fn workflow_state_defaults_and_queue_transition_round_trip() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut missing_frontmatter = BTreeMap::new();
missing_frontmatter.insert("status".to_string(), "open".to_string());
let missing_meta = ticket_meta(missing_frontmatter);
let missing_meta = ticket_meta(
parse_ticket_frontmatter("status: open").expect("missing workflow state parses"),
);
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Intake);
assert!(!missing_meta.workflow_state_explicit);
let mut closed_frontmatter = BTreeMap::new();
closed_frontmatter.insert("status".to_string(), "closed".to_string());
let closed_meta = ticket_meta(closed_frontmatter);
let closed_meta =
ticket_meta(parse_ticket_frontmatter("status: closed").expect("closed default parses"));
assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Done);
assert!(!closed_meta.workflow_state_explicit);