diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 62692195..c3003053 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -128,8 +128,12 @@ impl FeatureFlagConfigPartial { pub struct TicketFeatureConfigPartial { #[serde(default)] pub enabled: Option, + /// Legacy access field. Prefer `preset` for new profile/DCDL authoring. #[serde(default)] pub access: Option, + /// Semantic Ticket access preset for profile/DCDL authoring. + #[serde(default)] + pub preset: Option, } impl TicketFeatureConfigPartial { @@ -137,6 +141,7 @@ impl TicketFeatureConfigPartial { Self { enabled: other.enabled.or(self.enabled), access: other.access.or(self.access), + preset: other.preset.or(self.preset), } } } @@ -190,7 +195,7 @@ impl From for TicketFeatureConfig { fn from(value: TicketFeatureConfigPartial) -> Self { Self { enabled: value.enabled.unwrap_or_default(), - access: value.access.unwrap_or_default(), + access: value.preset.or(value.access).unwrap_or_default(), } } } @@ -200,6 +205,7 @@ impl From for TicketFeatureConfigPartial { Self { enabled: Some(value.enabled), access: Some(value.access), + preset: None, } } } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 4c9989a1..7891cd49 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -180,6 +180,12 @@ impl Default for TicketFeatureConfig { #[serde(rename_all = "snake_case")] pub enum TicketFeatureAccessConfig { ReadOnly, + WorkspaceAuthoring, + Intake, + OrchestrationControl, + WorkReport, + Review, + /// Legacy broad mutation preset retained as a migration shim. Lifecycle, } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index bd2adb68..acd8026e 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -16,8 +16,8 @@ use crate::model::{AuthRef, ModelManifest}; use crate::plugin::PluginConfig; use crate::{ EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError, - ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, - WorkerMetaConfig, paths, + ScopeConfig, ScopeRule, SkillsConfig, TicketFeatureAccessConfig, WebConfig, WorkerManifest, + WorkerManifestConfig, WorkerMetaConfig, paths, }; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; @@ -1012,6 +1012,18 @@ fn apply_role_profile( value["feature"]["memory"] = serde_json::json!({ "enabled": memory }); value["feature"]["web"] = serde_json::json!({ "enabled": web }); value["feature"]["workers"] = serde_json::json!({ "enabled": workers }); + let ticket_access = match slug { + "companion" => TicketFeatureAccessConfig::WorkspaceAuthoring, + "intake" => TicketFeatureAccessConfig::Intake, + "orchestrator" => TicketFeatureAccessConfig::OrchestrationControl, + "coder" => TicketFeatureAccessConfig::WorkReport, + "reviewer" => TicketFeatureAccessConfig::Review, + _ => TicketFeatureAccessConfig::Lifecycle, + }; + value["feature"]["ticket"] = serde_json::json!({ + "enabled": true, + "preset": ticket_access, + }); value["feature"]["ticket_orchestration"] = serde_json::json!({ "enabled": ticket_orchestration }); } @@ -1439,6 +1451,10 @@ mod tests { assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert!(companion.web.is_some()); assert!(companion.feature.ticket.enabled); + assert_eq!( + companion.feature.ticket.access, + TicketFeatureAccessConfig::WorkspaceAuthoring + ); assert!(!companion.feature.ticket_orchestration.enabled); assert_eq!( companion.compaction.as_ref().unwrap().threshold, @@ -1461,6 +1477,10 @@ mod tests { assert!(intake.feature.task.enabled); assert!(!intake.feature.workers.enabled); assert!(intake.feature.ticket.enabled); + assert_eq!( + intake.feature.ticket.access, + TicketFeatureAccessConfig::Intake + ); assert!(intake.scope.allow.is_empty()); assert!(intake.delegation_scope.allow.is_empty()); assert_eq!(intake.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); @@ -1472,6 +1492,10 @@ mod tests { assert!(orchestrator.feature.task.enabled); assert!(orchestrator.feature.workers.enabled); assert!(orchestrator.feature.ticket.enabled); + assert_eq!( + orchestrator.feature.ticket.access, + TicketFeatureAccessConfig::OrchestrationControl + ); assert!(orchestrator.feature.ticket_orchestration.enabled); assert!(orchestrator.scope.allow.is_empty()); assert!(orchestrator.delegation_scope.allow.is_empty()); @@ -1491,12 +1515,20 @@ mod tests { assert!(coder.web.is_some()); assert!(coder.compaction.is_some()); assert!(coder.feature.ticket.enabled); + assert_eq!( + coder.feature.ticket.access, + TicketFeatureAccessConfig::WorkReport + ); assert!(!coder.feature.ticket_orchestration.enabled); let reviewer = resolve("reviewer"); assert!(reviewer.feature.task.enabled); assert!(!reviewer.feature.workers.enabled); assert!(reviewer.feature.ticket.enabled); + assert_eq!( + reviewer.feature.ticket.access, + TicketFeatureAccessConfig::Review + ); assert!(!reviewer.feature.ticket_orchestration.enabled); assert!(reviewer.scope.allow.is_empty()); assert!(reviewer.delegation_scope.allow.is_empty()); diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 4b89e7e1..b3c04dcb 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -510,6 +510,21 @@ impl NewTicket { } } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketItemEdit { + pub title: Option, + pub body: Option, + pub author: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketDependencyCheck { + pub ticket: TicketSummary, + pub blockers: Vec, + pub queue_guard: TicketQueueGuard, + pub recommended_action: TicketWorkspaceNextAction, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TicketListState { @@ -1391,6 +1406,8 @@ pub trait TicketBackend { fn list(&self, filter: TicketListQuery) -> Result>; fn show(&self, id: TicketIdOrSlug) -> Result; fn create(&self, input: NewTicket) -> Result; + fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result; + fn dependency_check(&self, id: TicketIdOrSlug) -> Result; fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>; @@ -1449,6 +1466,13 @@ pub enum TicketBackendOperation { Create { input: NewTicket, }, + EditItem { + id: TicketIdOrSlug, + edit: TicketItemEdit, + }, + DependencyCheck { + id: TicketIdOrSlug, + }, AddEvent { id: TicketIdOrSlug, event: NewTicketEvent, @@ -1517,6 +1541,7 @@ pub enum TicketBackendOperationResult { Tickets(Vec), Ticket(Ticket), TicketRef(TicketRef), + DependencyCheck(TicketDependencyCheck), Relation(TicketRelation), Relations(Vec), RelationView(TicketRelationView), @@ -1547,6 +1572,12 @@ where TicketBackendOperation::Create { input } => { TicketBackendOperationResult::TicketRef(backend.create(input)?) } + TicketBackendOperation::EditItem { id, edit } => { + TicketBackendOperationResult::Ticket(backend.edit_item(id, edit)?) + } + TicketBackendOperation::DependencyCheck { id } => { + TicketBackendOperationResult::DependencyCheck(backend.dependency_check(id)?) + } TicketBackendOperation::AddEvent { id, event } => { backend.add_event(id, event)?; TicketBackendOperationResult::Unit @@ -2218,6 +2249,79 @@ impl TicketBackend for LocalTicketBackend { }) } + fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result { + if edit.title.is_none() && edit.body.is_none() { + return Err(TicketError::Conflict( + "TicketEditItem requires at least one of title or body".to_string(), + )); + } + if let Some(title) = edit.title.as_deref() { + validate_required_event_value("title", title)?; + } + if let Some(author) = edit.author.as_deref() { + validate_required_event_value("author", author)?; + } + let _lock = self.acquire_lock()?; + let dir = self.find_ticket_dir(&id)?; + let item = dir.join("item.md"); + let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?; + let mut updates = Vec::new(); + if let Some(title) = edit.title.as_deref() { + updates.push(("title", title)); + } + if !updates.is_empty() { + content = replace_frontmatter_fields(&content, &updates).map_err(|message| { + TicketError::Parse { + path: item.clone(), + message, + } + })?; + } + if let Some(body) = edit.body.as_ref() { + content = replace_item_body(&content, body.as_str()).map_err(|message| { + TicketError::Parse { + path: item.clone(), + message, + } + })?; + } + atomic_write(&item, content.as_bytes())?; + + let author = edit.author.unwrap_or_else(default_author); + let mut changes = Vec::new(); + if edit.title.is_some() { + changes.push("title"); + } + if edit.body.is_some() { + changes.push("body"); + } + let body = MarkdownText::new(format!("Ticket item updated: {}.", changes.join(", "))); + self.append_thread_event( + &dir, + "item_edit", + self.generated_heading("Item updated", "項目更新"), + &author, + None, + &[], + &body, + )?; + self.ticket_from_dir(&dir) + } + + fn dependency_check(&self, id: TicketIdOrSlug) -> Result { + let ticket = self.show(id)?; + let summary = ticket_summary_from_meta(ticket.meta.clone()); + let projection = project_ticket_workspace_item(&summary, &ticket.relations.blockers, None); + Ok(TicketDependencyCheck { + ticket: summary, + blockers: ticket.relations.blockers, + queue_guard: projection.queue_guard, + recommended_action: projection + .next_action + .unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator), + }) + } + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> { let _lock = self.acquire_lock()?; let dir = self.find_ticket_dir(&id)?; @@ -3737,6 +3841,32 @@ fn replace_frontmatter_fields( Ok(out) } +fn replace_item_body(content: &str, body: &str) -> std::result::Result { + let mut lines = content.lines(); + if lines.next() != Some("---") { + return Err("item.md missing frontmatter opener".to_string()); + } + let mut frontmatter = vec!["---".to_string()]; + let mut found_close = false; + for line in lines.by_ref() { + frontmatter.push(line.to_string()); + if line == "---" { + found_close = true; + break; + } + } + if !found_close { + return Err("item.md missing frontmatter closer".to_string()); + } + let mut out = frontmatter.join("\n"); + out.push_str("\n"); + out.push_str(body); + if !out.ends_with('\n') { + out.push('\n'); + } + Ok(out) +} + fn render_event_comment(attrs: &[(&str, &str)]) -> Result { let mut out = String::from("