ticket: add orchestration plan tools
This commit is contained in:
@@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use chrono::Utc;
|
||||
use fs4::fs_std::FileExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -37,6 +38,9 @@ const REQUIRED_FIELDS: [&str; 10] = [
|
||||
];
|
||||
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
|
||||
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
|
||||
const ORCHESTRATION_PLAN_ARTIFACT: &str = "orchestration-plan.jsonl";
|
||||
const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024;
|
||||
const MAX_ORCHESTRATION_PLAN_FIELD_BYTES: usize = 1024;
|
||||
const DEFAULT_TICKET_BODY: &str =
|
||||
"## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n";
|
||||
const JAPANESE_TICKET_BODY: &str =
|
||||
@@ -550,6 +554,104 @@ pub struct TicketRef {
|
||||
pub status: TicketStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OrchestrationPlanKind {
|
||||
Before,
|
||||
After,
|
||||
BlockedBy,
|
||||
Blocks,
|
||||
ConflictsWith,
|
||||
DoNotParallelize,
|
||||
WaitingCapacityNote,
|
||||
AcceptedPlan,
|
||||
}
|
||||
|
||||
impl OrchestrationPlanKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Before => "before",
|
||||
Self::After => "after",
|
||||
Self::BlockedBy => "blocked_by",
|
||||
Self::Blocks => "blocks",
|
||||
Self::ConflictsWith => "conflicts_with",
|
||||
Self::DoNotParallelize => "do_not_parallelize",
|
||||
Self::WaitingCapacityNote => "waiting_capacity_note",
|
||||
Self::AcceptedPlan => "accepted_plan",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"before" => Some(Self::Before),
|
||||
"after" => Some(Self::After),
|
||||
"blocked_by" => Some(Self::BlockedBy),
|
||||
"blocks" => Some(Self::Blocks),
|
||||
"conflicts_with" => Some(Self::ConflictsWith),
|
||||
"do_not_parallelize" => Some(Self::DoNotParallelize),
|
||||
"waiting_capacity_note" => Some(Self::WaitingCapacityNote),
|
||||
"accepted_plan" => Some(Self::AcceptedPlan),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_related_ticket(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Before
|
||||
| Self::After
|
||||
| Self::BlockedBy
|
||||
| Self::Blocks
|
||||
| Self::ConflictsWith
|
||||
| Self::DoNotParallelize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OrchestrationPlanKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AcceptedOrchestrationPlan {
|
||||
pub summary: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub branch: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worktree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role_plan: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewOrchestrationPlanRecord {
|
||||
pub kind: OrchestrationPlanKind,
|
||||
pub related_ticket: Option<String>,
|
||||
pub note: Option<String>,
|
||||
pub accepted_plan: Option<AcceptedOrchestrationPlan>,
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OrchestrationPlanRecord {
|
||||
pub id: String,
|
||||
pub ticket_id: String,
|
||||
pub ticket_slug: String,
|
||||
pub kind: OrchestrationPlanKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub related_ticket: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub accepted_plan: Option<AcceptedOrchestrationPlan>,
|
||||
pub author: String,
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketMeta {
|
||||
pub id: String,
|
||||
@@ -700,6 +802,16 @@ pub trait TicketBackend {
|
||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
|
||||
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
||||
fn add_orchestration_plan_record(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
record: NewOrchestrationPlanRecord,
|
||||
) -> Result<OrchestrationPlanRecord>;
|
||||
fn query_orchestration_plan_records(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
kind: Option<OrchestrationPlanKind>,
|
||||
) -> Result<Vec<OrchestrationPlanRecord>>;
|
||||
fn doctor(&self) -> Result<TicketDoctorReport>;
|
||||
}
|
||||
|
||||
@@ -1021,6 +1133,20 @@ impl LocalTicketBackend {
|
||||
})?;
|
||||
atomic_write(item, updated.as_bytes())
|
||||
}
|
||||
|
||||
fn orchestration_plan_path(&self, dir: &Path) -> PathBuf {
|
||||
dir.join("artifacts").join(ORCHESTRATION_PLAN_ARTIFACT)
|
||||
}
|
||||
|
||||
fn read_orchestration_plan_records_for_dir(
|
||||
&self,
|
||||
dir: &Path,
|
||||
) -> Result<Vec<OrchestrationPlanRecord>> {
|
||||
let item = dir.join("item.md");
|
||||
let meta = ticket_meta(read_item_file(&item)?.frontmatter);
|
||||
let path = self.orchestration_plan_path(dir);
|
||||
read_orchestration_plan_artifact(&path, Some(&meta))
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketBackend for LocalTicketBackend {
|
||||
@@ -1446,6 +1572,90 @@ impl TicketBackend for LocalTicketBackend {
|
||||
)
|
||||
}
|
||||
|
||||
fn add_orchestration_plan_record(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
record: NewOrchestrationPlanRecord,
|
||||
) -> Result<OrchestrationPlanRecord> {
|
||||
validate_new_orchestration_plan_record(&record)?;
|
||||
let _lock = self.acquire_lock()?;
|
||||
self.ensure_backend_dirs()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let item = dir.join("item.md");
|
||||
let meta = ticket_meta(read_item_file(&item)?.frontmatter);
|
||||
let artifacts = dir.join("artifacts");
|
||||
fs::create_dir_all(&artifacts).map_err(|e| io_err(&artifacts, e))?;
|
||||
let path = self.orchestration_plan_path(&dir);
|
||||
ensure_child_of(&artifacts, &path)?;
|
||||
let line_count = if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.map_err(|e| io_err(&path, e))?
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let at = now_utc();
|
||||
let output = OrchestrationPlanRecord {
|
||||
id: format!("orch-plan-{}-{}", compact_now_utc(), line_count + 1),
|
||||
ticket_id: meta.id.clone(),
|
||||
ticket_slug: meta.slug.clone(),
|
||||
kind: record.kind,
|
||||
related_ticket: record.related_ticket.map(trim_owned),
|
||||
note: record.note.map(trim_owned),
|
||||
accepted_plan: record.accepted_plan.map(trim_accepted_orchestration_plan),
|
||||
author: record.author.map(trim_owned).unwrap_or_else(default_author),
|
||||
at: at.clone(),
|
||||
};
|
||||
validate_orchestration_plan_record(&output, Some(&meta))?;
|
||||
let serialized = serde_json::to_string(&output).map_err(|e| {
|
||||
TicketError::Conflict(format!(
|
||||
"failed to serialize orchestration plan record: {e}"
|
||||
))
|
||||
})?;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
writeln!(file, "{serialized}").map_err(|e| io_err(&path, e))?;
|
||||
file.sync_all().map_err(|e| io_err(&path, e))?;
|
||||
self.set_frontmatter_fields(&item, &[("updated_at", &at)])?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn query_orchestration_plan_records(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
kind: Option<OrchestrationPlanKind>,
|
||||
) -> Result<Vec<OrchestrationPlanRecord>> {
|
||||
let mut records = Vec::new();
|
||||
if let Some(ticket) = ticket {
|
||||
let dir = self.find_ticket_dir(&ticket)?;
|
||||
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
||||
} else {
|
||||
for status in STATUSES {
|
||||
let status_dir = self.status_dir(status);
|
||||
if !status_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for entry in fs::read_dir(&status_dir).map_err(|e| io_err(&status_dir, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&status_dir, e))?;
|
||||
let dir = entry.path();
|
||||
if dir.is_dir() {
|
||||
records.extend(self.read_orchestration_plan_records_for_dir(&dir)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(kind) = kind {
|
||||
records.retain(|record| record.kind == kind);
|
||||
}
|
||||
records.sort_by(|a, b| a.at.cmp(&b.at).then_with(|| a.id.cmp(&b.id)));
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn doctor(&self) -> Result<TicketDoctorReport> {
|
||||
let mut report = TicketDoctorReport::default();
|
||||
for status in STATUSES {
|
||||
@@ -1577,6 +1787,12 @@ impl TicketBackend for LocalTicketBackend {
|
||||
}
|
||||
if artifacts.exists() {
|
||||
doctor_artifacts(&artifacts, &mut report)?;
|
||||
let meta = ticket_meta(parsed.frontmatter.clone());
|
||||
doctor_orchestration_plan_artifact(
|
||||
&artifacts.join(ORCHESTRATION_PLAN_ARTIFACT),
|
||||
&meta,
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1866,6 +2082,224 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter) -> TicketMeta {
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_owned(value: String) -> String {
|
||||
value.trim().to_string()
|
||||
}
|
||||
|
||||
fn trim_accepted_orchestration_plan(plan: AcceptedOrchestrationPlan) -> AcceptedOrchestrationPlan {
|
||||
AcceptedOrchestrationPlan {
|
||||
summary: plan.summary.trim().to_string(),
|
||||
branch: plan.branch.map(trim_owned),
|
||||
worktree: plan.worktree.map(trim_owned),
|
||||
role_plan: plan.role_plan.map(trim_owned),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_plan_required_text(label: &str, value: &str, max_bytes: usize) -> Result<()> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"orchestration plan {label} must not be empty"
|
||||
)));
|
||||
}
|
||||
validate_plan_optional_text(label, Some(trimmed), max_bytes)
|
||||
}
|
||||
|
||||
fn validate_plan_optional_text(label: &str, value: Option<&str>, max_bytes: usize) -> Result<()> {
|
||||
if let Some(value) = value {
|
||||
if value.as_bytes().len() > max_bytes {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"orchestration plan {label} exceeds {max_bytes} bytes"
|
||||
)));
|
||||
}
|
||||
if value.contains('\0') {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"orchestration plan {label} must not contain NUL bytes"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_plan_optional_single_line(
|
||||
label: &str,
|
||||
value: Option<&str>,
|
||||
max_bytes: usize,
|
||||
) -> Result<()> {
|
||||
validate_plan_optional_text(label, value, max_bytes)?;
|
||||
if let Some(value) = value {
|
||||
if value.contains('\n') || value.contains('\r') {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"orchestration plan {label} must be a single line"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_accepted_orchestration_plan(plan: &AcceptedOrchestrationPlan) -> Result<()> {
|
||||
validate_plan_required_text(
|
||||
"accepted_plan.summary",
|
||||
&plan.summary,
|
||||
MAX_ORCHESTRATION_PLAN_TEXT_BYTES,
|
||||
)?;
|
||||
validate_plan_optional_single_line(
|
||||
"accepted_plan.branch",
|
||||
plan.branch.as_deref(),
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
validate_plan_optional_single_line(
|
||||
"accepted_plan.worktree",
|
||||
plan.worktree.as_deref(),
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
validate_plan_optional_text(
|
||||
"accepted_plan.role_plan",
|
||||
plan.role_plan.as_deref(),
|
||||
MAX_ORCHESTRATION_PLAN_TEXT_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_new_orchestration_plan_record(record: &NewOrchestrationPlanRecord) -> Result<()> {
|
||||
if record.kind.requires_related_ticket() {
|
||||
let related = record.related_ticket.as_deref().ok_or_else(|| {
|
||||
TicketError::Conflict(format!(
|
||||
"orchestration plan kind `{}` requires related_ticket",
|
||||
record.kind
|
||||
))
|
||||
})?;
|
||||
validate_plan_required_text(
|
||||
"related_ticket",
|
||||
related,
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
validate_plan_optional_single_line(
|
||||
"related_ticket",
|
||||
Some(related),
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
} else if let Some(related) = record.related_ticket.as_deref() {
|
||||
validate_plan_optional_single_line(
|
||||
"related_ticket",
|
||||
Some(related),
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
}
|
||||
|
||||
if matches!(record.kind, OrchestrationPlanKind::AcceptedPlan) {
|
||||
let plan = record.accepted_plan.as_ref().ok_or_else(|| {
|
||||
TicketError::Conflict("accepted_plan record requires accepted_plan fields".to_string())
|
||||
})?;
|
||||
validate_accepted_orchestration_plan(plan)?;
|
||||
} else if record.accepted_plan.is_some() {
|
||||
return Err(TicketError::Conflict(
|
||||
"accepted_plan fields are only valid for accepted_plan records".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if matches!(record.kind, OrchestrationPlanKind::WaitingCapacityNote) {
|
||||
let note = record.note.as_deref().ok_or_else(|| {
|
||||
TicketError::Conflict("waiting_capacity_note records require note".to_string())
|
||||
})?;
|
||||
validate_plan_required_text("note", note, MAX_ORCHESTRATION_PLAN_TEXT_BYTES)?;
|
||||
} else {
|
||||
validate_plan_optional_text(
|
||||
"note",
|
||||
record.note.as_deref(),
|
||||
MAX_ORCHESTRATION_PLAN_TEXT_BYTES,
|
||||
)?;
|
||||
}
|
||||
validate_plan_optional_single_line(
|
||||
"author",
|
||||
record.author.as_deref(),
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_orchestration_plan_record(
|
||||
record: &OrchestrationPlanRecord,
|
||||
meta: Option<&TicketMeta>,
|
||||
) -> Result<()> {
|
||||
validate_plan_required_text("id", &record.id, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
|
||||
validate_plan_optional_single_line("id", Some(&record.id), MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
|
||||
validate_plan_required_text(
|
||||
"ticket_id",
|
||||
&record.ticket_id,
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
validate_plan_optional_single_line(
|
||||
"ticket_id",
|
||||
Some(&record.ticket_id),
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
validate_plan_required_text(
|
||||
"ticket_slug",
|
||||
&record.ticket_slug,
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
validate_plan_optional_single_line(
|
||||
"ticket_slug",
|
||||
Some(&record.ticket_slug),
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
validate_plan_required_text("author", &record.author, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
|
||||
validate_plan_optional_single_line(
|
||||
"author",
|
||||
Some(&record.author),
|
||||
MAX_ORCHESTRATION_PLAN_FIELD_BYTES,
|
||||
)?;
|
||||
validate_plan_required_text("at", &record.at, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
|
||||
validate_plan_optional_single_line("at", Some(&record.at), MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?;
|
||||
let new_record = NewOrchestrationPlanRecord {
|
||||
kind: record.kind,
|
||||
related_ticket: record.related_ticket.clone(),
|
||||
note: record.note.clone(),
|
||||
accepted_plan: record.accepted_plan.clone(),
|
||||
author: Some(record.author.clone()),
|
||||
};
|
||||
validate_new_orchestration_plan_record(&new_record)?;
|
||||
if let Some(meta) = meta {
|
||||
if record.ticket_id != meta.id || record.ticket_slug != meta.slug {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"orchestration plan record {} targets {}/{} but artifact belongs to {}/{}",
|
||||
record.id, record.ticket_id, record.ticket_slug, meta.id, meta.slug
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_orchestration_plan_artifact(
|
||||
path: &Path,
|
||||
meta: Option<&TicketMeta>,
|
||||
) -> Result<Vec<OrchestrationPlanRecord>> {
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let mut records = Vec::new();
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let record: OrchestrationPlanRecord =
|
||||
serde_json::from_str(line).map_err(|e| TicketError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("invalid orchestration plan record on line {}: {e}", idx + 1),
|
||||
})?;
|
||||
validate_orchestration_plan_record(&record, meta).map_err(|err| TicketError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
message: format!(
|
||||
"invalid orchestration plan record on line {}: {err}",
|
||||
idx + 1
|
||||
),
|
||||
})?;
|
||||
records.push(record);
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn format_yaml_string_scalar(value: &str) -> String {
|
||||
let mut out = String::from("'");
|
||||
for ch in value.chars() {
|
||||
@@ -2335,6 +2769,25 @@ fn collect_artifacts_inner(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn doctor_orchestration_plan_artifact(
|
||||
path: &Path,
|
||||
meta: &TicketMeta,
|
||||
report: &mut TicketDoctorReport,
|
||||
) -> Result<()> {
|
||||
match read_orchestration_plan_artifact(path, Some(meta)) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(TicketError::Parse { message, .. }) => {
|
||||
report.push_error(message, Some(path.to_path_buf()));
|
||||
Ok(())
|
||||
}
|
||||
Err(TicketError::Conflict(message)) => {
|
||||
report.push_error(message, Some(path.to_path_buf()));
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn doctor_artifacts(dir: &Path, report: &mut TicketDoctorReport) -> Result<()> {
|
||||
for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? {
|
||||
let entry = entry.map_err(|e| io_err(dir, e))?;
|
||||
@@ -3135,4 +3588,120 @@ workflow_state: planning
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, TicketError::InvalidPathComponent(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let first = backend.create(NewTicket::new("First ticket")).unwrap();
|
||||
let second = backend.create(NewTicket::new("Second ticket")).unwrap();
|
||||
|
||||
let before = backend
|
||||
.add_orchestration_plan_record(
|
||||
TicketIdOrSlug::Id(first.id.clone()),
|
||||
NewOrchestrationPlanRecord {
|
||||
kind: OrchestrationPlanKind::Before,
|
||||
related_ticket: Some(second.slug.clone()),
|
||||
note: Some(
|
||||
"First must land before second because both touch routing.".to_string(),
|
||||
),
|
||||
accepted_plan: None,
|
||||
author: Some("orchestrator".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(before.ticket_id, first.id);
|
||||
assert_eq!(before.kind, OrchestrationPlanKind::Before);
|
||||
|
||||
backend
|
||||
.add_orchestration_plan_record(
|
||||
TicketIdOrSlug::Slug(first.slug.clone()),
|
||||
NewOrchestrationPlanRecord {
|
||||
kind: OrchestrationPlanKind::AcceptedPlan,
|
||||
related_ticket: None,
|
||||
note: Some("Accepted during routing.".to_string()),
|
||||
accepted_plan: Some(AcceptedOrchestrationPlan {
|
||||
summary: "Implement in a sibling coder worktree, then review before merge."
|
||||
.to_string(),
|
||||
branch: Some("ticket-orchestration-plan-tool".to_string()),
|
||||
worktree: Some(".worktree/ticket-orchestration-plan-tool".to_string()),
|
||||
role_plan: Some(
|
||||
"Coder implements; Reviewer checks capability boundaries.".to_string(),
|
||||
),
|
||||
}),
|
||||
author: Some("orchestrator".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ticket_records = backend
|
||||
.query_orchestration_plan_records(Some(TicketIdOrSlug::Query(first.slug.clone())), None)
|
||||
.unwrap();
|
||||
assert_eq!(ticket_records.len(), 2);
|
||||
assert!(
|
||||
ticket_records
|
||||
.iter()
|
||||
.any(|record| record.kind == OrchestrationPlanKind::AcceptedPlan)
|
||||
);
|
||||
|
||||
let before_records = backend
|
||||
.query_orchestration_plan_records(None, Some(OrchestrationPlanKind::Before))
|
||||
.unwrap();
|
||||
assert_eq!(before_records.len(), 1);
|
||||
assert_eq!(
|
||||
before_records[0].related_ticket.as_deref(),
|
||||
Some(second.slug.as_str())
|
||||
);
|
||||
|
||||
let path = temp
|
||||
.path()
|
||||
.join("tickets")
|
||||
.join("open")
|
||||
.join(&first.id)
|
||||
.join("artifacts")
|
||||
.join(ORCHESTRATION_PLAN_ARTIFACT);
|
||||
assert!(path.is_file());
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
assert_eq!(content.lines().count(), 2);
|
||||
assert_eq!(backend.doctor().unwrap().error_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_plan_validation_rejects_missing_related_ticket_and_bad_artifacts() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let ticket = backend
|
||||
.create(NewTicket::new("Needs plan validation"))
|
||||
.unwrap();
|
||||
|
||||
let err = backend
|
||||
.add_orchestration_plan_record(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
NewOrchestrationPlanRecord {
|
||||
kind: OrchestrationPlanKind::BlockedBy,
|
||||
related_ticket: None,
|
||||
note: Some("Missing related ticket should fail.".to_string()),
|
||||
accepted_plan: None,
|
||||
author: None,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("requires related_ticket"));
|
||||
|
||||
let artifact = temp
|
||||
.path()
|
||||
.join("tickets")
|
||||
.join("open")
|
||||
.join(&ticket.id)
|
||||
.join("artifacts")
|
||||
.join(ORCHESTRATION_PLAN_ARTIFACT);
|
||||
fs::write(&artifact, "{not json}\n").unwrap();
|
||||
let report = backend.doctor().unwrap();
|
||||
assert!(report.error_count() > 0);
|
||||
assert!(report.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic
|
||||
.message
|
||||
.contains("invalid orchestration plan record")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user