Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6a476d65d |
Generated
-1
@@ -4417,7 +4417,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
match slug {
|
||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 2,
|
||||
revision: 3,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}),
|
||||
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 2,
|
||||
revision: 3,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}];
|
||||
@@ -69,7 +69,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coder_review_starts_on_a_ticket_branch_and_requires_committed_review_evidence() {
|
||||
fn coder_review_preserves_branch_policy_and_hands_approval_to_orchestrator() {
|
||||
let source =
|
||||
builtin_flow_source(CODER_REVIEW_FLOW_SLUG).expect("coder-review Flow must exist");
|
||||
|
||||
@@ -89,5 +89,8 @@ mod tests {
|
||||
"coder-review Flow must preserve branch/commit policy token {required:?}"
|
||||
);
|
||||
}
|
||||
assert!(source.content.contains("hand off to the Orchestrator"));
|
||||
assert!(source.content.contains("Do not call MergeRequestComplete"));
|
||||
assert!(!source.content.contains("Call MergeRequestComplete with"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,9 @@ use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||
use crate::plugin::PluginConfig;
|
||||
use crate::{
|
||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
|
||||
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
|
||||
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
|
||||
WorkerManifest, WorkerMeta,
|
||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig, ScopeConfig,
|
||||
SessionConfig, SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig,
|
||||
ToolPermissionRule, WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
||||
};
|
||||
|
||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||
@@ -98,8 +97,6 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub orchestration: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||
@@ -130,11 +127,6 @@ impl FeatureConfigPartial {
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||
merge_request: merge_option(
|
||||
self.merge_request,
|
||||
other.merge_request,
|
||||
MergeRequestFeatureConfigPartial::merge,
|
||||
),
|
||||
orchestration: merge_option(
|
||||
self.orchestration,
|
||||
other.orchestration,
|
||||
@@ -224,28 +216,6 @@ impl TicketFeatureConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MergeRequestFeatureConfigPartial {
|
||||
pub show: Option<bool>,
|
||||
pub open: Option<bool>,
|
||||
pub review: Option<bool>,
|
||||
pub readiness_check: Option<bool>,
|
||||
pub complete: Option<bool>,
|
||||
}
|
||||
|
||||
impl MergeRequestFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
show: other.show.or(self.show),
|
||||
open: other.open.or(self.open),
|
||||
review: other.review.or(self.review),
|
||||
readiness_check: other.readiness_check.or(self.readiness_check),
|
||||
complete: other.complete.or(self.complete),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
fn from(value: FeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
@@ -277,10 +247,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.ticket
|
||||
.map(TicketFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
merge_request: value
|
||||
.merge_request
|
||||
.map(MergeRequestFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
orchestration: value
|
||||
.orchestration
|
||||
.map(FeatureFlagConfig::from)
|
||||
@@ -360,30 +326,6 @@ impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MergeRequestFeatureConfigPartial> for MergeRequestFeatureConfig {
|
||||
fn from(value: MergeRequestFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
show: value.show.unwrap_or_default(),
|
||||
open: value.open.unwrap_or_default(),
|
||||
review: value.review.unwrap_or_default(),
|
||||
readiness_check: value.readiness_check.unwrap_or_default(),
|
||||
complete: value.complete.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MergeRequestFeatureConfig> for MergeRequestFeatureConfigPartial {
|
||||
fn from(value: MergeRequestFeatureConfig) -> Self {
|
||||
Self {
|
||||
show: Some(value.show),
|
||||
open: Some(value.open),
|
||||
review: Some(value.review),
|
||||
readiness_check: Some(value.readiness_check),
|
||||
complete: Some(value.complete),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
fn from(value: FeatureConfig) -> Self {
|
||||
Self {
|
||||
@@ -397,7 +339,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
objective: Some(value.objective.into()),
|
||||
manage_workdir: Some(value.manage_workdir.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
merge_request: Some(value.merge_request.into()),
|
||||
orchestration: Some(value.orchestration.into()),
|
||||
plugins: Some(value.plugins.into()),
|
||||
}
|
||||
@@ -1939,7 +1880,6 @@ worker_max_turns = 7
|
||||
assert!(!manifest.feature.objective.enabled);
|
||||
assert!(!manifest.feature.manage_workdir.enabled);
|
||||
assert!(!manifest.feature.ticket.enabled);
|
||||
assert!(!manifest.feature.merge_request.any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1959,13 +1899,6 @@ thread = false
|
||||
intake = false
|
||||
workflow = false
|
||||
|
||||
[feature.merge_request]
|
||||
show = true
|
||||
open = false
|
||||
review = true
|
||||
readiness_check = false
|
||||
complete = false
|
||||
|
||||
[feature.orchestration]
|
||||
enabled = false
|
||||
"#,
|
||||
@@ -2001,14 +1934,6 @@ enabled = false
|
||||
assert!(!manifest.feature.ticket.thread);
|
||||
assert!(!manifest.feature.ticket.intake);
|
||||
assert!(!manifest.feature.ticket.workflow);
|
||||
assert_eq!(
|
||||
manifest.feature.merge_request,
|
||||
MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
review: true,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert!(!manifest.feature.orchestration.enabled);
|
||||
assert!(!manifest.feature.memory.enabled);
|
||||
assert!(!manifest.feature.memory.staging);
|
||||
@@ -2032,13 +1957,6 @@ thread = false
|
||||
intake = false
|
||||
workflow = false
|
||||
|
||||
[feature.merge_request]
|
||||
show = true
|
||||
open = false
|
||||
review = true
|
||||
readiness_check = false
|
||||
complete = false
|
||||
|
||||
[feature.orchestration]
|
||||
enabled = false
|
||||
"#,
|
||||
@@ -2050,11 +1968,6 @@ enabled = false
|
||||
thread = true
|
||||
workflow = true
|
||||
|
||||
[feature.merge_request]
|
||||
open = true
|
||||
review = false
|
||||
readiness_check = true
|
||||
|
||||
[feature.orchestration]
|
||||
enabled = true
|
||||
|
||||
@@ -2104,16 +2017,6 @@ enabled = true
|
||||
assert!(manifest.feature.ticket.thread);
|
||||
assert!(!manifest.feature.ticket.intake);
|
||||
assert!(manifest.feature.ticket.workflow);
|
||||
assert_eq!(
|
||||
manifest.feature.merge_request,
|
||||
MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
open: true,
|
||||
review: false,
|
||||
readiness_check: true,
|
||||
complete: false,
|
||||
}
|
||||
);
|
||||
assert!(manifest.feature.orchestration.enabled);
|
||||
assert!(manifest.feature.objective.enabled);
|
||||
assert!(manifest.feature.web.enabled);
|
||||
|
||||
@@ -125,8 +125,6 @@ pub struct FeatureConfig {
|
||||
#[serde(default)]
|
||||
pub ticket: TicketFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub merge_request: MergeRequestFeatureConfig,
|
||||
#[serde(default)]
|
||||
pub orchestration: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub plugins: FeatureFlagConfig,
|
||||
@@ -145,7 +143,6 @@ impl Default for FeatureConfig {
|
||||
objective: FeatureFlagConfig::disabled(),
|
||||
manage_workdir: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
merge_request: MergeRequestFeatureConfig::default(),
|
||||
orchestration: FeatureFlagConfig::disabled(),
|
||||
plugins: FeatureFlagConfig::disabled(),
|
||||
}
|
||||
@@ -255,27 +252,6 @@ pub struct TicketFeatureConfig {
|
||||
pub workflow: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MergeRequestFeatureConfig {
|
||||
#[serde(default)]
|
||||
pub show: bool,
|
||||
#[serde(default)]
|
||||
pub open: bool,
|
||||
#[serde(default)]
|
||||
pub review: bool,
|
||||
#[serde(default)]
|
||||
pub readiness_check: bool,
|
||||
#[serde(default)]
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
impl MergeRequestFeatureConfig {
|
||||
pub fn any(self) -> bool {
|
||||
self.show || self.open || self.review || self.readiness_check || self.complete
|
||||
}
|
||||
}
|
||||
|
||||
/// External Agent Skills (`SKILL.md`) ingest configuration. Skills are
|
||||
/// loaded *only* from the directories listed here — there is no
|
||||
/// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest
|
||||
|
||||
@@ -919,37 +919,6 @@ fn apply_role_profile(
|
||||
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||
};
|
||||
value["feature"]["ticket"] = ticket;
|
||||
let merge_request = match slug {
|
||||
"coder" => serde_json::json!({
|
||||
"show": true,
|
||||
"open": true,
|
||||
"review": false,
|
||||
"readiness_check": false,
|
||||
"complete": false
|
||||
}),
|
||||
"reviewer" => serde_json::json!({
|
||||
"show": true,
|
||||
"open": false,
|
||||
"review": true,
|
||||
"readiness_check": false,
|
||||
"complete": false
|
||||
}),
|
||||
"orchestrator" => serde_json::json!({
|
||||
"show": true,
|
||||
"open": false,
|
||||
"review": false,
|
||||
"readiness_check": true,
|
||||
"complete": true
|
||||
}),
|
||||
_ => serde_json::json!({
|
||||
"show": false,
|
||||
"open": false,
|
||||
"review": false,
|
||||
"readiness_check": false,
|
||||
"complete": false
|
||||
}),
|
||||
};
|
||||
value["feature"]["merge_request"] = merge_request;
|
||||
}
|
||||
|
||||
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
|
||||
@@ -1392,7 +1361,6 @@ mod tests {
|
||||
assert!(companion.feature.objective.enabled);
|
||||
assert!(!companion.feature.ticket.intake);
|
||||
assert!(!companion.feature.orchestration.enabled);
|
||||
assert!(!companion.feature.merge_request.any());
|
||||
assert_eq!(
|
||||
companion.compaction.as_ref().unwrap().threshold,
|
||||
Some(240000)
|
||||
@@ -1433,15 +1401,6 @@ mod tests {
|
||||
assert!(!orchestrator.feature.sub_worker.enabled);
|
||||
assert!(orchestrator.feature.worker.enabled);
|
||||
assert!(!orchestrator.feature.worker.direct_spawn);
|
||||
assert_eq!(
|
||||
orchestrator.feature.merge_request,
|
||||
crate::MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
readiness_check: true,
|
||||
complete: true,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert!(orchestrator.feature.ticket.enabled);
|
||||
assert!(orchestrator.feature.ticket.enabled);
|
||||
assert!(!orchestrator.feature.ticket.authoring);
|
||||
@@ -1465,14 +1424,6 @@ mod tests {
|
||||
assert!(coder.feature.sub_worker.enabled);
|
||||
assert!(coder.feature.flow.enabled);
|
||||
assert!(!coder.feature.worker.enabled);
|
||||
assert_eq!(
|
||||
coder.feature.merge_request,
|
||||
crate::MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
open: true,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert!(coder.scope.allow.is_empty());
|
||||
assert!(coder.delegation_scope.allow.is_empty());
|
||||
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
|
||||
@@ -1491,14 +1442,6 @@ mod tests {
|
||||
assert!(!reviewer.feature.sub_worker.enabled);
|
||||
assert!(!reviewer.feature.flow.enabled);
|
||||
assert!(!reviewer.feature.worker.enabled);
|
||||
assert_eq!(
|
||||
reviewer.feature.merge_request,
|
||||
crate::MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
review: true,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert!(reviewer.feature.ticket.enabled);
|
||||
assert!(reviewer.feature.ticket.enabled);
|
||||
assert!(!reviewer.feature.ticket.authoring);
|
||||
@@ -1661,14 +1604,6 @@ enabled = true
|
||||
authoring = false
|
||||
thread = false
|
||||
intake = false
|
||||
|
||||
[feature.merge_request]
|
||||
show = true
|
||||
open = false
|
||||
review = true
|
||||
readiness_check = false
|
||||
complete = false
|
||||
|
||||
[feature.orchestration]
|
||||
enabled = false
|
||||
"#,
|
||||
@@ -1691,14 +1626,6 @@ enabled = false
|
||||
assert!(!resolved.manifest.feature.ticket.authoring);
|
||||
assert!(!resolved.manifest.feature.ticket.thread);
|
||||
assert!(!resolved.manifest.feature.ticket.intake);
|
||||
assert_eq!(
|
||||
resolved.manifest.feature.merge_request,
|
||||
crate::MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
review: true,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert!(!resolved.manifest.feature.orchestration.enabled);
|
||||
assert_eq!(
|
||||
resolved.manifest.delegation_scope.allow[0].target,
|
||||
|
||||
@@ -14,7 +14,6 @@ schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9.34"
|
||||
sha2.workspace = true
|
||||
rusqlite.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
+111
-622
@@ -19,7 +19,6 @@ use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod config;
|
||||
@@ -81,32 +80,6 @@ pub enum TicketError {
|
||||
Locked { path: PathBuf },
|
||||
#[error("ticket conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("ticket target repository is required")]
|
||||
MissingTargetRepository,
|
||||
#[error("ticket target repository `{0}` is not registered in this workspace")]
|
||||
UnknownTargetRepository(String),
|
||||
#[error("ticket target selector is required for repository `{0}`")]
|
||||
MissingTargetSelector(String),
|
||||
#[error(
|
||||
"ticket target selector `{selector}` is invalid for repository `{repository_id}`: {reason}"
|
||||
)]
|
||||
InvalidTargetSelector {
|
||||
repository_id: String,
|
||||
selector: String,
|
||||
reason: String,
|
||||
},
|
||||
#[error("ticket target authority is unavailable")]
|
||||
TargetAuthorityUnavailable,
|
||||
#[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")]
|
||||
StaleWorkflowState { expected: String, actual: String },
|
||||
#[error("invalid ticket workflow transition `{from}` -> `{to}`")]
|
||||
InvalidWorkflowTransition { from: String, to: String },
|
||||
#[error("ticket has unresolved blocking relations: {0}")]
|
||||
BlockingRelations(String),
|
||||
#[error(
|
||||
"ticket operation key `{operation_key}` was reused with a different request fingerprint"
|
||||
)]
|
||||
OperationFingerprintMismatch { operation_key: String },
|
||||
#[error("SQLite ticket backend error: {0}")]
|
||||
Sqlite(String),
|
||||
#[error("ticket parse error in {path}: {message}")]
|
||||
@@ -502,37 +475,6 @@ pub enum TicketTargetEdit {
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolvedTicketTarget {
|
||||
pub repository_id: String,
|
||||
pub ref_selector: String,
|
||||
}
|
||||
|
||||
/// Workspace-owned authority used to resolve and validate implementation targets.
|
||||
///
|
||||
/// Ticket storage never infers repositories from cwd or repository paths. The
|
||||
/// Workspace Backend supplies this boundary from its authoritative repository
|
||||
/// catalog. Backends without it fail closed for ready/queue transitions.
|
||||
pub trait TicketTargetAuthority: Send + Sync {
|
||||
fn resolve_target(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
repository_id: Option<&str>,
|
||||
ref_selector: Option<&str>,
|
||||
) -> Result<ResolvedTicketTarget>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketMarkReady {
|
||||
pub operation_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub intake_summary: Option<TicketIntakeSummary>,
|
||||
}
|
||||
|
||||
impl TicketTargetEdit {
|
||||
fn validate(&self) -> Result<()> {
|
||||
if let Self::Set {
|
||||
@@ -562,116 +504,6 @@ fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_ready_target(
|
||||
authority: Option<&Arc<dyn TicketTargetAuthority>>,
|
||||
workspace_id: &str,
|
||||
ticket: &Ticket,
|
||||
) -> Result<ResolvedTicketTarget> {
|
||||
authority
|
||||
.ok_or(TicketError::TargetAuthorityUnavailable)?
|
||||
.resolve_target(
|
||||
workspace_id,
|
||||
ticket.meta.repository_id.as_deref(),
|
||||
ticket.meta.ref_selector.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn mark_ready_fingerprint(
|
||||
ticket: &Ticket,
|
||||
request: &TicketMarkReady,
|
||||
target: &ResolvedTicketTarget,
|
||||
) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"ticket.mark-ready.v1\0");
|
||||
digest.update(ticket.meta.id.as_str().as_bytes());
|
||||
digest.update(b"\0planning\0");
|
||||
digest.update(target.repository_id.as_bytes());
|
||||
digest.update(b"\0");
|
||||
digest.update(target.ref_selector.as_bytes());
|
||||
digest.update(b"\0");
|
||||
if let Some(reason) = request.reason.as_deref() {
|
||||
digest.update(reason.as_bytes());
|
||||
}
|
||||
if let Some(summary) = request.intake_summary.as_ref() {
|
||||
digest.update(b"\0intake-summary\0");
|
||||
digest.update(summary.body.as_str().as_bytes());
|
||||
for reference in &summary.references {
|
||||
digest.update(b"\0");
|
||||
digest.update(reference.kind.as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(reference.target.as_bytes());
|
||||
}
|
||||
}
|
||||
digest
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Result<bool> {
|
||||
let Some(event) = ticket
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.attributes.get("operation_key") == Some(&request.operation_key))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let target = ResolvedTicketTarget {
|
||||
repository_id: event
|
||||
.attributes
|
||||
.get("repository_id")
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("mark-ready event is missing repository_id".to_owned())
|
||||
})?,
|
||||
ref_selector: event
|
||||
.attributes
|
||||
.get("ref_selector")
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("mark-ready event is missing ref_selector".to_owned())
|
||||
})?,
|
||||
};
|
||||
let fingerprint = mark_ready_fingerprint(ticket, request, &target);
|
||||
if event.attributes.get("request_fingerprint") != Some(&fingerprint) {
|
||||
return Err(TicketError::OperationFingerprintMismatch {
|
||||
operation_key: request.operation_key.clone(),
|
||||
});
|
||||
}
|
||||
if ticket.meta.workflow_state != TicketWorkflowState::Ready
|
||||
|| ticket.meta.repository_id.as_deref() != Some(target.repository_id.as_str())
|
||||
|| ticket.meta.ref_selector.as_deref() != Some(target.ref_selector.as_str())
|
||||
{
|
||||
return Err(TicketError::StaleWorkflowState {
|
||||
expected: TicketWorkflowState::Ready.as_str().to_owned(),
|
||||
actual: ticket.meta.workflow_state.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn validate_generic_state_change(
|
||||
current: TicketWorkflowState,
|
||||
to: TicketWorkflowState,
|
||||
) -> Result<()> {
|
||||
if current == TicketWorkflowState::Planning && to == TicketWorkflowState::Ready
|
||||
|| current == TicketWorkflowState::Ready && to == TicketWorkflowState::Queued
|
||||
{
|
||||
return Err(TicketError::InvalidWorkflowTransition {
|
||||
from: current.as_str().to_owned(),
|
||||
to: to.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if !TicketWorkflowState::is_role_transition(current, to) {
|
||||
return Err(TicketError::InvalidWorkflowTransition {
|
||||
from: current.as_str().to_owned(),
|
||||
to: to.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketItemEdit {
|
||||
pub title: Option<String>,
|
||||
@@ -1692,7 +1524,12 @@ pub trait TicketBackend {
|
||||
change: TicketStateChange,
|
||||
) -> Result<()>;
|
||||
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket>;
|
||||
fn mark_intake_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
) -> Result<()>;
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
||||
fn add_ticket_relation(
|
||||
@@ -1768,9 +1605,10 @@ pub enum TicketBackendOperation {
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
},
|
||||
MarkReady {
|
||||
MarkIntakeReady {
|
||||
id: TicketIdOrSlug,
|
||||
request: TicketMarkReady,
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
},
|
||||
QueueReady {
|
||||
id: TicketIdOrSlug,
|
||||
@@ -1872,8 +1710,13 @@ where
|
||||
backend.set_workflow_state(id, change)?;
|
||||
TicketBackendOperationResult::Unit
|
||||
}
|
||||
TicketBackendOperation::MarkReady { id, request } => {
|
||||
TicketBackendOperationResult::Ticket(backend.mark_ready(id, request)?)
|
||||
TicketBackendOperation::MarkIntakeReady {
|
||||
id,
|
||||
summary,
|
||||
change,
|
||||
} => {
|
||||
backend.mark_intake_ready(id, summary, change)?;
|
||||
TicketBackendOperationResult::Unit
|
||||
}
|
||||
TicketBackendOperation::QueueReady { id, queued_by } => {
|
||||
backend.queue_ready(id, &queued_by)?;
|
||||
@@ -1913,25 +1756,10 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalTicketBackend {
|
||||
root: PathBuf,
|
||||
record_language: Option<String>,
|
||||
target_authority: Option<Arc<dyn TicketTargetAuthority>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LocalTicketBackend {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LocalTicketBackend")
|
||||
.field("root", &self.root)
|
||||
.field("record_language", &self.record_language)
|
||||
.field(
|
||||
"target_authority",
|
||||
&self.target_authority.as_ref().map(|_| "configured"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalTicketBackend {
|
||||
@@ -1939,7 +1767,6 @@ impl LocalTicketBackend {
|
||||
Self {
|
||||
root: root.into(),
|
||||
record_language: None,
|
||||
target_authority: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1948,11 +1775,6 @@ impl LocalTicketBackend {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_target_authority(mut self, authority: Arc<dyn TicketTargetAuthority>) -> Self {
|
||||
self.target_authority = Some(authority);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn record_language(&self) -> Option<&str> {
|
||||
self.record_language.as_deref()
|
||||
}
|
||||
@@ -2254,16 +2076,6 @@ impl LocalTicketBackend {
|
||||
dir: &Path,
|
||||
change: &TicketStateChange,
|
||||
state_field: Option<&str>,
|
||||
) -> Result<()> {
|
||||
self.append_state_changed_event_with_attributes(dir, change, state_field, &[])
|
||||
}
|
||||
|
||||
fn append_state_changed_event_with_attributes(
|
||||
&self,
|
||||
dir: &Path,
|
||||
change: &TicketStateChange,
|
||||
state_field: Option<&str>,
|
||||
extra_attributes: &[(&str, &str)],
|
||||
) -> Result<()> {
|
||||
validate_state_change(change)?;
|
||||
let author = change.author.clone().unwrap_or_else(default_author);
|
||||
@@ -2275,7 +2087,6 @@ impl LocalTicketBackend {
|
||||
if let Some(state_field) = state_field {
|
||||
attrs.push(("field", state_field));
|
||||
}
|
||||
attrs.extend_from_slice(extra_attributes);
|
||||
self.append_thread_event(
|
||||
dir,
|
||||
TicketEventKind::StateChanged.as_str(),
|
||||
@@ -2436,7 +2247,6 @@ pub struct SqliteTicketBackend {
|
||||
record_language: Option<String>,
|
||||
event_attributes: BTreeMap<String, String>,
|
||||
mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
|
||||
target_authority: Option<Arc<dyn TicketTargetAuthority>>,
|
||||
#[cfg(test)]
|
||||
full_ticket_load_count: Arc<AtomicUsize>,
|
||||
}
|
||||
@@ -2453,10 +2263,6 @@ impl fmt::Debug for SqliteTicketBackend {
|
||||
"mutation_hook",
|
||||
&self.mutation_hook.as_ref().map(|_| "configured"),
|
||||
)
|
||||
.field(
|
||||
"target_authority",
|
||||
&self.target_authority.as_ref().map(|_| "configured"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -2469,7 +2275,6 @@ impl SqliteTicketBackend {
|
||||
record_language: None,
|
||||
event_attributes: BTreeMap::new(),
|
||||
mutation_hook: None,
|
||||
target_authority: None,
|
||||
#[cfg(test)]
|
||||
full_ticket_load_count: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
@@ -2511,11 +2316,6 @@ impl SqliteTicketBackend {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_target_authority(mut self, authority: Arc<dyn TicketTargetAuthority>) -> Self {
|
||||
self.target_authority = Some(authority);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn db_path(&self) -> &Path {
|
||||
self.db_path.as_path()
|
||||
}
|
||||
@@ -3366,15 +3166,6 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
validate_required_event_value("author", author)?;
|
||||
}
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
if edit.target.is_some() {
|
||||
let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state;
|
||||
if current != TicketWorkflowState::Planning {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket implementation target is locked after planning (current state: {})",
|
||||
current.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let now = now_utc();
|
||||
let mut body_edit_audit = TicketBodyEditAudit::None;
|
||||
if let Some(title) = edit.title.as_ref() {
|
||||
@@ -3540,41 +3331,10 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
}
|
||||
|
||||
fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> {
|
||||
validate_state_change(&change)?;
|
||||
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
|
||||
TicketError::InvalidWorkflowTransition {
|
||||
from: change.from.clone(),
|
||||
to: change.to.clone(),
|
||||
}
|
||||
})?;
|
||||
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
|
||||
TicketError::InvalidWorkflowTransition {
|
||||
from: change.from.clone(),
|
||||
to: change.to.clone(),
|
||||
}
|
||||
})?;
|
||||
validate_generic_state_change(from, to)?;
|
||||
self.with_write(|conn| {
|
||||
validate_state_change(&change)?;
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state;
|
||||
if current != from {
|
||||
return Err(TicketError::StaleWorkflowState {
|
||||
expected: from.as_str().to_owned(),
|
||||
actual: current.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
|
||||
let ticket = self.load_ticket(conn, &ticket_id)?;
|
||||
let blockers = ticket
|
||||
.relations
|
||||
.blockers
|
||||
.into_iter()
|
||||
.filter(|blocker| !relation_blocker_allows_queue(blocker))
|
||||
.collect::<Vec<_>>();
|
||||
if !blockers.is_empty() {
|
||||
return Err(TicketError::BlockingRelations(format_relation_blockers(&blockers)));
|
||||
}
|
||||
}
|
||||
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| TicketError::Conflict(format!("unknown workflow_state '{}':", change.to)))?;
|
||||
let at = now_utc();
|
||||
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(change.author.clone().unwrap_or_else(default_author)), at: Some(at.clone()), status: None, from: Some(change.from), to: Some(change.to), reason: Some(change.reason), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: change.body, references: change.references, attributes: BTreeMap::new() })?;
|
||||
conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, updated_at = ?4, status = CASE WHEN ?3 = 'closed' THEN 'closed' ELSE status END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, to.as_str(), at]).map_err(sqlite_err)?;
|
||||
@@ -3582,120 +3342,24 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
|
||||
validate_required_event_value("operation_key", &request.operation_key)?;
|
||||
self.with_write(|conn| {
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
let ticket = self.load_ticket(conn, &ticket_id)?;
|
||||
if validate_mark_ready_replay(&ticket, &request)? {
|
||||
return Ok(ticket);
|
||||
}
|
||||
let target = resolve_ready_target(
|
||||
self.target_authority.as_ref(),
|
||||
&self.workspace_id,
|
||||
&ticket,
|
||||
)?;
|
||||
let fingerprint = mark_ready_fingerprint(&ticket, &request, &target);
|
||||
if ticket.meta.workflow_state != TicketWorkflowState::Planning {
|
||||
return Err(TicketError::StaleWorkflowState {
|
||||
expected: TicketWorkflowState::Planning.as_str().to_owned(),
|
||||
actual: ticket.meta.workflow_state.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let reason = request
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("implementation target validated")
|
||||
.to_owned();
|
||||
let at = now_utc();
|
||||
if let Some(mut summary) = request.intake_summary.clone() {
|
||||
validate_intake_summary(&summary)?;
|
||||
summary.author = request.author.clone().or(summary.author);
|
||||
self.insert_event(
|
||||
conn,
|
||||
&ticket_id,
|
||||
&TicketEvent {
|
||||
kind: TicketEventKind::IntakeSummary,
|
||||
author: summary.author,
|
||||
at: None,
|
||||
status: None,
|
||||
from: None,
|
||||
to: None,
|
||||
reason: None,
|
||||
state_field: None,
|
||||
heading: Some(TicketEventKind::IntakeSummary.heading()),
|
||||
body: summary.body,
|
||||
references: summary.references,
|
||||
attributes: BTreeMap::new(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
self.insert_event(
|
||||
conn,
|
||||
&ticket_id,
|
||||
&TicketEvent {
|
||||
kind: TicketEventKind::StateChanged,
|
||||
author: Some(request.author.unwrap_or_else(default_author)),
|
||||
at: Some(at.clone()),
|
||||
status: None,
|
||||
from: Some(TicketWorkflowState::Planning.as_str().to_owned()),
|
||||
to: Some(TicketWorkflowState::Ready.as_str().to_owned()),
|
||||
reason: Some(reason),
|
||||
state_field: Some("state".to_owned()),
|
||||
heading: Some(TicketEventKind::StateChanged.heading()),
|
||||
body: MarkdownText::new(format!(
|
||||
"Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.",
|
||||
target.repository_id, target.ref_selector
|
||||
)),
|
||||
references: Vec::new(),
|
||||
attributes: BTreeMap::from([
|
||||
("operation_key".to_owned(), request.operation_key),
|
||||
("request_fingerprint".to_owned(), fingerprint),
|
||||
("repository_id".to_owned(), target.repository_id.clone()),
|
||||
("ref_selector".to_owned(), target.ref_selector.clone()),
|
||||
]),
|
||||
},
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'",
|
||||
params![self.workspace_id, ticket_id, target.repository_id, target.ref_selector, at],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
self.load_ticket(conn, &ticket_id)
|
||||
})
|
||||
fn mark_intake_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
) -> Result<()> {
|
||||
self.add_intake_summary(id.clone(), summary)?;
|
||||
self.set_workflow_state(id, change)
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
|
||||
validate_required_event_value("queued_by", queued_by)?;
|
||||
self.with_write(|conn| {
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
let ticket = self.load_ticket(conn, &ticket_id)?;
|
||||
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
|
||||
return Err(TicketError::StaleWorkflowState {
|
||||
expected: TicketWorkflowState::Ready.as_str().to_owned(),
|
||||
actual: ticket.meta.workflow_state.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let target = resolve_ready_target(
|
||||
self.target_authority.as_ref(),
|
||||
&self.workspace_id,
|
||||
&ticket,
|
||||
)?;
|
||||
let blockers = ticket
|
||||
.relations
|
||||
.blockers
|
||||
.iter()
|
||||
.filter(|blocker| !relation_blocker_allows_queue(blocker))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !blockers.is_empty() {
|
||||
return Err(TicketError::BlockingRelations(format_relation_blockers(&blockers)));
|
||||
}
|
||||
if ticket.meta.workflow_state != TicketWorkflowState::Ready { return Err(TicketError::Conflict(format!("Ticket state is {}; only ready Tickets can be queued", ticket.meta.workflow_state.as_str()))); }
|
||||
let at = now_utc();
|
||||
conn.execute("UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, repository_id = ?5, ref_selector = ?6, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", params![self.workspace_id, ticket_id, queued_by, at, target.repository_id, target.ref_selector]).map_err(sqlite_err)?;
|
||||
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(queued_by.to_string()), at: Some(at.clone()), status: None, from: Some("ready".to_string()), to: Some("queued".to_string()), reason: Some("queued".to_string()), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), attributes: BTreeMap::from([("queued_by".to_owned(), queued_by.to_owned()), ("queued_at".to_owned(), at), ("repository_id".to_owned(), target.repository_id), ("ref_selector".to_owned(), target.ref_selector)]) })
|
||||
conn.execute("UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, queued_by, at]).map_err(sqlite_err)?;
|
||||
self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(queued_by.to_string()), at: Some(at), status: None, from: Some("ready".to_string()), to: Some("queued".to_string()), reason: Some("queued".to_string()), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), attributes: BTreeMap::new() })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4012,15 +3676,6 @@ impl TicketBackend for LocalTicketBackend {
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let item = dir.join("item.md");
|
||||
if edit.target.is_some() {
|
||||
let current = self.ticket_workflow_state_from_dir(&dir)?;
|
||||
if current != TicketWorkflowState::Planning {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket implementation target is locked after planning (current state: {})",
|
||||
current.as_str()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut content = fs::read_to_string(&item).map_err(|e| io_err(&item, e))?;
|
||||
let mut body_edit_audit = TicketBodyEditAudit::None;
|
||||
let mut updates = Vec::new();
|
||||
@@ -4237,7 +3892,13 @@ impl TicketBackend for LocalTicketBackend {
|
||||
change.to
|
||||
))
|
||||
})?;
|
||||
validate_generic_state_change(from, to)?;
|
||||
if !TicketWorkflowState::is_role_transition(from, to) {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"workflow_state transition {} -> {} is not allowed through set_workflow_state; use dedicated planning-ready or queue APIs for gated transitions",
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
)));
|
||||
}
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress {
|
||||
@@ -4255,62 +3916,43 @@ impl TicketBackend for LocalTicketBackend {
|
||||
self.apply_workflow_state_change(&dir, from, to, change, &[])
|
||||
}
|
||||
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result<Ticket> {
|
||||
validate_required_event_value("operation_key", &request.operation_key)?;
|
||||
fn mark_intake_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
) -> Result<()> {
|
||||
let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| {
|
||||
TicketError::Conflict(format!(
|
||||
"invalid workflow_state transition source: {}",
|
||||
change.from
|
||||
))
|
||||
})?;
|
||||
let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| {
|
||||
TicketError::Conflict(format!(
|
||||
"invalid workflow_state transition target: {}",
|
||||
change.to
|
||||
))
|
||||
})?;
|
||||
if !TicketWorkflowState::is_planning_ready_transition(from, to) {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"mark_intake_ready only allows state planning -> ready, got {} -> {}",
|
||||
from.as_str(),
|
||||
to.as_str()
|
||||
)));
|
||||
}
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let ticket = self.ticket_from_dir(&dir)?;
|
||||
if validate_mark_ready_replay(&ticket, &request)? {
|
||||
return Ok(ticket);
|
||||
let current = self.ticket_workflow_state_from_dir(&dir)?;
|
||||
if current != from {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"state changed concurrently: expected `{}`, found `{}`",
|
||||
from.as_str(),
|
||||
current.as_str()
|
||||
)));
|
||||
}
|
||||
let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?;
|
||||
let fingerprint = mark_ready_fingerprint(&ticket, &request, &target);
|
||||
if ticket.meta.workflow_state != TicketWorkflowState::Planning {
|
||||
return Err(TicketError::StaleWorkflowState {
|
||||
expected: TicketWorkflowState::Planning.as_str().to_owned(),
|
||||
actual: ticket.meta.workflow_state.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let reason = request
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("implementation target validated");
|
||||
let mut change = TicketStateChange::new(
|
||||
TicketWorkflowState::Planning.as_str(),
|
||||
TicketWorkflowState::Ready.as_str(),
|
||||
reason,
|
||||
MarkdownText::new(format!(
|
||||
"Implementation target `{}` at selector `{}` was validated and the Ticket was marked ready.",
|
||||
target.repository_id, target.ref_selector
|
||||
)),
|
||||
);
|
||||
change.author = request.author.clone().or_else(|| Some(default_author()));
|
||||
if let Some(mut summary) = request.intake_summary {
|
||||
summary.author = request.author.clone().or(summary.author);
|
||||
self.append_intake_summary_event(&dir, &summary)?;
|
||||
}
|
||||
self.append_state_changed_event_with_attributes(
|
||||
&dir,
|
||||
&change,
|
||||
Some("state"),
|
||||
&[
|
||||
("operation_key", request.operation_key.as_str()),
|
||||
("request_fingerprint", fingerprint.as_str()),
|
||||
("repository_id", target.repository_id.as_str()),
|
||||
("ref_selector", target.ref_selector.as_str()),
|
||||
],
|
||||
)?;
|
||||
self.set_frontmatter_fields(
|
||||
&dir.join("item.md"),
|
||||
&[
|
||||
("state", TicketWorkflowState::Ready.as_str()),
|
||||
("repository_id", target.repository_id.as_str()),
|
||||
("ref_selector", target.ref_selector.as_str()),
|
||||
],
|
||||
)?;
|
||||
self.ticket_from_dir(&dir)
|
||||
self.append_intake_summary_event(&dir, &summary)?;
|
||||
self.apply_workflow_state_change(&dir, from, to, change, &[])
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> {
|
||||
@@ -4319,22 +3961,14 @@ impl TicketBackend for LocalTicketBackend {
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let item = dir.join("item.md");
|
||||
let meta = ticket_meta_for_dir(&dir, read_item_file(&item)?.frontmatter)?;
|
||||
if meta.workflow_state != TicketWorkflowState::Ready {
|
||||
return Err(TicketError::StaleWorkflowState {
|
||||
expected: TicketWorkflowState::Ready.as_str().to_owned(),
|
||||
actual: meta.workflow_state.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
let ticket = self.ticket_from_dir(&dir)?;
|
||||
let target = resolve_ready_target(self.target_authority.as_ref(), "local", &ticket)?;
|
||||
let blockers = self.relation_blockers_for_meta(&meta)?;
|
||||
let active_blockers = blockers
|
||||
.into_iter()
|
||||
.filter(|blocker| !relation_blocker_allows_queue(blocker))
|
||||
.collect::<Vec<_>>();
|
||||
if !active_blockers.is_empty() {
|
||||
return Err(TicketError::BlockingRelations(format!(
|
||||
"{}: {}",
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket {} has unresolved blocking relation(s): {}",
|
||||
meta.id,
|
||||
format_relation_blockers(&active_blockers)
|
||||
)));
|
||||
@@ -4352,12 +3986,7 @@ impl TicketBackend for LocalTicketBackend {
|
||||
TicketWorkflowState::Ready,
|
||||
TicketWorkflowState::Queued,
|
||||
change,
|
||||
&[
|
||||
("queued_by", queued_by),
|
||||
("queued_at", at.as_str()),
|
||||
("repository_id", target.repository_id.as_str()),
|
||||
("ref_selector", target.ref_selector.as_str()),
|
||||
],
|
||||
&[("queued_by", queued_by), ("queued_at", at.as_str())],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5094,17 +4723,9 @@ fn invalid_ticket_record_reason(error: &TicketError) -> &'static str {
|
||||
TicketError::Locked { .. } => "ticket backend is locked",
|
||||
TicketError::Sqlite(_) => "could not read ticket record",
|
||||
TicketError::NotFound(_) => "ticket record is missing",
|
||||
TicketError::Ambiguous { .. }
|
||||
| TicketError::Conflict(_)
|
||||
| TicketError::MissingTargetRepository
|
||||
| TicketError::UnknownTargetRepository(_)
|
||||
| TicketError::MissingTargetSelector(_)
|
||||
| TicketError::InvalidTargetSelector { .. }
|
||||
| TicketError::TargetAuthorityUnavailable
|
||||
| TicketError::StaleWorkflowState { .. }
|
||||
| TicketError::InvalidWorkflowTransition { .. }
|
||||
| TicketError::BlockingRelations(_)
|
||||
| TicketError::OperationFingerprintMismatch { .. } => "invalid ticket record metadata",
|
||||
TicketError::Ambiguous { .. } | TicketError::Conflict(_) => {
|
||||
"invalid ticket record metadata"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6443,32 +6064,8 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestTargetAuthority;
|
||||
|
||||
impl TicketTargetAuthority for TestTargetAuthority {
|
||||
fn resolve_target(
|
||||
&self,
|
||||
_workspace_id: &str,
|
||||
repository_id: Option<&str>,
|
||||
ref_selector: Option<&str>,
|
||||
) -> Result<ResolvedTicketTarget> {
|
||||
let repository_id = repository_id.unwrap_or("main");
|
||||
if repository_id == "unknown" {
|
||||
return Err(TicketError::UnknownTargetRepository(
|
||||
repository_id.to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(ResolvedTicketTarget {
|
||||
repository_id: repository_id.to_owned(),
|
||||
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn backend(dir: &TempDir) -> LocalTicketBackend {
|
||||
LocalTicketBackend::new(dir.path().join("tickets"))
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn assert_ticket_target_edit_semantics<B: TicketBackend>(backend: &B) {
|
||||
@@ -6977,92 +6574,6 @@ state: planning
|
||||
assert_ticket_target_edit_semantics(&backend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_mark_ready_and_queue_enforce_target_and_blockers_atomically() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test")
|
||||
.unwrap()
|
||||
.with_target_authority(Arc::new(TestTargetAuthority));
|
||||
let mut dependency = NewTicket::new("Dependency");
|
||||
dependency.repository_id = Some("main".to_owned());
|
||||
let dependency = backend.create(dependency).unwrap();
|
||||
let mut implementation = NewTicket::new("Implementation");
|
||||
implementation.repository_id = Some("main".to_owned());
|
||||
let implementation = backend.create(implementation).unwrap();
|
||||
backend
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id(implementation.id.clone()),
|
||||
NewTicketRelation {
|
||||
kind: TicketRelationKind::DependsOn,
|
||||
target: dependency.id.clone(),
|
||||
note: None,
|
||||
author: Some("test".to_owned()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let request = TicketMarkReady {
|
||||
operation_key: "sqlite-ready".to_owned(),
|
||||
reason: Some("target accepted".to_owned()),
|
||||
author: Some("test".to_owned()),
|
||||
intake_summary: None,
|
||||
};
|
||||
let ready = backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(implementation.id.clone()),
|
||||
request.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop"));
|
||||
let replay = backend
|
||||
.mark_ready(TicketIdOrSlug::Id(implementation.id.clone()), request)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
replay
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.attributes.contains_key("operation_key"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(matches!(
|
||||
backend.queue_ready(
|
||||
TicketIdOrSlug::Id(implementation.id.clone()),
|
||||
"orchestrator",
|
||||
),
|
||||
Err(TicketError::BlockingRelations(_))
|
||||
));
|
||||
let after_rejection = backend
|
||||
.show(TicketIdOrSlug::Id(implementation.id.clone()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after_rejection.meta.workflow_state,
|
||||
TicketWorkflowState::Ready
|
||||
);
|
||||
assert!(!after_rejection.events.iter().any(|event| {
|
||||
event.from.as_deref() == Some("ready") && event.to.as_deref() == Some("queued")
|
||||
}));
|
||||
backend
|
||||
.close(
|
||||
TicketIdOrSlug::Id(dependency.id),
|
||||
MarkdownText::new("resolved"),
|
||||
)
|
||||
.unwrap();
|
||||
backend
|
||||
.queue_ready(
|
||||
TicketIdOrSlug::Id(implementation.id.clone()),
|
||||
"orchestrator",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
backend
|
||||
.show(TicketIdOrSlug::Id(implementation.id))
|
||||
.unwrap()
|
||||
.meta
|
||||
.workflow_state,
|
||||
TicketWorkflowState::Queued
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_backend_persists_and_edits_ticket_target() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -7406,25 +6917,24 @@ state: planning
|
||||
fn add_event_status_and_close_preserve_local_layout() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let mut input = NewTicket::new("Flow Ticket");
|
||||
input.repository_id = Some("main".to_owned());
|
||||
let ticket = backend.create(input).unwrap();
|
||||
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
|
||||
backend
|
||||
.add_event(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
||||
)
|
||||
.unwrap();
|
||||
let mut summary = TicketIntakeSummary::new("Ready for queue.");
|
||||
summary.author = Some("test".to_string());
|
||||
let mut change = TicketStateChange::new(
|
||||
"planning",
|
||||
"ready",
|
||||
"ready_for_queue",
|
||||
MarkdownText::new("Ready for queue."),
|
||||
);
|
||||
change.author = Some("test".to_string());
|
||||
backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: "test-flow-ready".to_owned(),
|
||||
reason: Some("ready_for_queue".to_owned()),
|
||||
author: Some("test".to_owned()),
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
|
||||
.unwrap();
|
||||
let current_item = tmp.path().join("tickets").join(&ticket.id).join("item.md");
|
||||
assert!(current_item.exists());
|
||||
@@ -7617,8 +7127,6 @@ state: planning
|
||||
|
||||
let mut ready_input = NewTicket::new("Ready Workflow");
|
||||
ready_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
ready_input.repository_id = Some("main".to_owned());
|
||||
ready_input.ref_selector = Some("develop".to_owned());
|
||||
let ready = backend.create(ready_input).unwrap();
|
||||
backend
|
||||
.queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel")
|
||||
@@ -7648,7 +7156,7 @@ state: planning
|
||||
|
||||
assert!(matches!(
|
||||
backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"),
|
||||
Err(TicketError::StaleWorkflowState { .. })
|
||||
Err(TicketError::Conflict(_))
|
||||
));
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
@@ -7684,60 +7192,41 @@ state: planning
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_ready_resolves_target_and_is_idempotent() {
|
||||
fn mark_intake_ready_records_summary_and_state_change() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let mut input = NewTicket::new("Planning Ready");
|
||||
input.repository_id = Some("main".to_owned());
|
||||
let ticket = backend.create(input).unwrap();
|
||||
let request = TicketMarkReady {
|
||||
operation_key: "ready-op-1".to_owned(),
|
||||
reason: Some("accepted".to_owned()),
|
||||
author: Some("intake".to_owned()),
|
||||
intake_summary: None,
|
||||
};
|
||||
let ticket = backend.create(NewTicket::new("Planning Ready")).unwrap();
|
||||
let mut summary = TicketIntakeSummary::new("Concise accepted requirements.");
|
||||
summary.author = Some("intake".to_string());
|
||||
let mut change =
|
||||
TicketStateChange::new("planning", "ready", "accepted", "Ticket is ready to queue.");
|
||||
change.author = Some("intake".to_string());
|
||||
|
||||
let first = backend
|
||||
.mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request.clone())
|
||||
backend
|
||||
.mark_intake_ready(TicketIdOrSlug::Id(ticket.id.clone()), summary, change)
|
||||
.unwrap();
|
||||
let second = backend
|
||||
.mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request)
|
||||
.unwrap();
|
||||
assert_eq!(first.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert_eq!(first.meta.repository_id.as_deref(), Some("main"));
|
||||
assert_eq!(first.meta.ref_selector.as_deref(), Some("develop"));
|
||||
assert_eq!(first.events, second.events);
|
||||
assert_eq!(
|
||||
first
|
||||
let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert!(
|
||||
record
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.from.as_deref() == Some("planning")
|
||||
&& event.to.as_deref() == Some("ready")
|
||||
})
|
||||
.count(),
|
||||
1
|
||||
.any(|event| event.kind == TicketEventKind::IntakeSummary)
|
||||
);
|
||||
assert!(matches!(
|
||||
backend.mark_ready(
|
||||
TicketIdOrSlug::Id(ticket.id),
|
||||
TicketMarkReady {
|
||||
operation_key: "ready-op-1".to_owned(),
|
||||
reason: Some("different".to_owned()),
|
||||
author: Some("intake".to_owned()),
|
||||
intake_summary: None,
|
||||
},
|
||||
),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
assert!(record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.state_field.as_deref() == Some("state")
|
||||
&& event.from.as_deref() == Some("planning")
|
||||
&& event.to.as_deref() == Some("ready")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_sets_state_closed() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let input = NewTicket::new("Close Workflow");
|
||||
let mut input = NewTicket::new("Close Workflow");
|
||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
let ticket = backend.create(input).unwrap();
|
||||
|
||||
backend
|
||||
|
||||
+61
-185
@@ -16,9 +16,8 @@ use crate::{
|
||||
NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord,
|
||||
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
|
||||
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
|
||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState,
|
||||
default_author,
|
||||
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind,
|
||||
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||
@@ -43,7 +42,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
"TicketClose",
|
||||
@@ -69,7 +68,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
|
||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||
|
||||
pub const TICKET_TOOL_NAMES: [&str; 20] = [
|
||||
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"QueryTicket",
|
||||
@@ -78,7 +77,6 @@ pub const TICKET_TOOL_NAMES: [&str; 20] = [
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -101,14 +99,13 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -135,12 +132,9 @@ const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Mark
|
||||
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
|
||||
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||
"Append a typed Ticket implementation_report event. `body` is Markdown.";
|
||||
const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \
|
||||
The backend atomically validates and normalizes the persisted repository/ref target, records one typed \
|
||||
state_changed event, and transitions planning -> ready. `reason` is optional.";
|
||||
const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \
|
||||
The backend applies the same target validation and lock as TicketMarkReady and commits the summary, \
|
||||
state_changed event, effective target, and planning -> ready transition atomically.";
|
||||
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
|
||||
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
||||
for `state`, and transitions state to `ready`.";
|
||||
const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \
|
||||
Ticket backend. The backend performs the gated ready -> queued transition, records queued_by/queued_at, \
|
||||
and rejects unresolved blocking relations.";
|
||||
@@ -180,7 +174,6 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||
"TicketPlan" => PLAN_DESCRIPTION,
|
||||
"TicketDecision" => DECISION_DESCRIPTION,
|
||||
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
||||
"TicketMarkReady" => MARK_READY_DESCRIPTION,
|
||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||
@@ -312,8 +305,13 @@ impl TicketBackend for TicketToolBackend {
|
||||
self.backend.set_workflow_state(id, change)
|
||||
}
|
||||
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult<Ticket> {
|
||||
self.backend.mark_ready(id, request)
|
||||
fn mark_intake_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
) -> TicketResult<()> {
|
||||
self.backend.mark_intake_ready(id, summary, change)
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
||||
@@ -560,24 +558,18 @@ struct TicketThreadEventParams {
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketMarkReadyParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Optional reason attached to the state_changed event.
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary appended before the ready transition.
|
||||
/// Concise bounded intake summary to append as a typed intake_summary event.
|
||||
intake_summary: String,
|
||||
/// Optional reason attached to the state_changed event.
|
||||
/// Reason attached to the state_changed event. Defaults to `planning_ready`.
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
/// Optional state_changed body. If omitted, a concise default is used.
|
||||
#[serde(default)]
|
||||
state_change_body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -844,11 +836,6 @@ struct TicketImplementationReportTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketMarkReadyTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketIntakeReadyTool {
|
||||
backend: TicketToolBackend,
|
||||
@@ -912,23 +899,6 @@ impl Tool for TicketCreateTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketCreateParams = parse_input("TicketCreate", input_json)?;
|
||||
if params
|
||||
.state
|
||||
.is_some_and(|state| !matches!(state.into_state(), TicketWorkflowState::Planning))
|
||||
{
|
||||
return Err(backend_error(
|
||||
"TicketCreate",
|
||||
TicketError::InvalidWorkflowTransition {
|
||||
from: "creation".to_owned(),
|
||||
to: params
|
||||
.state
|
||||
.expect("checked non-planning state")
|
||||
.into_state()
|
||||
.as_str()
|
||||
.to_owned(),
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut input = NewTicket::new(params.title);
|
||||
if let Some(body) = params.body {
|
||||
input.body = MarkdownText::new(body);
|
||||
@@ -1144,68 +1114,41 @@ impl_ticket_thread_event_tool!(
|
||||
TicketEventKind::ImplementationReport
|
||||
);
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketMarkReadyTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketMarkReadyParams = parse_input("TicketMarkReady", input_json)?;
|
||||
let ticket = self
|
||||
.backend
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ticket-mark-ready:{}", ctx.call_id),
|
||||
reason: params.reason,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.map_err(|error| backend_error("TicketMarkReady", error))?;
|
||||
Ok(json_output(
|
||||
format!("Marked ticket {} state ready", params.ticket),
|
||||
json!({
|
||||
"ticket": ticket.meta.id,
|
||||
"state": ticket.meta.workflow_state.as_str(),
|
||||
"repository_id": ticket.meta.repository_id,
|
||||
"ref_selector": ticket.meta.ref_selector,
|
||||
"ok": true
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketIntakeReadyTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: llm_engine::tool::ToolExecutionContext,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketIntakeReadyParams = parse_input("TicketIntakeReady", input_json)?;
|
||||
let ticket = self
|
||||
.backend
|
||||
.mark_ready(
|
||||
let from = TicketWorkflowState::Planning;
|
||||
let reason = params
|
||||
.reason
|
||||
.unwrap_or_else(|| "planning_ready".to_string());
|
||||
let body = params.state_change_body.unwrap_or_else(|| {
|
||||
self.backend
|
||||
.default_intake_ready_state_change_body(from.as_str())
|
||||
});
|
||||
let mut summary = TicketIntakeSummary::new(params.intake_summary);
|
||||
summary.author = None;
|
||||
let mut change = TicketStateChange::new(
|
||||
from.as_str(),
|
||||
TicketWorkflowState::Ready.as_str(),
|
||||
reason,
|
||||
body,
|
||||
);
|
||||
change.author = None;
|
||||
self.backend
|
||||
.mark_intake_ready(
|
||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||
TicketMarkReady {
|
||||
operation_key: format!("ticket-intake-ready:{}", ctx.call_id),
|
||||
reason: params.reason,
|
||||
author: None,
|
||||
intake_summary: Some(TicketIntakeSummary::new(params.intake_summary)),
|
||||
},
|
||||
summary,
|
||||
change,
|
||||
)
|
||||
.map_err(|error| backend_error("TicketIntakeReady", error))?;
|
||||
Ok(json_output(
|
||||
format!("Marked ticket {} state ready after intake", params.ticket),
|
||||
json!({
|
||||
"ticket": ticket.meta.id,
|
||||
"state": ticket.meta.workflow_state.as_str(),
|
||||
"repository_id": ticket.meta.repository_id,
|
||||
"ref_selector": ticket.meta.ref_selector,
|
||||
"ok": true
|
||||
}),
|
||||
format!("Marked ticket {} state ready", params.ticket),
|
||||
json!({ "ticket": params.ticket, "state": "ready", "ok": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1783,7 +1726,6 @@ fn input_schema(name: &str) -> Value {
|
||||
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
|
||||
}
|
||||
"TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)),
|
||||
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||
"TicketWorkflowState" => {
|
||||
@@ -1832,7 +1774,6 @@ impl_from_backend!(TicketCommentTool);
|
||||
impl_from_backend!(TicketPlanTool);
|
||||
impl_from_backend!(TicketDecisionTool);
|
||||
impl_from_backend!(TicketImplementationReportTool);
|
||||
impl_from_backend!(TicketMarkReadyTool);
|
||||
impl_from_backend!(TicketIntakeReadyTool);
|
||||
impl_from_backend!(TicketQueueTool);
|
||||
impl_from_backend!(TicketWorkflowStateTool);
|
||||
@@ -1860,7 +1801,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||
"TicketImplementationReport",
|
||||
backend.clone(),
|
||||
),
|
||||
tool_definition::<TicketMarkReadyTool>("TicketMarkReady", backend.clone()),
|
||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
||||
@@ -1886,26 +1826,8 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestTargetAuthority;
|
||||
|
||||
impl crate::TicketTargetAuthority for TestTargetAuthority {
|
||||
fn resolve_target(
|
||||
&self,
|
||||
_workspace_id: &str,
|
||||
repository_id: Option<&str>,
|
||||
ref_selector: Option<&str>,
|
||||
) -> crate::Result<crate::ResolvedTicketTarget> {
|
||||
Ok(crate::ResolvedTicketTarget {
|
||||
repository_id: repository_id.unwrap_or("main").to_owned(),
|
||||
ref_selector: ref_selector.unwrap_or("develop").to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn backend(temp: &TempDir) -> LocalTicketBackend {
|
||||
LocalTicketBackend::new(temp.path().join("tickets"))
|
||||
.with_target_authority(Arc::new(TestTargetAuthority))
|
||||
}
|
||||
|
||||
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
|
||||
@@ -1955,7 +1877,6 @@ mod tests {
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -2539,17 +2460,16 @@ mod tests {
|
||||
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let mut input = NewTicket::new("Workflow Tool");
|
||||
input.repository_id = Some("main".to_owned());
|
||||
let created = backend.create(input).unwrap();
|
||||
let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
|
||||
let created = backend.create(NewTicket::new("Workflow Tool")).unwrap();
|
||||
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
|
||||
let workflow = tool_by_name(backend.clone(), "TicketWorkflowState");
|
||||
|
||||
intake_ready
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.id.clone(),
|
||||
"reason": "requirements accepted"
|
||||
"intake_summary": "Requirements accepted; implementation can be queued.",
|
||||
"author": "intake-worker"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
@@ -2592,12 +2512,12 @@ mod tests {
|
||||
|
||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done);
|
||||
assert!(record.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::StateChanged
|
||||
&& event.from.as_deref() == Some("planning")
|
||||
&& event.to.as_deref() == Some("ready")
|
||||
&& event.attributes.contains_key("request_fingerprint")
|
||||
}));
|
||||
assert!(
|
||||
record
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::IntakeSummary)
|
||||
);
|
||||
let transitions = record
|
||||
.events
|
||||
.iter()
|
||||
@@ -2618,38 +2538,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_intake_ready_records_summary_with_validated_target() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let mut input = NewTicket::new("Intake Workflow");
|
||||
input.repository_id = Some("main".to_owned());
|
||||
let created = backend.create(input).unwrap();
|
||||
tool_by_name(backend.clone(), "TicketIntakeReady")
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.id.clone(),
|
||||
"intake_summary": "Requirements and target are accepted.",
|
||||
"reason": "intake_complete"
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert_eq!(record.meta.ref_selector.as_deref(), Some("develop"));
|
||||
assert_eq!(
|
||||
record
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == TicketEventKind::IntakeSummary)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -2773,11 +2661,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
ready_error
|
||||
.to_string()
|
||||
.contains("invalid ticket workflow transition")
|
||||
);
|
||||
assert!(ready_error.to_string().contains("not allowed"));
|
||||
|
||||
let mut done_input = NewTicket::new("Backward Bypass");
|
||||
done_input.workflow_state = Some(TicketWorkflowState::Done);
|
||||
@@ -2796,11 +2680,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
backward_error
|
||||
.to_string()
|
||||
.contains("invalid ticket workflow transition")
|
||||
);
|
||||
assert!(backward_error.to_string().contains("not allowed"));
|
||||
|
||||
let mut queued_input = NewTicket::new("Skip Bypass");
|
||||
queued_input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
@@ -2819,21 +2699,17 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
skip_error
|
||||
.to_string()
|
||||
.contains("invalid ticket workflow transition")
|
||||
);
|
||||
assert!(skip_error.to_string().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_mark_ready_tool_rejects_non_planning_ticket() {
|
||||
async fn ticket_intake_ready_tool_rejects_non_planning_ticket() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let mut input = NewTicket::new("Already Ready");
|
||||
input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||
let created = backend.create(input).unwrap();
|
||||
let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady");
|
||||
let intake_ready = tool_by_name(backend.clone(), "TicketIntakeReady");
|
||||
|
||||
let error = intake_ready
|
||||
.execute(
|
||||
@@ -2847,7 +2723,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("stale ticket workflow state"));
|
||||
assert!(error.to_string().contains("state changed concurrently"));
|
||||
let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||
assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert!(!record.events.iter().any(|event| {
|
||||
@@ -2991,7 +2867,7 @@ mod tests {
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketMarkReady",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketRelationRecord",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
|
||||
@@ -769,21 +769,6 @@ where
|
||||
),
|
||||
);
|
||||
}
|
||||
if feature_config.merge_request.any() {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Merge Request tools require Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::merge_request::MergeRequestFeature::new(
|
||||
workspace_client,
|
||||
feature_config.merge_request,
|
||||
),
|
||||
);
|
||||
}
|
||||
if feature_config.manage_workdir.enabled {
|
||||
// Workdir lifecycle is Workspace control-plane authority. The Worker
|
||||
// receives only the injected WorkspaceClient and never Runtime URLs,
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
|
||||
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ToolContribution,
|
||||
ToolDeclaration, ToolDefinition,
|
||||
};
|
||||
use crate::feature::ToolDefinition;
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use manifest::MergeRequestFeatureConfig;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const FEATURE_ID: &str = "merge_request";
|
||||
const FEATURE_NAME: &str = "Merge Request tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Operation-specific Merge Request workflow tools over Workspace authority.";
|
||||
const FEATURE_INSTRUCTION_ID: &str = "merge_request.workflow";
|
||||
pub const FEATURE_PROMPT_REF: &str = "common.merge_request";
|
||||
|
||||
fn workflow_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin(FEATURE_INSTRUCTION_ID),
|
||||
FEATURE_PROMPT_REF,
|
||||
"Operation-specific Merge Request workflow guidance",
|
||||
)
|
||||
.expect("static Merge Request workflow instruction declaration is valid")
|
||||
}
|
||||
|
||||
const ALL_KINDS: [Kind; 5] = [
|
||||
Kind::Show,
|
||||
Kind::Open,
|
||||
Kind::Review,
|
||||
Kind::Readiness,
|
||||
Kind::Complete,
|
||||
pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestOpen",
|
||||
"MergeRequestComplete",
|
||||
];
|
||||
|
||||
pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit";
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
Show,
|
||||
@@ -111,23 +89,13 @@ struct ReviewFindingInput {
|
||||
body: String,
|
||||
}
|
||||
impl Kind {
|
||||
fn enabled(self, config: MergeRequestFeatureConfig) -> bool {
|
||||
match self {
|
||||
Self::Show => config.show,
|
||||
Self::Open => config.open,
|
||||
Self::Review => config.review,
|
||||
Self::Readiness => config.readiness_check,
|
||||
Self::Complete => config.complete,
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Show => "MergeRequestShow",
|
||||
Self::Readiness => "MergeRequestReadinessCheck",
|
||||
Self::Open => "MergeRequestOpen",
|
||||
Self::Complete => "MergeRequestComplete",
|
||||
Self::Review => "MergeRequestReview",
|
||||
Self::Review => "MergeRequestReviewSubmit",
|
||||
}
|
||||
}
|
||||
fn schema(self) -> serde_json::Value {
|
||||
@@ -250,53 +218,24 @@ fn definition(client: Arc<dyn WorkspaceClient>, kind: Kind) -> ToolDefinition {
|
||||
)
|
||||
})
|
||||
}
|
||||
pub struct MergeRequestFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
config: MergeRequestFeatureConfig,
|
||||
pub fn common_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||
vec![
|
||||
definition(c.clone(), Kind::Show),
|
||||
definition(c.clone(), Kind::Readiness),
|
||||
definition(c.clone(), Kind::Open),
|
||||
definition(c, Kind::Complete),
|
||||
]
|
||||
}
|
||||
|
||||
impl MergeRequestFeature {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>, config: MergeRequestFeatureConfig) -> Self {
|
||||
Self { client, config }
|
||||
}
|
||||
|
||||
fn kinds(&self) -> impl Iterator<Item = Kind> + '_ {
|
||||
ALL_KINDS
|
||||
.into_iter()
|
||||
.filter(|kind| kind.enabled(self.config))
|
||||
pub fn reviewer_tools(c: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||
if c.reviewer_context().is_some() {
|
||||
vec![
|
||||
definition(c.clone(), Kind::Show),
|
||||
definition(c, Kind::Review),
|
||||
]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for MergeRequestFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||
.with_description(FEATURE_DESCRIPTION);
|
||||
if self.config.any() {
|
||||
descriptor = descriptor.with_instruction(workflow_instruction());
|
||||
}
|
||||
for kind in self.kinds() {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||
kind.name(),
|
||||
description(kind.name()).unwrap_or("Merge Request operation."),
|
||||
));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
|
||||
fn install(&self, ctx: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
if self.config.any() {
|
||||
ctx.instructions()
|
||||
.register(FeatureInstructionContribution::new(workflow_instruction()))?;
|
||||
}
|
||||
let mut tools = ctx.tools();
|
||||
for kind in self.kinds() {
|
||||
let definition = definition(self.client.clone(), kind);
|
||||
tools.register(ToolContribution::new(kind.name(), definition))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(n: &str) -> Option<&'static str> {
|
||||
match n {
|
||||
"MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."),
|
||||
@@ -309,7 +248,7 @@ pub fn description(n: &str) -> Option<&'static str> {
|
||||
"MergeRequestComplete" => {
|
||||
Some("Complete using an approved review event and final target-ref evidence.")
|
||||
}
|
||||
"MergeRequestReview" => {
|
||||
"MergeRequestReviewSubmit" => {
|
||||
Some("Submit the injected Reviewer capability result for its captured subject ref.")
|
||||
}
|
||||
_ => None,
|
||||
@@ -318,72 +257,6 @@ pub fn description(n: &str) -> Option<&'static str> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::hook::HookRegistryBuilder;
|
||||
use crate::worker::TestWorkspaceHttpClient;
|
||||
|
||||
fn install(config: MergeRequestFeatureConfig) -> (Vec<String>, Vec<String>) {
|
||||
let client: Arc<dyn WorkspaceClient> =
|
||||
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(MergeRequestFeature::new(client, config))
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
(
|
||||
report.installed_tool_names(),
|
||||
report
|
||||
.installed_instruction_contributions()
|
||||
.into_iter()
|
||||
.map(|instruction| instruction.prompt_ref)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_names(config: MergeRequestFeatureConfig) -> Vec<String> {
|
||||
install(config).0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_define_the_exact_registered_tool_surface() {
|
||||
let coder = MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
open: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(tool_names(coder), ["MergeRequestShow", "MergeRequestOpen"]);
|
||||
|
||||
let reviewer = MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
review: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
tool_names(reviewer),
|
||||
["MergeRequestShow", "MergeRequestReview"]
|
||||
);
|
||||
|
||||
let orchestrator = MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
readiness_check: true,
|
||||
complete: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
tool_names(orchestrator),
|
||||
[
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestComplete"
|
||||
]
|
||||
);
|
||||
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
|
||||
let unspecified = install(MergeRequestFeatureConfig::default());
|
||||
assert!(unspecified.0.is_empty());
|
||||
assert!(unspecified.1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_hide_revision_and_commit_authority() {
|
||||
let schemas = [
|
||||
@@ -403,5 +276,6 @@ mod tests {
|
||||
assert!(!j.contains(banned), "{banned} in {j}")
|
||||
}
|
||||
}
|
||||
assert!(!MERGE_REQUEST_COMMON_TOOL_NAMES.contains(&"MergeRequestRequestReview"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use ticket::{
|
||||
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
||||
};
|
||||
|
||||
use super::merge_request;
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
|
||||
@@ -53,7 +54,7 @@ impl WorkspaceTicketReadKind {
|
||||
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
|
||||
}
|
||||
Self::Show => {
|
||||
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, implementation reports, and current Merge Request review evidence."
|
||||
"Show one authoritative Workspace Ticket with its item revision, paged thread, links, historical implementation reports, and current Merge Request readiness evidence."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,8 +83,6 @@ enum WorkspaceTicketStateFilter {
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketEvidenceFilter {
|
||||
ImplementationReport,
|
||||
ImplementationReportAfterRescope,
|
||||
MergeRequest,
|
||||
Commit,
|
||||
ApprovedReview,
|
||||
@@ -103,8 +102,6 @@ enum WorkspaceTicketReviewFilter {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WorkspaceTicketAttentionFilter {
|
||||
DoneNotClosed,
|
||||
ImplementationReportNotClosed,
|
||||
ReportAfterRescope,
|
||||
UnresolvedReview,
|
||||
MissingCommit,
|
||||
Blocked,
|
||||
@@ -146,15 +143,15 @@ struct WorkspaceQueryTicketInput {
|
||||
/// Exact typed event kinds that must occur in the bounded thread window.
|
||||
#[serde(default)]
|
||||
event_kinds: Vec<String>,
|
||||
/// Required evidence kinds: implementation_report, implementation_report_after_rescope,
|
||||
/// merge_request, commit, or approved_review.
|
||||
/// Required evidence kinds: merge_request, commit, or approved_review.
|
||||
#[serde(default)]
|
||||
evidence: Vec<WorkspaceTicketEvidenceFilter>,
|
||||
/// Current authoritative Merge Request review status: none, pending, approved,
|
||||
/// request_changes, or unresolved_changes.
|
||||
review_status: Option<WorkspaceTicketReviewFilter>,
|
||||
/// Attention filters include done_not_closed, implementation_report_not_closed,
|
||||
/// report_after_rescope, unresolved_review, missing_commit, blocked, and unblocked.
|
||||
/// Attention filters include done_not_closed, unresolved_review, missing_commit,
|
||||
/// blocked, unblocked, ready, awaiting_review, unresolved_changes,
|
||||
/// stale_after_rescope, and missing_evidence.
|
||||
#[serde(default)]
|
||||
attention: Vec<WorkspaceTicketAttentionFilter>,
|
||||
related_ticket_id: Option<String>,
|
||||
@@ -380,7 +377,6 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketMarkReady",
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
@@ -398,7 +394,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"TicketComment",
|
||||
"TicketMarkReady",
|
||||
"TicketQueue",
|
||||
"TicketClose",
|
||||
"TicketRelationRecord",
|
||||
@@ -589,6 +584,22 @@ impl FeatureModule for TicketFeature {
|
||||
ticket_tool_description(name, self.record_language.as_deref()),
|
||||
));
|
||||
}
|
||||
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
|
||||
let names: Vec<&str> = if client.reviewer_context().is_some() {
|
||||
vec![
|
||||
"MergeRequestShow",
|
||||
merge_request::MERGE_REQUEST_REVIEW_TOOL_NAME,
|
||||
]
|
||||
} else {
|
||||
merge_request::MERGE_REQUEST_COMMON_TOOL_NAMES.to_vec()
|
||||
};
|
||||
for name in names {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||
name,
|
||||
merge_request::description(name).unwrap_or("Merge Request operation."),
|
||||
));
|
||||
}
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
|
||||
@@ -646,6 +657,17 @@ impl FeatureModule for TicketFeature {
|
||||
};
|
||||
tools.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
|
||||
let definitions = if client.reviewer_context().is_some() {
|
||||
merge_request::reviewer_tools(client.clone())
|
||||
} else {
|
||||
merge_request::common_tools(client.clone())
|
||||
};
|
||||
for definition in definitions {
|
||||
let (meta, _) = definition();
|
||||
tools.register(ToolContribution::new(meta.name.clone(), definition))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -868,15 +890,16 @@ impl WorkspaceHttpTicketBackend {
|
||||
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::MarkReady { id, request } => Self::request(
|
||||
TicketBackendOperation::MarkIntakeReady {
|
||||
id,
|
||||
summary,
|
||||
change,
|
||||
} => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/workflow/mark-ready", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(request).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket mark-ready request: {error}"))
|
||||
})?),
|
||||
)
|
||||
.map(TicketBackendOperationResult::Ticket),
|
||||
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
|
||||
Some(serde_json::json!({ "summary": summary, "change": change })),
|
||||
),
|
||||
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
@@ -1092,15 +1115,22 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ready(
|
||||
fn mark_intake_ready(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketMarkReady,
|
||||
) -> TicketResult<Ticket> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::MarkReady { id, request }),
|
||||
TicketBackendOperationResult::Ticket
|
||||
)
|
||||
summary: TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
) -> TicketResult<()> {
|
||||
match self.invoke(TicketBackendOperation::MarkIntakeReady {
|
||||
id,
|
||||
summary,
|
||||
change,
|
||||
})? {
|
||||
TicketBackendOperationResult::Unit => Ok(()),
|
||||
other => Err(TicketError::Conflict(format!(
|
||||
"unexpected ticket backend response: {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> TicketResult<()> {
|
||||
@@ -1269,7 +1299,7 @@ mod tests {
|
||||
assert_eq!(show.name, "ShowTicket");
|
||||
assert!(show.input_schema["properties"]["event_limit"].is_object());
|
||||
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
|
||||
assert_eq!(tool_names.len(), 10);
|
||||
assert_eq!(tool_names.len(), 9);
|
||||
assert!(
|
||||
tool_names.len() < 13,
|
||||
"authoring catalog must stay below the prior broad catalog"
|
||||
@@ -1486,7 +1516,6 @@ language = "Japanese"
|
||||
assert!(installed.iter().any(|tool| *tool == "TicketCreate"));
|
||||
assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
|
||||
assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
|
||||
assert!(installed.iter().any(|tool| *tool == "TicketMarkReady"));
|
||||
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
|
||||
assert!(!installed.iter().any(|tool| *tool == "TicketWorkflowState"));
|
||||
assert!(
|
||||
|
||||
@@ -527,39 +527,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_request_instruction_matches_the_exposed_operations() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
let coder = catalog
|
||||
.render_name(
|
||||
"common.merge_request",
|
||||
Value::from_serialize(serde_json::json!({
|
||||
"tools": ["MergeRequestShow", "MergeRequestOpen"]
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(coder.contains("Reread the current Merge Request"));
|
||||
assert!(coder.contains("Open the Merge Request only after"));
|
||||
assert!(!coder.contains("Submit the authoritative verdict"));
|
||||
assert!(!coder.contains("Complete integration only after"));
|
||||
|
||||
let orchestrator = catalog
|
||||
.render_name(
|
||||
"common.merge_request",
|
||||
Value::from_serialize(serde_json::json!({
|
||||
"tools": [
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestComplete"
|
||||
]
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(orchestrator.contains("Use `MergeRequestReadinessCheck`"));
|
||||
assert!(orchestrator.contains("Complete integration only after"));
|
||||
assert!(!orchestrator.contains("Open the Merge Request only after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_capable_roles_classify_commits_by_change_type() {
|
||||
let catalog = PromptCatalog::builtins_only().unwrap();
|
||||
@@ -665,6 +632,14 @@ mod tests {
|
||||
"Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers"
|
||||
));
|
||||
assert!(prompt.contains("never compensate by creating an independent Reviewer Worker"));
|
||||
assert!(
|
||||
prompt.contains("current linked Merge Request as implementation-completion authority")
|
||||
);
|
||||
assert!(prompt.contains("do not require an `implementation_report`"));
|
||||
assert!(prompt.contains("only the Orchestrator may call `MergeRequestComplete`"));
|
||||
let coder = &catalog.projection.templates["role.coder"];
|
||||
assert!(coder.contains("hand off to the Orchestrator"));
|
||||
assert!(coder.contains("Do not call `MergeRequestComplete`"));
|
||||
assert!(!prompt.contains("sibling Coder/Reviewer Workers"));
|
||||
}
|
||||
|
||||
|
||||
@@ -319,7 +319,6 @@ fn append_trailing_section(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::feature::FeatureInstructionId;
|
||||
use chrono::TimeZone;
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule};
|
||||
use tempfile::TempDir;
|
||||
@@ -401,48 +400,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_request_role_prompts_match_operation_specific_tool_surfaces() {
|
||||
fn render(role: &str, tools: &[&str]) -> String {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let scope = build_scope(tmp.path());
|
||||
let prompts = PromptCatalog::builtins_only().unwrap();
|
||||
let template =
|
||||
SystemPromptTemplate::parse(role, PromptCatalogSource::builtins_only()).unwrap();
|
||||
let instruction = FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin("merge_request.workflow"),
|
||||
"common.merge_request",
|
||||
"Merge Request workflow",
|
||||
)
|
||||
.unwrap();
|
||||
let mut ctx = context(tmp.path(), &scope, &prompts);
|
||||
ctx.tool_names = tools.iter().map(|name| (*name).to_string()).collect();
|
||||
ctx.feature_instructions = std::slice::from_ref(&instruction);
|
||||
template.render(&ctx).unwrap()
|
||||
}
|
||||
|
||||
let coder = render("role.coder", &["MergeRequestShow", "MergeRequestOpen"]);
|
||||
assert!(coder.contains("Open the Merge Request only after"));
|
||||
assert!(!coder.contains("Complete integration only after"));
|
||||
assert!(coder.contains("Do not call `MergeRequestComplete`"));
|
||||
|
||||
let reviewer = render("role.reviewer", &["MergeRequestShow", "MergeRequestReview"]);
|
||||
assert!(reviewer.contains("Submit the authoritative verdict"));
|
||||
assert!(!reviewer.contains("Open the Merge Request only after"));
|
||||
|
||||
let orchestrator = render(
|
||||
"role.orchestrator",
|
||||
&[
|
||||
"MergeRequestShow",
|
||||
"MergeRequestReadinessCheck",
|
||||
"MergeRequestComplete",
|
||||
],
|
||||
);
|
||||
assert!(orchestrator.contains("Use `MergeRequestReadinessCheck`"));
|
||||
assert!(orchestrator.contains("Complete integration only after"));
|
||||
assert!(!orchestrator.contains("Submit the authoritative verdict"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_templates_are_selected_without_filesystem_resolution() {
|
||||
let loader = PromptCatalogSource::builtins_only();
|
||||
|
||||
@@ -5890,13 +5890,6 @@ model_id = "claude-sonnet-4-20250514"
|
||||
[engine]
|
||||
instruction = "saved"
|
||||
|
||||
[feature.merge_request]
|
||||
show = true
|
||||
open = false
|
||||
review = true
|
||||
readiness_check = false
|
||||
complete = false
|
||||
|
||||
[[scope.allow]]
|
||||
target = "/snapshot/workspace"
|
||||
permission = "read"
|
||||
@@ -5919,13 +5912,6 @@ model_id = "claude-sonnet-4-20250514"
|
||||
[engine]
|
||||
instruction = "current"
|
||||
|
||||
[feature.merge_request]
|
||||
show = true
|
||||
open = true
|
||||
review = true
|
||||
readiness_check = true
|
||||
complete = true
|
||||
|
||||
[[scope.allow]]
|
||||
target = "/current/workspace"
|
||||
permission = "write"
|
||||
@@ -5945,14 +5931,6 @@ permission = "write"
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(restored.engine.instruction, "saved");
|
||||
assert_eq!(
|
||||
restored.feature.merge_request,
|
||||
manifest::MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
review: true,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(restored.scope.allow.len(), 1);
|
||||
assert_eq!(
|
||||
restored.scope.allow[0].target,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use chrono::Utc;
|
||||
use chrono::{DateTime, Utc};
|
||||
use merge_request::{
|
||||
MergeRequest, MergeRequestError, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent,
|
||||
ReviewDecision,
|
||||
@@ -167,12 +167,25 @@ impl merge_request::RepositorySource for AuthorityMergeRequestSource {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TicketMergeRevisionSource: Send + Sync {
|
||||
fn resolve_subject_ref(&self, repository_id: &str, selector: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
struct UnresolvedTicketMergeRevisionSource;
|
||||
|
||||
impl TicketMergeRevisionSource for UnresolvedTicketMergeRevisionSource {
|
||||
fn resolve_subject_ref(&self, _repository_id: &str, _selector: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteWorkspaceAuthority {
|
||||
workspace_id: String,
|
||||
store: SqliteWorkspaceStore,
|
||||
ticket_backend: SqliteTicketBackend,
|
||||
merge_request_store: Arc<MergeRequestStore>,
|
||||
merge_revision_source: Arc<dyn TicketMergeRevisionSource>,
|
||||
}
|
||||
|
||||
impl SqliteWorkspaceAuthority {
|
||||
@@ -198,9 +211,18 @@ impl SqliteWorkspaceAuthority {
|
||||
)
|
||||
.map_err(|error| Error::Store(error.to_string()))?,
|
||||
),
|
||||
merge_revision_source: Arc::new(UnresolvedTicketMergeRevisionSource),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_merge_revision_source(
|
||||
mut self,
|
||||
merge_revision_source: Arc<dyn TicketMergeRevisionSource>,
|
||||
) -> Self {
|
||||
self.merge_revision_source = merge_revision_source;
|
||||
self
|
||||
}
|
||||
|
||||
fn objective_record(&self, id: &str) -> Result<ObjectiveRecord> {
|
||||
self.store
|
||||
.get_objective(&self.workspace_id, id)?
|
||||
@@ -367,11 +389,21 @@ impl SqliteWorkspaceAuthority {
|
||||
worker_id: assignment.worker.worker_id,
|
||||
});
|
||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||
Ok(request) => Some(merge_request_summary(request)),
|
||||
Ok(request) => {
|
||||
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
||||
self.merge_revision_source
|
||||
.resolve_subject_ref(&request.repository_id, selector)
|
||||
});
|
||||
Some(merge_request_summary(request, current_subject_ref))
|
||||
}
|
||||
Err(MergeRequestError::NotFound) => None,
|
||||
Err(error) => return Err(Error::Store(error.to_string())),
|
||||
};
|
||||
let evidence = ticket_evidence_summary(&ticket.events, merge_request.as_ref());
|
||||
let evidence = ticket_evidence_summary(
|
||||
ticket.meta.repository_id.as_deref(),
|
||||
&ticket.events,
|
||||
merge_request.as_ref(),
|
||||
);
|
||||
let item_revision = ticket
|
||||
.events
|
||||
.iter()
|
||||
@@ -1023,17 +1055,45 @@ fn ticket_evidence_event(sequence: usize, event: &TicketEvent) -> TicketEvidence
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_request_summary(request: MergeRequest) -> TicketMergeRequestSummary {
|
||||
let review_subject_ref = request.thread.iter().rev().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(review) => Some(review.subject_ref.as_str()),
|
||||
fn merge_request_summary(
|
||||
request: MergeRequest,
|
||||
current_subject_ref: Option<String>,
|
||||
) -> TicketMergeRequestSummary {
|
||||
let latest_review_request = request.thread.iter().rev().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(review) => Some(review),
|
||||
_ => None,
|
||||
});
|
||||
let current_review =
|
||||
review_subject_ref.and_then(|subject_ref| request.effective_review(subject_ref));
|
||||
let current_review = current_subject_ref
|
||||
.as_deref()
|
||||
.and_then(|subject_ref| request.effective_review(subject_ref));
|
||||
let current_review_request = current_review
|
||||
.and_then(|review| {
|
||||
request.thread.iter().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(review_request)
|
||||
if review_request.event_id == review.request_event_id =>
|
||||
{
|
||||
Some(review_request)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
current_subject_ref.as_deref().and_then(|subject_ref| {
|
||||
request.thread.iter().rev().find_map(|event| match event {
|
||||
MergeRequestThreadEvent::ReviewRequested(review)
|
||||
if review.subject_ref == subject_ref =>
|
||||
{
|
||||
Some(review)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
});
|
||||
let review_status = match current_review.map(|review| &review.decision) {
|
||||
Some(ReviewDecision::Approve) => "approved",
|
||||
Some(ReviewDecision::RequestChanges) => "changes_requested",
|
||||
None => "pending",
|
||||
None if latest_review_request.is_some() => "pending",
|
||||
None => "none",
|
||||
}
|
||||
.to_string();
|
||||
let state = match request.state {
|
||||
@@ -1045,68 +1105,132 @@ fn merge_request_summary(request: MergeRequest) -> TicketMergeRequestSummary {
|
||||
|
||||
TicketMergeRequestSummary {
|
||||
merge_request_id: request.merge_request_id.clone(),
|
||||
repository_id: request.repository_id.clone(),
|
||||
state,
|
||||
review_status,
|
||||
selector_from: request.selector_from.clone(),
|
||||
selector_to: request.selector_to.clone(),
|
||||
updated_at: request.updated_at.to_rfc3339(),
|
||||
review_subject_ref: review_subject_ref.map(str::to_string),
|
||||
current_subject_ref,
|
||||
review_subject_ref: latest_review_request.map(|review| review.subject_ref.clone()),
|
||||
review_requested_at: current_review_request.map(|review| review.created_at.to_rfc3339()),
|
||||
review_submitted_at: current_review.map(|review| review.created_at.to_rfc3339()),
|
||||
review_excerpt: current_review.map(|review| truncate_body(&review.body, 240).0),
|
||||
}
|
||||
}
|
||||
|
||||
fn substantive_item_edit(event: &TicketEvent) -> bool {
|
||||
if event.kind.as_str() != "item_edit" {
|
||||
return false;
|
||||
}
|
||||
let Some(changes) = event.attributes.get("changes") else {
|
||||
// Legacy item-edit events do not identify changed fields. Treat them as
|
||||
// substantive so readiness fails closed rather than accepting a stale review.
|
||||
return true;
|
||||
};
|
||||
changes
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.any(|field| matches!(field, "title" | "body" | "target"))
|
||||
}
|
||||
|
||||
fn event_timestamp(event: &TicketEvent) -> Option<DateTime<Utc>> {
|
||||
event
|
||||
.at
|
||||
.as_deref()
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn ticket_evidence_summary(
|
||||
ticket_repository_id: Option<&str>,
|
||||
events: &[TicketEvent],
|
||||
merge_request: Option<&TicketMergeRequestSummary>,
|
||||
) -> TicketEvidenceSummary {
|
||||
let latest_report = events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, event)| event.kind.as_str() == "implementation_report")
|
||||
.map(|(sequence, _)| sequence);
|
||||
let latest_rescope = events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, event)| event.kind.as_str() == "item_edit")
|
||||
.map(|(sequence, _)| sequence);
|
||||
let report_after_rescope =
|
||||
latest_report.is_some_and(|report| latest_rescope.is_none_or(|rescope| report > rescope));
|
||||
let has_commit = merge_request.is_some_and(|request| {
|
||||
let linked_merge_request = merge_request.filter(|request| {
|
||||
request.state == "open"
|
||||
&& ticket_repository_id
|
||||
.is_some_and(|repository_id| repository_id == request.repository_id)
|
||||
});
|
||||
let has_merge_request = linked_merge_request.is_some();
|
||||
let has_current_subject_ref = linked_merge_request.is_some_and(|request| {
|
||||
request
|
||||
.current_subject_ref
|
||||
.as_deref()
|
||||
.is_some_and(|subject_ref| !subject_ref.is_empty())
|
||||
});
|
||||
let has_review_request = linked_merge_request.is_some_and(|request| {
|
||||
request
|
||||
.review_subject_ref
|
||||
.as_ref()
|
||||
.as_deref()
|
||||
.is_some_and(|subject_ref| !subject_ref.is_empty())
|
||||
}) || events.iter().any(|event| {
|
||||
event.attributes.contains_key("commit") || event.attributes.contains_key("head_commit")
|
||||
});
|
||||
let review_status = merge_request.map(|request| request.review_status.clone());
|
||||
let approved = review_status.as_deref() == Some("approved");
|
||||
let has_commit = has_current_subject_ref;
|
||||
let review_status = linked_merge_request.map(|request| request.review_status.clone());
|
||||
let approved_current_subject = review_status.as_deref() == Some("approved");
|
||||
let unresolved_request_changes = review_status.as_deref() == Some("changes_requested");
|
||||
let latest_rescope = events
|
||||
.iter()
|
||||
.filter(|event| substantive_item_edit(event))
|
||||
.last();
|
||||
let review_after_rescope = approved_current_subject
|
||||
&& latest_rescope.is_none_or(|rescope| {
|
||||
let Some(rescope_at) = event_timestamp(rescope) else {
|
||||
return false;
|
||||
};
|
||||
let Some(requested_at) = linked_merge_request
|
||||
.and_then(|request| request.review_requested_at.as_deref())
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(reviewed_at) = linked_merge_request
|
||||
.and_then(|request| request.review_submitted_at.as_deref())
|
||||
.and_then(|value| DateTime::parse_from_rfc3339(value).ok())
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
requested_at > rescope_at && reviewed_at > rescope_at
|
||||
});
|
||||
|
||||
let mut missing = Vec::new();
|
||||
if latest_report.is_none() {
|
||||
missing.push("implementation_report".to_string());
|
||||
} else if !report_after_rescope {
|
||||
missing.push("implementation_report_after_rescope".to_string());
|
||||
match merge_request {
|
||||
None => missing.push("merge_request".to_string()),
|
||||
Some(request) if request.state != "open" => missing.push("open_merge_request".to_string()),
|
||||
Some(request)
|
||||
if ticket_repository_id
|
||||
.is_none_or(|repository_id| repository_id != request.repository_id) =>
|
||||
{
|
||||
missing.push("merge_request_repository".to_string())
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
if !has_current_subject_ref {
|
||||
missing.push("current_subject_ref".to_string());
|
||||
}
|
||||
if !has_commit {
|
||||
missing.push("commit".to_string());
|
||||
}
|
||||
if merge_request.is_none() {
|
||||
missing.push("merge_request".to_string());
|
||||
if unresolved_request_changes {
|
||||
missing.push("unresolved_request_changes".to_string());
|
||||
}
|
||||
if !approved {
|
||||
missing.push("approved_review".to_string());
|
||||
if !approved_current_subject {
|
||||
missing.push("approved_current_subject".to_string());
|
||||
}
|
||||
if approved_current_subject && !review_after_rescope {
|
||||
missing.push("review_after_rescope".to_string());
|
||||
}
|
||||
|
||||
TicketEvidenceSummary {
|
||||
has_implementation_report: latest_report.is_some(),
|
||||
implementation_report_after_rescope: report_after_rescope,
|
||||
has_merge_request: merge_request.is_some(),
|
||||
has_merge_request,
|
||||
has_current_subject_ref,
|
||||
has_review_request,
|
||||
has_commit,
|
||||
review_status,
|
||||
approved,
|
||||
approved_current_subject,
|
||||
review_after_rescope,
|
||||
unresolved_request_changes,
|
||||
complete_for_integration: missing.is_empty(),
|
||||
missing,
|
||||
@@ -1124,11 +1248,7 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
for evidence in &query.evidence {
|
||||
if !matches!(
|
||||
evidence.as_str(),
|
||||
"implementation_report"
|
||||
| "implementation_report_after_rescope"
|
||||
| "merge_request"
|
||||
| "commit"
|
||||
| "approved_review"
|
||||
"merge_request" | "commit" | "approved_review"
|
||||
) {
|
||||
return Err(Error::InvalidRecordId(format!(
|
||||
"unsupported Ticket evidence filter `{evidence}`"
|
||||
@@ -1139,8 +1259,6 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
if !matches!(
|
||||
attention.as_str(),
|
||||
"done_not_closed"
|
||||
| "implementation_report_not_closed"
|
||||
| "report_after_rescope"
|
||||
| "unresolved_review"
|
||||
| "missing_commit"
|
||||
| "blocked"
|
||||
@@ -1260,6 +1378,41 @@ fn parse_query_cursor(cursor: &str) -> Result<(String, String)> {
|
||||
Ok((value[..length].to_string(), value[length..].to_string()))
|
||||
}
|
||||
|
||||
fn ticket_evidence_matches(evidence: &TicketEvidenceSummary, filter: &str) -> bool {
|
||||
match filter {
|
||||
"merge_request" => evidence.has_merge_request,
|
||||
"commit" => evidence.has_commit,
|
||||
"approved_review" => evidence.approved_current_subject,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_attention_matches(
|
||||
state: &str,
|
||||
evidence: &TicketEvidenceSummary,
|
||||
is_blocked: bool,
|
||||
authoritative_events: &[TicketEvent],
|
||||
attention: &str,
|
||||
) -> bool {
|
||||
match attention {
|
||||
"done_not_closed" => state == "done",
|
||||
"unresolved_review" => evidence.unresolved_request_changes,
|
||||
"missing_commit" => !evidence.has_commit,
|
||||
"blocked" => is_blocked,
|
||||
"unblocked" => !is_blocked,
|
||||
"ready" => state == "ready" && !is_blocked,
|
||||
"awaiting_review" => {
|
||||
evidence.has_review_request && evidence.review_status.as_deref() == Some("pending")
|
||||
}
|
||||
"unresolved_changes" => evidence.unresolved_request_changes,
|
||||
"stale_after_rescope" => {
|
||||
authoritative_events.iter().any(substantive_item_edit) && !evidence.review_after_rescope
|
||||
}
|
||||
"missing_evidence" => !evidence.complete_for_integration,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_matches_query(
|
||||
summary: &TicketSummary,
|
||||
detail: &TicketDetail,
|
||||
@@ -1293,7 +1446,10 @@ fn ticket_matches_query(
|
||||
}
|
||||
if let Some(review_status) = &query.review_status {
|
||||
let matches = match review_status.as_str() {
|
||||
"none" => detail.evidence.review_status.is_none(),
|
||||
"none" => matches!(
|
||||
detail.evidence.review_status.as_deref(),
|
||||
None | Some("none")
|
||||
),
|
||||
"request_changes" | "unresolved_changes" => {
|
||||
detail.evidence.review_status.as_deref() == Some("changes_requested")
|
||||
}
|
||||
@@ -1306,43 +1462,19 @@ fn ticket_matches_query(
|
||||
if !query
|
||||
.evidence
|
||||
.iter()
|
||||
.all(|evidence| match evidence.as_str() {
|
||||
"implementation_report" => detail.evidence.has_implementation_report,
|
||||
"implementation_report_after_rescope" => {
|
||||
detail.evidence.implementation_report_after_rescope
|
||||
}
|
||||
"merge_request" => detail.evidence.has_merge_request,
|
||||
"commit" => detail.evidence.has_commit,
|
||||
"approved_review" => detail.evidence.approved,
|
||||
_ => false,
|
||||
})
|
||||
.all(|filter| ticket_evidence_matches(&detail.evidence, filter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !query
|
||||
.attention
|
||||
.iter()
|
||||
.all(|attention| match attention.as_str() {
|
||||
"done_not_closed" => summary.state == "done",
|
||||
"implementation_report_not_closed" => {
|
||||
detail.evidence.has_implementation_report && summary.state != "closed"
|
||||
}
|
||||
"report_after_rescope" => detail.evidence.implementation_report_after_rescope,
|
||||
"unresolved_review" => detail.evidence.unresolved_request_changes,
|
||||
"missing_commit" => !detail.evidence.has_commit,
|
||||
"blocked" => !detail.relations.blockers.is_empty(),
|
||||
"unblocked" => detail.relations.blockers.is_empty(),
|
||||
"ready" => summary.state == "ready" && detail.relations.blockers.is_empty(),
|
||||
"awaiting_review" => detail.evidence.review_status.as_deref() == Some("pending"),
|
||||
"unresolved_changes" => detail.evidence.unresolved_request_changes,
|
||||
"stale_after_rescope" => {
|
||||
detail.evidence.has_implementation_report
|
||||
&& !detail.evidence.implementation_report_after_rescope
|
||||
}
|
||||
"missing_evidence" => !detail.evidence.complete_for_integration,
|
||||
_ => false,
|
||||
})
|
||||
{
|
||||
if !query.attention.iter().all(|attention| {
|
||||
ticket_attention_matches(
|
||||
&summary.state,
|
||||
&detail.evidence,
|
||||
!detail.relations.blockers.is_empty(),
|
||||
authoritative_events,
|
||||
attention,
|
||||
)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if query.linked_objective_id.as_ref().is_some_and(|id| {
|
||||
@@ -1812,31 +1944,63 @@ fn workspace_action_priority_name(priority: TicketWorkspaceActionPriority) -> &'
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::store::{ObjectiveRecord, ObjectiveTicketLinkRecord, WorkspaceRecord};
|
||||
|
||||
#[test]
|
||||
fn merge_request_summary_tracks_the_latest_requested_subject() {
|
||||
let actor = merge_request::WorkerIdentity {
|
||||
fn actor() -> merge_request::WorkerIdentity {
|
||||
merge_request::WorkerIdentity {
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
worker_id: "worker-1".to_string(),
|
||||
};
|
||||
let at = Utc::now();
|
||||
let first_review = merge_request::ReviewEvent {
|
||||
event_id: "review-1".to_string(),
|
||||
sequence: 2,
|
||||
request_event_id: "request-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: "approved".to_string(),
|
||||
findings: Vec::new(),
|
||||
reviewer: actor.clone(),
|
||||
created_at: at,
|
||||
};
|
||||
let mut request = MergeRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn reviewed_merge_request(decision: ReviewDecision, revoked: bool) -> MergeRequest {
|
||||
let requested_at = DateTime::parse_from_rfc3339("2026-01-01T00:03:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let reviewed_at = DateTime::parse_from_rfc3339("2026-01-01T00:04:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let reviewer = actor();
|
||||
let mut thread = vec![
|
||||
MergeRequestThreadEvent::ReviewRequested(merge_request::ReviewRequestedEvent {
|
||||
event_id: "request-1".to_string(),
|
||||
sequence: 1,
|
||||
subject_ref: "commit-1".to_string(),
|
||||
requested_by: reviewer.clone(),
|
||||
reviewer: reviewer.clone(),
|
||||
created_at: requested_at,
|
||||
}),
|
||||
MergeRequestThreadEvent::Review(merge_request::ReviewEvent {
|
||||
event_id: "review-1".to_string(),
|
||||
sequence: 2,
|
||||
request_event_id: "request-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
decision,
|
||||
body: "review body".to_string(),
|
||||
findings: Vec::new(),
|
||||
reviewer: reviewer.clone(),
|
||||
created_at: reviewed_at,
|
||||
}),
|
||||
];
|
||||
if revoked {
|
||||
thread.push(MergeRequestThreadEvent::ReviewRevoked(
|
||||
merge_request::ReviewRevokedEvent {
|
||||
event_id: "revoke-1".to_string(),
|
||||
sequence: 3,
|
||||
review_event_id: "review-1".to_string(),
|
||||
subject_ref: "commit-1".to_string(),
|
||||
reason: "stale".to_string(),
|
||||
revoked_by: reviewer,
|
||||
created_at: reviewed_at + chrono::Duration::minutes(1),
|
||||
},
|
||||
));
|
||||
}
|
||||
MergeRequest {
|
||||
workspace_id: "workspace-1".to_string(),
|
||||
merge_request_id: "mr-1".to_string(),
|
||||
repository_id: "main".to_string(),
|
||||
@@ -1844,59 +2008,21 @@ mod tests {
|
||||
selector_from: Some("work/ticket-1".to_string()),
|
||||
selector_to: "orchestration".to_string(),
|
||||
ticket_ids: vec!["ticket-1".to_string()],
|
||||
thread: vec![
|
||||
MergeRequestThreadEvent::ReviewRequested(merge_request::ReviewRequestedEvent {
|
||||
event_id: "request-1".to_string(),
|
||||
sequence: 1,
|
||||
subject_ref: "commit-1".to_string(),
|
||||
requested_by: actor.clone(),
|
||||
reviewer: actor.clone(),
|
||||
created_at: at,
|
||||
}),
|
||||
MergeRequestThreadEvent::Review(first_review),
|
||||
MergeRequestThreadEvent::ReviewRequested(merge_request::ReviewRequestedEvent {
|
||||
event_id: "request-2".to_string(),
|
||||
sequence: 3,
|
||||
subject_ref: "commit-2".to_string(),
|
||||
requested_by: actor.clone(),
|
||||
reviewer: actor.clone(),
|
||||
created_at: at,
|
||||
}),
|
||||
],
|
||||
created_at: at,
|
||||
updated_at: at,
|
||||
};
|
||||
|
||||
let pending = merge_request_summary(request.clone());
|
||||
assert_eq!(pending.review_status, "pending");
|
||||
assert_eq!(pending.review_subject_ref.as_deref(), Some("commit-2"));
|
||||
assert_eq!(pending.review_submitted_at, None);
|
||||
|
||||
request.thread.push(MergeRequestThreadEvent::Review(
|
||||
merge_request::ReviewEvent {
|
||||
event_id: "review-2".to_string(),
|
||||
sequence: 4,
|
||||
request_event_id: "request-2".to_string(),
|
||||
subject_ref: "commit-2".to_string(),
|
||||
decision: ReviewDecision::Approve,
|
||||
body: "current approval".to_string(),
|
||||
findings: Vec::new(),
|
||||
reviewer: actor,
|
||||
created_at: at,
|
||||
},
|
||||
));
|
||||
let approved = merge_request_summary(request);
|
||||
assert_eq!(approved.review_status, "approved");
|
||||
assert_eq!(approved.review_subject_ref.as_deref(), Some("commit-2"));
|
||||
assert_eq!(approved.review_excerpt.as_deref(), Some("current approval"));
|
||||
thread,
|
||||
created_at: requested_at,
|
||||
updated_at: reviewed_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_evidence_summary_requires_report_after_latest_rescope_and_approved_revision() {
|
||||
let event = |kind: &str| TicketEvent {
|
||||
fn ticket_event(kind: &str, at: &str, changes: Option<&str>) -> TicketEvent {
|
||||
let mut attributes = BTreeMap::new();
|
||||
if let Some(changes) = changes {
|
||||
attributes.insert("changes".to_string(), changes.to_string());
|
||||
}
|
||||
TicketEvent {
|
||||
kind: ticket::TicketEventKind::Other(kind.to_string()),
|
||||
author: Some("coder".to_string()),
|
||||
at: Some("2026-01-01T00:00:00Z".to_string()),
|
||||
at: Some(at.to_string()),
|
||||
status: None,
|
||||
from: None,
|
||||
to: None,
|
||||
@@ -1904,43 +2030,186 @@ mod tests {
|
||||
state_field: None,
|
||||
heading: None,
|
||||
body: ticket::MarkdownText::new(kind),
|
||||
attributes: Default::default(),
|
||||
attributes,
|
||||
references: Vec::new(),
|
||||
};
|
||||
let request = TicketMergeRequestSummary {
|
||||
merge_request_id: "mr-1".to_string(),
|
||||
state: "open".to_string(),
|
||||
review_status: "approved".to_string(),
|
||||
selector_from: Some("work/ticket".to_string()),
|
||||
selector_to: "orchestration".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
review_subject_ref: Some("head".to_string()),
|
||||
review_submitted_at: Some("2026-01-01T00:00:00Z".to_string()),
|
||||
review_excerpt: Some("approved".to_string()),
|
||||
};
|
||||
let stale = ticket_evidence_summary(
|
||||
&[event("implementation_report"), event("item_edit")],
|
||||
Some(&request),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_request_summary_uses_the_provider_resolved_current_subject() {
|
||||
let approved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
assert!(!stale.implementation_report_after_rescope);
|
||||
assert!(!stale.complete_for_integration);
|
||||
assert!(
|
||||
stale
|
||||
.missing
|
||||
.contains(&"implementation_report_after_rescope".to_string())
|
||||
assert_eq!(approved.review_status, "approved");
|
||||
assert_eq!(approved.current_subject_ref.as_deref(), Some("commit-1"));
|
||||
assert_eq!(
|
||||
approved.review_requested_at.as_deref(),
|
||||
Some("2026-01-01T00:03:00+00:00")
|
||||
);
|
||||
let current = ticket_evidence_summary(
|
||||
|
||||
let moved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
Some("commit-2".to_string()),
|
||||
);
|
||||
assert_eq!(moved.review_status, "pending");
|
||||
assert_eq!(moved.current_subject_ref.as_deref(), Some("commit-2"));
|
||||
assert_eq!(moved.review_subject_ref.as_deref(), Some("commit-1"));
|
||||
assert_eq!(moved.review_submitted_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_readiness_requires_current_unrevoked_approval_without_a_report() {
|
||||
let approved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&approved));
|
||||
assert!(evidence.complete_for_integration);
|
||||
assert!(evidence.approved_current_subject);
|
||||
assert!(evidence.review_after_rescope);
|
||||
assert!(evidence.has_commit);
|
||||
|
||||
let with_audit_events = ticket_evidence_summary(
|
||||
Some("main"),
|
||||
&[
|
||||
event("implementation_report"),
|
||||
event("item_edit"),
|
||||
event("implementation_report"),
|
||||
ticket_event("comment", "2026-01-01T00:05:00Z", None),
|
||||
ticket_event("implementation_report", "2026-01-01T00:06:00Z", None),
|
||||
],
|
||||
Some(&request),
|
||||
Some(&approved),
|
||||
);
|
||||
assert!(current.implementation_report_after_rescope);
|
||||
assert!(current.has_commit);
|
||||
assert!(current.approved);
|
||||
assert!(current.complete_for_integration);
|
||||
assert!(with_audit_events.complete_for_integration);
|
||||
|
||||
let revoked = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, true),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&revoked));
|
||||
assert!(!evidence.approved_current_subject);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
|
||||
let changes = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::RequestChanges, false),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&changes));
|
||||
assert!(evidence.unresolved_request_changes);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_readiness_fails_closed_for_missing_or_closed_current_merge_request() {
|
||||
let unresolved =
|
||||
merge_request_summary(reviewed_merge_request(ReviewDecision::Approve, false), None);
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&unresolved));
|
||||
assert!(!evidence.has_current_subject_ref);
|
||||
assert!(!evidence.has_commit);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
|
||||
let mut closed_request = reviewed_merge_request(ReviewDecision::Approve, false);
|
||||
closed_request.state = MergeRequestState::Closed;
|
||||
let closed = merge_request_summary(closed_request, Some("commit-1".to_string()));
|
||||
let evidence = ticket_evidence_summary(Some("main"), &[], Some(&closed));
|
||||
assert!(!evidence.has_merge_request);
|
||||
assert!(!evidence.complete_for_integration);
|
||||
assert!(evidence.missing.contains(&"open_merge_request".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_readiness_requires_request_and_approval_after_substantive_rescope() {
|
||||
let approved = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let fresh = ticket_evidence_summary(
|
||||
Some("main"),
|
||||
&[ticket_event(
|
||||
"item_edit",
|
||||
"2026-01-01T00:02:00Z",
|
||||
Some("body"),
|
||||
)],
|
||||
Some(&approved),
|
||||
);
|
||||
assert!(fresh.review_after_rescope);
|
||||
assert!(fresh.complete_for_integration);
|
||||
|
||||
let stale = ticket_evidence_summary(
|
||||
Some("main"),
|
||||
&[ticket_event(
|
||||
"item_edit",
|
||||
"2026-01-01T00:05:00Z",
|
||||
Some("title"),
|
||||
)],
|
||||
Some(&approved),
|
||||
);
|
||||
assert!(!stale.review_after_rescope);
|
||||
assert!(!stale.complete_for_integration);
|
||||
assert!(stale.missing.contains(&"review_after_rescope".to_string()));
|
||||
|
||||
let metadata_only = ticket_evidence_summary(
|
||||
Some("main"),
|
||||
&[ticket_event(
|
||||
"item_edit",
|
||||
"2026-01-01T00:05:00Z",
|
||||
Some("formatter"),
|
||||
)],
|
||||
Some(&approved),
|
||||
);
|
||||
assert!(metadata_only.complete_for_integration);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_query_filters_map_to_current_merge_request_evidence() {
|
||||
let approved_summary = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
Some("commit-1".to_string()),
|
||||
);
|
||||
let approved = ticket_evidence_summary(Some("main"), &[], Some(&approved_summary));
|
||||
assert!(ticket_evidence_matches(&approved, "merge_request"));
|
||||
assert!(ticket_evidence_matches(&approved, "commit"));
|
||||
assert!(ticket_evidence_matches(&approved, "approved_review"));
|
||||
assert!(!ticket_attention_matches(
|
||||
"inprogress",
|
||||
&approved,
|
||||
false,
|
||||
&[],
|
||||
"missing_evidence",
|
||||
));
|
||||
|
||||
let pending_summary = merge_request_summary(
|
||||
reviewed_merge_request(ReviewDecision::Approve, false),
|
||||
Some("commit-2".to_string()),
|
||||
);
|
||||
let pending = ticket_evidence_summary(Some("main"), &[], Some(&pending_summary));
|
||||
assert!(ticket_attention_matches(
|
||||
"inprogress",
|
||||
&pending,
|
||||
false,
|
||||
&[],
|
||||
"awaiting_review",
|
||||
));
|
||||
assert!(!ticket_evidence_matches(&pending, "approved_review"));
|
||||
|
||||
let stale_events = vec![ticket_event(
|
||||
"item_edit",
|
||||
"2026-01-01T00:05:00Z",
|
||||
Some("target"),
|
||||
)];
|
||||
let stale = ticket_evidence_summary(Some("main"), &stale_events, Some(&approved_summary));
|
||||
assert!(ticket_attention_matches(
|
||||
"inprogress",
|
||||
&stale,
|
||||
false,
|
||||
&stale_events,
|
||||
"stale_after_rescope",
|
||||
));
|
||||
assert!(ticket_attention_matches(
|
||||
"inprogress",
|
||||
&stale,
|
||||
false,
|
||||
&stale_events,
|
||||
"missing_evidence",
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -31,7 +31,8 @@ mod workspace_subscription;
|
||||
|
||||
pub use authority::{
|
||||
MemoryAuthority, MemoryDocument, MemoryStagingEntry, MemoryStagingResolution,
|
||||
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, WorkspaceAuthority,
|
||||
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
||||
WorkspaceAuthority,
|
||||
};
|
||||
pub use config::{
|
||||
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
|
||||
|
||||
@@ -239,12 +239,15 @@ pub struct TicketAssignmentSummary {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketMergeRequestSummary {
|
||||
pub merge_request_id: String,
|
||||
pub repository_id: String,
|
||||
pub state: String,
|
||||
pub review_status: String,
|
||||
pub selector_from: Option<String>,
|
||||
pub selector_to: String,
|
||||
pub updated_at: String,
|
||||
pub current_subject_ref: Option<String>,
|
||||
pub review_subject_ref: Option<String>,
|
||||
pub review_requested_at: Option<String>,
|
||||
pub review_submitted_at: Option<String>,
|
||||
pub review_excerpt: Option<String>,
|
||||
}
|
||||
@@ -252,12 +255,13 @@ pub struct TicketMergeRequestSummary {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketEvidenceSummary {
|
||||
pub has_implementation_report: bool,
|
||||
pub implementation_report_after_rescope: bool,
|
||||
pub has_merge_request: bool,
|
||||
pub has_current_subject_ref: bool,
|
||||
pub has_review_request: bool,
|
||||
pub has_commit: bool,
|
||||
pub review_status: Option<String>,
|
||||
pub approved: bool,
|
||||
pub approved_current_subject: bool,
|
||||
pub review_after_rescope: bool,
|
||||
pub unresolved_request_changes: bool,
|
||||
pub complete_for_integration: bool,
|
||||
pub missing: Vec<String>,
|
||||
|
||||
@@ -62,7 +62,7 @@ use crate::auth::{
|
||||
};
|
||||
use crate::authority::{
|
||||
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
|
||||
SqliteWorkspaceAuthority, TicketAuthority,
|
||||
SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
||||
};
|
||||
use crate::companion::{
|
||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||
@@ -809,7 +809,11 @@ impl WorkspaceApi {
|
||||
authority: SqliteWorkspaceAuthority::new(
|
||||
config.database_path.clone(),
|
||||
config.workspace_id.clone(),
|
||||
)?,
|
||||
)?
|
||||
.with_merge_revision_source(Arc::new(MergeRequestRepositorySource {
|
||||
workspace_id: config.workspace_id.clone(),
|
||||
reader: RepositoryRegistryReader::new(config.repositories.clone()),
|
||||
})),
|
||||
config,
|
||||
store,
|
||||
runtime,
|
||||
@@ -1060,18 +1064,11 @@ impl WorkspaceApi {
|
||||
&self,
|
||||
request: &WorkerSpawnRequest,
|
||||
) -> ApiResult<()> {
|
||||
let (selected_repository_id, selected_ref_selector) =
|
||||
let selected_repository_id =
|
||||
if let Some(working_directory) = request.resolved_working_directory_request.as_ref() {
|
||||
let repository_id = working_directory.repository.id.as_str();
|
||||
self.require_workspace_repository(repository_id)?;
|
||||
(
|
||||
Some(repository_id.to_string()),
|
||||
working_directory
|
||||
.repository
|
||||
.selector
|
||||
.as_deref()
|
||||
.map(str::to_owned),
|
||||
)
|
||||
Some(repository_id.to_string())
|
||||
} else if let Some(claim) = request.resolved_working_directory.as_ref() {
|
||||
let workdir = self
|
||||
.store
|
||||
@@ -1083,48 +1080,20 @@ impl WorkspaceApi {
|
||||
)))
|
||||
})?;
|
||||
self.require_workspace_repository(&workdir.repository_id)?;
|
||||
(Some(workdir.repository_id), workdir.creation_selector)
|
||||
Some(workdir.repository_id)
|
||||
} else {
|
||||
(None, None)
|
||||
None
|
||||
};
|
||||
|
||||
if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent {
|
||||
let ticket = self.authority.ticket(ticket_id)?;
|
||||
// Workdir-less Ticket Workers cannot execute repository implementation.
|
||||
// Preserve that control-plane launch while still validating any persisted
|
||||
// target (including its Workspace ownership) when one exists.
|
||||
if selected_repository_id.is_none() && ticket.repository_id.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let repository_id = ticket.repository_id.as_deref().ok_or_else(|| {
|
||||
ApiError::from(Error::Config(
|
||||
"Ticket implementation target must be validated and persisted before spawning a Ticket Worker".to_owned(),
|
||||
))
|
||||
})?;
|
||||
let ref_selector = ticket.ref_selector.as_deref().ok_or_else(|| {
|
||||
ApiError::from(Error::Config(
|
||||
"Ticket implementation target selector must be validated and persisted before spawning a Ticket Worker".to_owned(),
|
||||
))
|
||||
})?;
|
||||
self.require_workspace_repository(repository_id)?;
|
||||
self.repository_reader()
|
||||
.observe_merge_target(repository_id, Some(ref_selector))
|
||||
.map_err(|error| {
|
||||
ApiError::from(Error::Config(format!(
|
||||
"Ticket implementation target is no longer resolvable: {error:?}"
|
||||
)))
|
||||
})?;
|
||||
if selected_repository_id.as_deref() != Some(repository_id) {
|
||||
return Err(ApiError::from(Error::Config(format!(
|
||||
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch resolves `{}`",
|
||||
selected_repository_id.as_deref().unwrap_or("none")
|
||||
))));
|
||||
}
|
||||
if selected_ref_selector.as_deref() != Some(ref_selector) {
|
||||
return Err(ApiError::from(Error::Config(format!(
|
||||
"Ticket `{ticket_id}` targets selector `{ref_selector}`, but the Worker launch resolves `{}`",
|
||||
selected_ref_selector.as_deref().unwrap_or("none")
|
||||
))));
|
||||
if let Some(repository_id) = ticket.repository_id.as_deref() {
|
||||
self.require_workspace_repository(repository_id)?;
|
||||
if selected_repository_id.as_deref() != Some(repository_id) {
|
||||
return Err(ApiError::from(Error::Config(format!(
|
||||
"Ticket `{ticket_id}` targets repository `{repository_id}`, but the Worker launch does not resolve that repository in this Workspace"
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1350,8 +1319,8 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
post(scoped_set_ticket_workflow_state),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready",
|
||||
post(scoped_mark_ticket_ready),
|
||||
"/api/w/{workspace_id}/tickets/{id}/intake-ready",
|
||||
post(scoped_prepare_ticket_intake_ready),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets/{id}/workflow/queue",
|
||||
@@ -1432,10 +1401,6 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||
"/api/w/{workspace_id}/tickets/{id}/state",
|
||||
post(scoped_transition_ticket_state),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets/{id}/ready",
|
||||
post(scoped_mark_ticket_ready_from_browser),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets/{id}/events",
|
||||
post(scoped_append_ticket_event),
|
||||
@@ -3146,55 +3111,6 @@ struct BrowserCloseTicketRequest {
|
||||
resolution: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct WorkspaceTicketTargetAuthority {
|
||||
api: WorkspaceApi,
|
||||
}
|
||||
|
||||
impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority {
|
||||
fn resolve_target(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
repository_id: Option<&str>,
|
||||
ref_selector: Option<&str>,
|
||||
) -> ticket::Result<ticket::ResolvedTicketTarget> {
|
||||
if workspace_id != self.api.config.workspace_id {
|
||||
return Err(ticket::TicketError::UnknownTargetRepository(
|
||||
repository_id.unwrap_or_default().to_owned(),
|
||||
));
|
||||
}
|
||||
let repository_id = repository_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(ticket::TicketError::MissingTargetRepository)?;
|
||||
let repository = self
|
||||
.api
|
||||
.store
|
||||
.get_repository(workspace_id, repository_id)
|
||||
.map_err(|error| ticket::TicketError::Conflict(error.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
ticket::TicketError::UnknownTargetRepository(repository_id.to_owned())
|
||||
})?;
|
||||
let selector = ref_selector
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or(repository.default_ref.as_deref())
|
||||
.ok_or_else(|| ticket::TicketError::MissingTargetSelector(repository_id.to_owned()))?;
|
||||
self.api
|
||||
.repository_reader()
|
||||
.observe_merge_target(repository_id, Some(selector))
|
||||
.map_err(|error| ticket::TicketError::InvalidTargetSelector {
|
||||
repository_id: repository_id.to_owned(),
|
||||
selector: selector.to_owned(),
|
||||
reason: format!("{error:?}"),
|
||||
})?;
|
||||
Ok(ticket::ResolvedTicketTarget {
|
||||
repository_id: repository_id.to_owned(),
|
||||
ref_selector: selector.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
|
||||
let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root)
|
||||
.map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?;
|
||||
@@ -3202,10 +3118,7 @@ fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
|
||||
api.config.database_path.clone(),
|
||||
api.config.workspace_id.clone(),
|
||||
)?
|
||||
.with_record_language(config.ticket_record_language())
|
||||
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
|
||||
api: api.clone(),
|
||||
})))
|
||||
.with_record_language(config.ticket_record_language()))
|
||||
}
|
||||
|
||||
fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<TicketDetail>> {
|
||||
@@ -3299,26 +3212,6 @@ async fn scoped_append_ticket_event(
|
||||
browser_ticket_detail(&api, &path.id)
|
||||
}
|
||||
|
||||
async fn scoped_mark_ticket_ready_from_browser(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||
Json(request): Json<TicketMarkReadyRequest>,
|
||||
) -> ApiResult<Json<TicketDetail>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
browser_ticket_backend(&api)?
|
||||
.mark_ready(
|
||||
TicketIdOrSlug::Id(path.id.clone()),
|
||||
ticket::TicketMarkReady {
|
||||
operation_key: request.operation_key,
|
||||
reason: request.reason,
|
||||
author: Some("web".to_owned()),
|
||||
intake_summary: None,
|
||||
},
|
||||
)
|
||||
.map_err(Error::from)?;
|
||||
browser_ticket_detail(&api, &path.id)
|
||||
}
|
||||
|
||||
async fn scoped_queue_ticket(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||
@@ -3380,10 +3273,7 @@ async fn execute_worker_ticket_rest_operation(
|
||||
api.config.workspace_id.clone(),
|
||||
)
|
||||
.map_err(Error::from)?
|
||||
.with_record_language(config.ticket_record_language())
|
||||
.with_target_authority(Arc::new(WorkspaceTicketTargetAuthority {
|
||||
api: api.clone(),
|
||||
}));
|
||||
.with_record_language(config.ticket_record_language());
|
||||
let operation_kind = ticket_mutation_operation_kind(&operation);
|
||||
let is_mutation = operation_kind != "read";
|
||||
let target = ticket_mutation_target(&operation).cloned();
|
||||
@@ -3535,15 +3425,6 @@ async fn scoped_create_ticket_record(
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<ticket::NewTicket>,
|
||||
) -> ApiResult<Json<ticket::TicketRef>> {
|
||||
if input
|
||||
.workflow_state
|
||||
.is_some_and(|state| state != TicketWorkflowState::Planning)
|
||||
{
|
||||
return Err(settings_bad_request(
|
||||
"ticket_create_state_bypass",
|
||||
"Ticket creation must start in planning; use guarded workflow operations for later states",
|
||||
));
|
||||
}
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
@@ -3657,12 +3538,9 @@ async fn scoped_add_ticket_intake_summary(
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TicketMarkReadyRequest {
|
||||
operation_key: String,
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
#[serde(default)]
|
||||
intake_summary: Option<ticket::TicketIntakeSummary>,
|
||||
struct TicketIntakeReadyRequest {
|
||||
summary: ticket::TicketIntakeSummary,
|
||||
change: TicketStateChange,
|
||||
}
|
||||
|
||||
async fn scoped_set_ticket_state_field(
|
||||
@@ -3704,31 +3582,24 @@ async fn scoped_set_ticket_workflow_state(
|
||||
ticket_rest_unit(result)
|
||||
}
|
||||
|
||||
async fn scoped_mark_ticket_ready(
|
||||
async fn scoped_prepare_ticket_intake_ready(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<TicketMarkReadyRequest>,
|
||||
) -> ApiResult<Json<ticket::Ticket>> {
|
||||
Json(request): Json<TicketIntakeReadyRequest>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let result = execute_worker_ticket_rest_operation(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
TicketBackendOperation::MarkReady {
|
||||
TicketBackendOperation::MarkIntakeReady {
|
||||
id: TicketIdOrSlug::Query(id),
|
||||
request: ticket::TicketMarkReady {
|
||||
operation_key: request.operation_key,
|
||||
reason: request.reason,
|
||||
author: None,
|
||||
intake_summary: request.intake_summary,
|
||||
},
|
||||
summary: request.summary,
|
||||
change: request.change,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
ticket_rest_result(result, |result| match result {
|
||||
TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
|
||||
_ => None,
|
||||
})
|
||||
ticket_rest_unit(result)
|
||||
}
|
||||
|
||||
async fn scoped_queue_ticket_record(
|
||||
@@ -3854,6 +3725,15 @@ struct MergeRequestRepositorySource {
|
||||
reader: RepositoryRegistryReader,
|
||||
}
|
||||
|
||||
impl TicketMergeRevisionSource for MergeRequestRepositorySource {
|
||||
fn resolve_subject_ref(&self, repository_id: &str, selector: &str) -> Option<String> {
|
||||
self.reader
|
||||
.observe_merge_target(repository_id, Some(selector))
|
||||
.ok()
|
||||
.map(|target| target.commit)
|
||||
}
|
||||
}
|
||||
|
||||
impl merge_request::RepositorySource for MergeRequestRepositorySource {
|
||||
fn repository_belongs_to_workspace(
|
||||
&self,
|
||||
@@ -4501,7 +4381,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
|
||||
| TicketBackendOperation::AddIntakeSummary { id, .. }
|
||||
| TicketBackendOperation::SetStateField { id, .. }
|
||||
| TicketBackendOperation::SetWorkflowState { id, .. }
|
||||
| TicketBackendOperation::MarkReady { id, .. }
|
||||
| TicketBackendOperation::MarkIntakeReady { id, .. }
|
||||
| TicketBackendOperation::QueueReady { id, .. }
|
||||
| TicketBackendOperation::Close { id, .. }
|
||||
| TicketBackendOperation::AddTicketRelation { id, .. }
|
||||
@@ -4542,11 +4422,11 @@ fn bind_worker_ticket_operation_source(
|
||||
| TicketBackendOperation::SetStateField { change, .. }
|
||||
| TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author),
|
||||
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
|
||||
TicketBackendOperation::MarkReady { request, .. } => {
|
||||
request.author = Some(author.clone());
|
||||
if let Some(summary) = request.intake_summary.as_mut() {
|
||||
summary.author = Some(author);
|
||||
}
|
||||
TicketBackendOperation::MarkIntakeReady {
|
||||
summary, change, ..
|
||||
} => {
|
||||
summary.author = Some(author.clone());
|
||||
change.author = Some(author);
|
||||
}
|
||||
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
|
||||
TicketBackendOperation::AddTicketRelation { relation, .. } => {
|
||||
@@ -4568,7 +4448,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
|
||||
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
|
||||
TicketBackendOperation::SetStateField { .. } => "set_state_field",
|
||||
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
|
||||
TicketBackendOperation::MarkReady { .. } => "mark_ready",
|
||||
TicketBackendOperation::MarkIntakeReady { .. } => "mark_intake_ready",
|
||||
TicketBackendOperation::QueueReady { .. } => "queue_ready",
|
||||
TicketBackendOperation::Close { .. } => "close",
|
||||
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
|
||||
@@ -12016,26 +11896,7 @@ impl From<Error> for ApiError {
|
||||
ticket::TicketError::NotFound(_) => "ticket_not_found",
|
||||
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
|
||||
ticket::TicketError::Locked { .. } => "ticket_locked",
|
||||
ticket::TicketError::Conflict(_)
|
||||
| ticket::TicketError::StaleWorkflowState { .. }
|
||||
| ticket::TicketError::InvalidWorkflowTransition { .. }
|
||||
| ticket::TicketError::BlockingRelations(_)
|
||||
| ticket::TicketError::OperationFingerprintMismatch { .. } => "ticket_conflict",
|
||||
ticket::TicketError::MissingTargetRepository => {
|
||||
"ticket_target_repository_missing"
|
||||
}
|
||||
ticket::TicketError::UnknownTargetRepository(_) => {
|
||||
"ticket_target_repository_unknown"
|
||||
}
|
||||
ticket::TicketError::MissingTargetSelector(_) => {
|
||||
"ticket_target_selector_missing"
|
||||
}
|
||||
ticket::TicketError::InvalidTargetSelector { .. } => {
|
||||
"ticket_target_selector_invalid"
|
||||
}
|
||||
ticket::TicketError::TargetAuthorityUnavailable => {
|
||||
"ticket_target_authority_unavailable"
|
||||
}
|
||||
ticket::TicketError::Conflict(_) => "ticket_conflict",
|
||||
ticket::TicketError::InvalidPathComponent(_)
|
||||
| ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request",
|
||||
ticket::TicketError::Io { .. }
|
||||
@@ -12443,7 +12304,7 @@ mod tests {
|
||||
assert_eq!(builtin.definition.name, "coder-review");
|
||||
assert_eq!(builtin.selector.to_string(), "builtin:coder-review");
|
||||
assert_eq!(builtin.flow_id, "builtin:coder-review");
|
||||
assert_eq!(builtin.revision, 2);
|
||||
assert_eq!(builtin.revision, 3);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_flow_sources(&api.config.workspace_id)
|
||||
@@ -13701,7 +13562,7 @@ mod tests {
|
||||
config.repositories = vec![ConfiguredRepository {
|
||||
id: TEST_REPOSITORY_ID.to_string(),
|
||||
provider: "git".to_string(),
|
||||
uri: workspace_root.display().to_string(),
|
||||
uri: ".".to_string(),
|
||||
path: workspace_root,
|
||||
display_name: Some("Test Repository".to_string()),
|
||||
default_selector: Some("HEAD".to_string()),
|
||||
@@ -13858,7 +13719,7 @@ mod tests {
|
||||
|
||||
fn init_clean_git_workspace(path: &std::path::Path) {
|
||||
for args in [
|
||||
vec!["init", "--initial-branch=develop"],
|
||||
vec!["init"],
|
||||
vec!["config", "user.email", "test@example.invalid"],
|
||||
vec!["config", "user.name", "Yoi Test"],
|
||||
] {
|
||||
@@ -13890,88 +13751,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_ready_resolves_workspace_target_and_closes_lifecycle_bypasses() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
|
||||
let mut input = ticket::NewTicket::new("Validated target");
|
||||
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
||||
input.ref_selector = Some("develop".to_owned());
|
||||
let ticket_ref = backend.create(input).unwrap();
|
||||
let request = ticket::TicketMarkReady {
|
||||
operation_key: "ready-server-test".to_owned(),
|
||||
reason: Some("target accepted".to_owned()),
|
||||
author: Some("test".to_owned()),
|
||||
intake_summary: None,
|
||||
};
|
||||
let ready = backend
|
||||
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone())
|
||||
.unwrap();
|
||||
assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready);
|
||||
assert_eq!(
|
||||
ready.meta.repository_id.as_deref(),
|
||||
Some(TEST_REPOSITORY_ID)
|
||||
);
|
||||
assert_eq!(ready.meta.ref_selector.as_deref(), Some("develop"));
|
||||
assert_eq!(
|
||||
backend
|
||||
.mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request)
|
||||
.unwrap()
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.attributes.contains_key("operation_key"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(matches!(
|
||||
backend.edit_item(
|
||||
TicketIdOrSlug::Id(ticket_ref.id.clone()),
|
||||
ticket::TicketItemEdit {
|
||||
target: Some(ticket::TicketTargetEdit::Set {
|
||||
repository_id: TEST_REPOSITORY_ID.to_owned(),
|
||||
ref_selector: Some("other".to_owned()),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
Err(ticket::TicketError::Conflict(_))
|
||||
));
|
||||
|
||||
let mut missing = ticket::NewTicket::new("Missing target");
|
||||
missing.repository_id = Some("unknown".to_owned());
|
||||
let missing = backend.create(missing).unwrap();
|
||||
assert!(matches!(
|
||||
backend.mark_ready(
|
||||
TicketIdOrSlug::Id(missing.id.clone()),
|
||||
ticket::TicketMarkReady {
|
||||
operation_key: "missing-repository".to_owned(),
|
||||
reason: None,
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
),
|
||||
Err(ticket::TicketError::UnknownTargetRepository(_))
|
||||
));
|
||||
assert_eq!(
|
||||
backend
|
||||
.show(TicketIdOrSlug::Id(missing.id))
|
||||
.unwrap()
|
||||
.meta
|
||||
.workflow_state,
|
||||
TicketWorkflowState::Planning
|
||||
);
|
||||
assert!(matches!(
|
||||
backend.set_workflow_state(
|
||||
TicketIdOrSlug::Id(ticket_ref.id),
|
||||
TicketStateChange::new("ready", "queued", "bypass", "must use TicketQueue",),
|
||||
),
|
||||
Err(ticket::TicketError::InvalidWorkflowTransition { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_source_actor_roles_use_canonical_vocabulary() {
|
||||
assert_eq!(worker_source_actor_role(true, false), "coder");
|
||||
@@ -14013,7 +13792,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let source_worker = api
|
||||
.runtime
|
||||
@@ -14071,24 +13849,29 @@ mod tests {
|
||||
let orchestrator = started.worker.unwrap().worker;
|
||||
execution.take_inputs();
|
||||
|
||||
let mut input = ticket::NewTicket::new("Bounded notification");
|
||||
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
||||
input.ref_selector = Some("develop".to_owned());
|
||||
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Bounded notification"))
|
||||
.unwrap();
|
||||
let ticket_id = TicketIdOrSlug::Id(ticket.id.clone());
|
||||
let operations = [
|
||||
TicketBackendOperation::MarkReady {
|
||||
TicketBackendOperation::SetWorkflowState {
|
||||
id: ticket_id.clone(),
|
||||
request: ticket::TicketMarkReady {
|
||||
operation_key: "notification-ready".to_owned(),
|
||||
reason: Some("ready for implementation".to_owned()),
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
change: TicketStateChange::new(
|
||||
"planning",
|
||||
"ready",
|
||||
"ready for implementation",
|
||||
"test transition",
|
||||
),
|
||||
},
|
||||
TicketBackendOperation::QueueReady {
|
||||
TicketBackendOperation::SetWorkflowState {
|
||||
id: ticket_id.clone(),
|
||||
queued_by: "spoofed".to_owned(),
|
||||
change: TicketStateChange::new(
|
||||
"ready",
|
||||
"queued",
|
||||
"queued for implementation",
|
||||
"test transition",
|
||||
),
|
||||
},
|
||||
TicketBackendOperation::SetWorkflowState {
|
||||
id: ticket_id.clone(),
|
||||
@@ -14493,11 +14276,30 @@ mod tests {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("Recover queued work");
|
||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
||||
input.ref_selector = Some("HEAD".to_owned());
|
||||
let ticket_ref = backend.create(input).unwrap();
|
||||
let ticket_ref = backend
|
||||
.create(ticket::NewTicket::new("Recover queued work"))
|
||||
.unwrap();
|
||||
backend
|
||||
.mark_intake_ready(
|
||||
TicketIdOrSlug::Id(ticket_ref.id.clone()),
|
||||
ticket::TicketIntakeSummary {
|
||||
author: Some("intake".to_string()),
|
||||
body: MarkdownText::new("Ready"),
|
||||
references: Vec::new(),
|
||||
},
|
||||
ticket::TicketStateChange {
|
||||
from: "planning".to_string(),
|
||||
to: "ready".to_string(),
|
||||
reason: "ready".to_string(),
|
||||
author: Some("intake".to_string()),
|
||||
body: MarkdownText::new("Ready"),
|
||||
references: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
backend
|
||||
.queue_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), "browser-user")
|
||||
.unwrap();
|
||||
*api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone());
|
||||
|
||||
let Json(started) = scoped_start_workspace_orchestrator(
|
||||
@@ -14923,7 +14725,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let api = test_api(dir.path()).await;
|
||||
let ticket_ref = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
@@ -14963,7 +14764,7 @@ mod tests {
|
||||
replace_all: false,
|
||||
target: Some(TicketTargetEdit::Set {
|
||||
repository_id: "main".to_string(),
|
||||
ref_selector: Some("develop".to_string()),
|
||||
ref_selector: Some("feature/api".to_string()),
|
||||
}),
|
||||
author: Some("browser-user".to_string()),
|
||||
}),
|
||||
@@ -14973,7 +14774,7 @@ mod tests {
|
||||
assert_eq!(edited.title, "Browser Ticket API edited");
|
||||
assert_eq!(edited.body, "Updated from the Browser API.");
|
||||
assert_eq!(edited.repository_id.as_deref(), Some("main"));
|
||||
assert_eq!(edited.ref_selector.as_deref(), Some("develop"));
|
||||
assert_eq!(edited.ref_selector.as_deref(), Some("feature/api"));
|
||||
assert_eq!(edited.assignee, None);
|
||||
assert_eq!(edited.relations.outgoing.len(), 1);
|
||||
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
|
||||
@@ -14994,13 +14795,14 @@ mod tests {
|
||||
event.kind == "comment" && event.body.as_deref() == Some("API comment")
|
||||
}));
|
||||
|
||||
let Json(ready) = scoped_mark_ticket_ready_from_browser(
|
||||
let Json(ready) = scoped_transition_ticket_state(
|
||||
State(api.clone()),
|
||||
AxumPath(path()),
|
||||
Json(TicketMarkReadyRequest {
|
||||
operation_key: "browser-ready".to_owned(),
|
||||
reason: Some("intake complete".to_owned()),
|
||||
intake_summary: None,
|
||||
Json(BrowserTransitionTicketStateRequest {
|
||||
state: TicketWorkflowState::Ready,
|
||||
reason: Some("intake complete".to_string()),
|
||||
body: Some("Ready for queue".to_string()),
|
||||
author: Some("browser-user".to_string()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -12,7 +12,8 @@ use ticket::config::{
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
|
||||
TicketListQuery, TicketListState, TicketRelationKind, TicketSummary, TicketWorkflowState,
|
||||
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary,
|
||||
TicketWorkflowState,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||
@@ -629,16 +630,8 @@ fn state(
|
||||
let id = TicketIdOrSlug::Query(options.query.clone());
|
||||
let target_state = match options.state {
|
||||
StateTarget::Planning => TicketWorkflowState::Planning,
|
||||
StateTarget::Ready => {
|
||||
return Err(TicketCliError::new(
|
||||
"ready requires Workspace repository authority; use the Browser Mark ready action or TicketMarkReady",
|
||||
));
|
||||
}
|
||||
StateTarget::Queued => {
|
||||
return Err(TicketCliError::new(
|
||||
"queued is an Orchestrator operation; use TicketQueue after MarkReady succeeds",
|
||||
));
|
||||
}
|
||||
StateTarget::Ready => TicketWorkflowState::Ready,
|
||||
StateTarget::Queued => TicketWorkflowState::Queued,
|
||||
StateTarget::InProgress => TicketWorkflowState::InProgress,
|
||||
StateTarget::Done => {
|
||||
return Err(TicketCliError::new(
|
||||
@@ -653,16 +646,33 @@ fn state(
|
||||
};
|
||||
let current = backend.show(id.clone())?;
|
||||
let ticket_id = current.meta.id.clone();
|
||||
let from = current.meta.workflow_state;
|
||||
let change = ticket::TicketStateChange {
|
||||
from: from.as_str().to_string(),
|
||||
to: target_state.as_str().to_string(),
|
||||
reason: "cli_state".to_string(),
|
||||
author: Some("yoi ticket".to_string()),
|
||||
body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
|
||||
references: Vec::new(),
|
||||
};
|
||||
backend.set_workflow_state(id, change)?;
|
||||
match target_state {
|
||||
TicketWorkflowState::Ready => backend.mark_intake_ready(
|
||||
id,
|
||||
TicketIntakeSummary::new("Marked ready by `yoi ticket state`."),
|
||||
ticket::TicketStateChange {
|
||||
from: current.meta.workflow_state.as_str().to_string(),
|
||||
to: TicketWorkflowState::Ready.as_str().to_string(),
|
||||
reason: "cli_state".to_string(),
|
||||
author: Some("yoi ticket".to_string()),
|
||||
body: "Marked ready by `yoi ticket state`.\n".into(),
|
||||
references: Vec::new(),
|
||||
},
|
||||
)?,
|
||||
TicketWorkflowState::Queued => backend.queue_ready(id, "yoi ticket")?,
|
||||
_ => {
|
||||
let from = current.meta.workflow_state;
|
||||
let change = ticket::TicketStateChange {
|
||||
from: from.as_str().to_string(),
|
||||
to: target_state.as_str().to_string(),
|
||||
reason: "cli_state".to_string(),
|
||||
author: Some("yoi ticket".to_string()),
|
||||
body: format!("State changed to `{}`.\n", target_state.as_str()).into(),
|
||||
references: Vec::new(),
|
||||
};
|
||||
backend.set_workflow_state(id, change)?;
|
||||
}
|
||||
}
|
||||
Ok(success(format!(
|
||||
"state\t{}\t{}\n",
|
||||
ticket_id,
|
||||
@@ -1325,14 +1335,23 @@ mod tests {
|
||||
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
|
||||
);
|
||||
|
||||
let ready_error = parse_ticket_args(&args(&["state", &ticket_id, "ready"]))
|
||||
.and_then(|cli| run_in_workspace(cli, temp.path()))
|
||||
.unwrap_err();
|
||||
assert!(ready_error.to_string().contains("TicketMarkReady"));
|
||||
let queue_error = parse_ticket_args(&args(&["state", &ticket_id, "queued"]))
|
||||
.and_then(|cli| run_in_workspace(cli, temp.path()))
|
||||
.unwrap_err();
|
||||
assert!(queue_error.to_string().contains("TicketQueue"));
|
||||
let ready = run(&temp, &["state", &ticket_id, "ready"]);
|
||||
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
|
||||
let ready_listed = run(&temp, &["list", "--state", "ready"]);
|
||||
assert!(ready_listed.stdout.contains(&ticket_id));
|
||||
|
||||
let queued = run(&temp, &["state", &ticket_id, "queued"]);
|
||||
assert_eq!(queued.stdout, format!("state\t{}\tqueued\n", ticket_id));
|
||||
let queued_listed = run(&temp, &["list", "--state", "queued"]);
|
||||
assert!(queued_listed.stdout.contains(&ticket_id));
|
||||
|
||||
let inprogress = run(&temp, &["state", &ticket_id, "inprogress"]);
|
||||
assert_eq!(
|
||||
inprogress.stdout,
|
||||
format!("state\t{}\tinprogress\n", ticket_id)
|
||||
);
|
||||
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
|
||||
assert!(inprogress_listed.stdout.contains(&ticket_id));
|
||||
|
||||
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
|
||||
.and_then(|cli| run_in_workspace(cli, temp.path()))
|
||||
|
||||
@@ -31,15 +31,14 @@ Maintainers can inspect the local `.yoi/tickets/` files directly when debugging
|
||||
|
||||
## Ticket tools inside Workers
|
||||
|
||||
Workers with the Ticket and operation-specific Merge Request built-in features can use typed workflow tools:
|
||||
Workers with the Ticket built-in feature can use typed Ticket tools:
|
||||
|
||||
- `TicketCreate`
|
||||
- `QueryTicket` — bounded authoritative Ticket discovery with typed state/text/event/evidence/relation/Objective/time/attention filters, stable snippets, and cursor metadata.
|
||||
- `ShowTicket` — detailed authority for one Ticket, including item revision, bounded thread/event references, relations, linked Objectives, implementation reports, and current Merge Request/review evidence.
|
||||
- `TicketComment`
|
||||
- Coder: `MergeRequestShow`, `MergeRequestOpen`
|
||||
- Reviewer: `MergeRequestShow`, `MergeRequestReview` — available only inside the attested direct-child Reviewer request; grant and subject-ref capability material are not model input.
|
||||
- Orchestrator: `MergeRequestShow`, `MergeRequestReadinessCheck`, `MergeRequestComplete`
|
||||
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
|
||||
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
|
||||
- `TicketClose`
|
||||
- `TicketRelationRecord`
|
||||
|
||||
@@ -244,7 +243,7 @@ Implementation normally happens in a child git worktree created by the Orchestra
|
||||
|
||||
The assigned Coder launches the Reviewer as an actual direct-child `builtin:reviewer` SubWorker with read-only scope and a structured handoff bound to the current immutable Merge Request revision. Server authority revalidates the parent assignment, Runtime-owned child session, effective profile, one-shot review attempt, and revision; prose output is not approval.
|
||||
|
||||
The Reviewer records the structured result with `MergeRequestReview`. Request changes requires a new immutable revision and a fresh child attempt. The Orchestrator uses `MergeRequestReadinessCheck` and then `MergeRequestComplete` for guarded integration with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
|
||||
The Reviewer records the structured result with `MergeRequestReviewSubmit`. Request changes requires a new immutable revision and a fresh child attempt. `MergeRequestComplete` performs guarded Ticket completion with operation-id dedupe/CAS semantics; Flow transitions are not completion authority.
|
||||
|
||||
Blockers must be fixed or explicitly escalated before merge-ready submission.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
};
|
||||
|
||||
review = {
|
||||
instructions = "Spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The child must commit MergeRequestReview; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
|
||||
instructions = "Spawn one actual direct-child SubWorker with profile builtin:reviewer, read-only scope, and a structured review handoff bound to the current immutable Merge Request revision. The child must commit MergeRequestReviewSubmit; prose output and Worker observation are not approval authority. After the structured current-revision result exists, request a Flow transition.";
|
||||
transitions = {
|
||||
approved = {
|
||||
target = "complete";
|
||||
@@ -39,17 +39,17 @@
|
||||
};
|
||||
|
||||
complete = {
|
||||
instructions = "Leave concise implementation and validation evidence on the Ticket, then hand off the exact approved Merge Request revision to the Orchestrator for readiness and integration. Do not call MergeRequestComplete; Coder approval handoff is not Ticket completion authority. After durable handoff evidence exists, request a Flow transition.";
|
||||
instructions = "The exact current Merge Request subject has authoritative approval. Leave a concise human-facing summary only when useful, then hand off to the Orchestrator. Do not call MergeRequestComplete; approval and the current MR revision are the durable completion evidence. Request a Flow transition only after the Orchestrator's authoritative completion changes the Ticket to done.";
|
||||
transitions = {
|
||||
completed = {
|
||||
target = "done";
|
||||
condition = "The exact approved Merge Request revision and implementation evidence have been durably handed off to the Orchestrator. A Flow state or prose report alone is never Ticket completion authority.";
|
||||
condition = "The Orchestrator completed the current approved Merge Request and the authoritative Ticket state is done. A Flow state or prose report alone is never sufficient.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
done = {
|
||||
instructions = "The approved implementation has been handed off for Orchestrator-owned readiness and integration. Flow terminal state only reflects that handoff.";
|
||||
instructions = "The guarded Merge Request completion operation committed Ticket state done. Flow terminal state only reflects that durable authority.";
|
||||
terminal = true;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,13 +30,6 @@ feature = {
|
||||
worker = { enabled = false; };
|
||||
objective = { enabled = true; };
|
||||
ticket = { enabled = true; authoring = true; thread = true; };
|
||||
merge_request = {
|
||||
show = false;
|
||||
open = false;
|
||||
review = false;
|
||||
readiness_check = false;
|
||||
complete = false;
|
||||
};
|
||||
};
|
||||
|
||||
memory = {
|
||||
|
||||
@@ -12,12 +12,5 @@ import "./base.dcdl" // {
|
||||
flow = { enabled = true; };
|
||||
worker = { enabled = true; };
|
||||
ticket = { enabled = true; thread = true; };
|
||||
merge_request = {
|
||||
show = true;
|
||||
open = true;
|
||||
review = false;
|
||||
readiness_check = false;
|
||||
complete = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,13 +12,6 @@ import "./base.dcdl" // {
|
||||
worker = { enabled = true; direct_spawn = false; };
|
||||
manage_workdir = { enabled = true; };
|
||||
ticket = { enabled = true; thread = true; workflow = true; };
|
||||
merge_request = {
|
||||
show = true;
|
||||
open = false;
|
||||
review = false;
|
||||
readiness_check = true;
|
||||
complete = true;
|
||||
};
|
||||
orchestration = { enabled = true; };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,12 +11,5 @@ import "./base.dcdl" // {
|
||||
sub_worker = { enabled = false; };
|
||||
worker = { enabled = false; };
|
||||
ticket = { enabled = true; thread = false; };
|
||||
merge_request = {
|
||||
show = true;
|
||||
open = false;
|
||||
review = true;
|
||||
readiness_check = false;
|
||||
complete = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ let
|
||||
defaultDocument = import "./default.md";
|
||||
commonLanguage = import "./common/language.md";
|
||||
commonGit = import "./common/git.md";
|
||||
commonMergeRequest = import "./common/merge-request.md";
|
||||
commonTickets = import "./common/tickets.md";
|
||||
commonToolUsage = import "./common/tool-usage.md";
|
||||
commonWorkerObservation = import "./common/worker-observation.md";
|
||||
@@ -37,7 +36,6 @@ in
|
||||
common = {
|
||||
git = commonGit.content;
|
||||
language = commonLanguage.content;
|
||||
merge_request = commonMergeRequest.content;
|
||||
tickets = commonTickets.content;
|
||||
tool_usage = commonToolUsage.content;
|
||||
worker_observation = commonWorkerObservation.content;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
## Merge Request workflow
|
||||
|
||||
Use only the exposed Merge Request operations; their availability expresses this Worker's workflow responsibility, not authorization to bypass Backend validation.
|
||||
{% if "MergeRequestShow" in tools %}
|
||||
- Reread the current Merge Request and append-only thread with `MergeRequestShow` before making review or integration decisions.
|
||||
{% endif %}
|
||||
{% if "MergeRequestOpen" in tools %}
|
||||
- Open the Merge Request only after all intended changes are committed and the Workdir is clean. Use immutable source and target selectors; do not infer target authority from a branch name or cwd.
|
||||
- Before requesting independent review, make the exact current MR revision authoritative.
|
||||
{% endif %}
|
||||
{% if "MergeRequestReview" in tools %}
|
||||
- Review the exact current immutable MR revision independently. Submit the authoritative verdict through `MergeRequestReview`; prose alone is not approval.
|
||||
{% endif %}
|
||||
{% if "MergeRequestReadinessCheck" in tools %}
|
||||
- Use `MergeRequestReadinessCheck` to resolve current refs and authoritative review readiness before integration.
|
||||
{% endif %}
|
||||
{% if "MergeRequestComplete" in tools %}
|
||||
- Complete integration only after readiness confirms approval for the exact current revision and all target/ref guards pass. Merge completion is separate from implementation and review evidence.
|
||||
{% endif %}
|
||||
@@ -1,6 +1,6 @@
|
||||
## Ticket workflow
|
||||
|
||||
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
|
||||
Use the available typed Ticket tools as the authority for Ticket reads and mutations. Use `QueryTicket` for bounded discovery and filtering, then `ShowTicket` for the authoritative item revision, thread/evidence, relations, linked Objectives, and current Merge Request context before routing, review, or closure decisions. Current linked-MR evidence is completion authority; `implementation_report` entries are optional historical/audit context and must not be required for integration readiness. Do not invoke a Ticket CLI or edit backend storage directly as an alternative implementation of those tools.
|
||||
|
||||
Read the relevant Ticket before making implementation, routing, review, state, or closure decisions. Do not infer the current contract from an id, title, notification, or remembered summary alone. Check related or potentially duplicate Tickets when creating or materially rescoping work.
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ Treat the first committed user message as the bounded Ticket/action context and
|
||||
|
||||
{% include "common.git" %}
|
||||
|
||||
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReview` through its injected capability authority.
|
||||
Before review, open a Merge Request with immutable `selector_from` / `selector_to`. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and pass only the Ticket id in the structured review handoff. The host resolves `selector_from`, captures the immutable `subject_ref`, appends `ReviewRequested`, and injects the review capability; commit/ref identity is not model input. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected capability authority.
|
||||
|
||||
A request-changes result requires a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. After the exact current Merge Request revision has authoritative approval, leave concise implementation evidence on the Ticket and hand off integration to the Orchestrator. Do not call `MergeRequestComplete`.
|
||||
A request-changes result requires a fresh Reviewer child request. Flow terminal state is not Ticket completion authority. After the exact current Merge Request subject has an authoritative approval, leave a concise human-facing summary when useful and hand off to the Orchestrator. Do not call `MergeRequestComplete`; approval and the current MR revision are the durable completion evidence.
|
||||
|
||||
@@ -6,6 +6,8 @@ Keep durable orchestration behavior here and treat the first committed user mess
|
||||
|
||||
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
|
||||
|
||||
Treat the current linked Merge Request as implementation-completion authority. A current provider-resolved source ref, commit/repository evidence, an effective approval for that exact subject, review freshness after the latest substantive Ticket item edit, and no unresolved request-changes are sufficient; do not require an `implementation_report`. Human summaries remain optional audit context. Recheck `ShowTicket` and `MergeRequestReadinessCheck` immediately before guarded completion, and only the Orchestrator may call `MergeRequestComplete`.
|
||||
|
||||
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
|
||||
|
||||
Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots.
|
||||
|
||||
@@ -2,6 +2,6 @@ You are the Ticket Reviewer role running as an actual Runtime-owned direct child
|
||||
|
||||
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the host-captured `ReviewRequested.subject_ref` against Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Use read-only inspection and focused validation; do not merge, close, mutate the Workdir, or take over implementation.
|
||||
|
||||
Your prose response is not review authority. Before finishing, call `MergeRequestReview` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work.
|
||||
Your prose response is not review authority. Before finishing, call `MergeRequestReviewSubmit` exactly once with `approve` or `request_changes`, a bounded evidence summary, and concrete structured findings. Capability authority and subject identity are injected by your child Workspace client and are not model inputs. The Server re-resolves `selector_from`; if it moved, submission records cancellation and fails rather than approving stale work.
|
||||
|
||||
Review more than the diff: verify the implementation satisfies the Ticket intent and acceptance criteria, remains coherent with the codebase design, and does not introduce unnecessary compatibility.
|
||||
|
||||
@@ -69,23 +69,27 @@ export type TicketAssignmentSummary = {
|
||||
|
||||
export type TicketMergeRequestSummary = {
|
||||
merge_request_id: string;
|
||||
repository_id: string;
|
||||
state: string;
|
||||
review_status: string;
|
||||
selector_from: string | null;
|
||||
selector_to: string;
|
||||
updated_at: string;
|
||||
current_subject_ref: string | null;
|
||||
review_subject_ref: string | null;
|
||||
review_requested_at: string | null;
|
||||
review_submitted_at: string | null;
|
||||
review_excerpt: string | null;
|
||||
};
|
||||
|
||||
export type TicketEvidenceSummary = {
|
||||
has_implementation_report: boolean;
|
||||
implementation_report_after_rescope: boolean;
|
||||
has_merge_request: boolean;
|
||||
has_current_subject_ref: boolean;
|
||||
has_review_request: boolean;
|
||||
has_commit: boolean;
|
||||
review_status: string | null;
|
||||
approved: boolean;
|
||||
approved_current_subject: boolean;
|
||||
review_after_rescope: boolean;
|
||||
unresolved_request_changes: boolean;
|
||||
complete_for_integration: boolean;
|
||||
missing: Array<string>;
|
||||
|
||||
@@ -247,11 +247,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
assert(
|
||||
ticketDetailLoad.includes("/repositories") &&
|
||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||
ticketDetailPage.includes('mutate("ready", "/ready"') &&
|
||||
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
||||
ticketDetailPage.includes("targetCandidateValid") &&
|
||||
ticketDetailPage.includes("persistedTargetValid") &&
|
||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||
ticketDetailPage.includes("merged_result_commit") &&
|
||||
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
import type {
|
||||
RepositoryListResponse,
|
||||
RepositorySummary,
|
||||
TicketDetail,
|
||||
} from "$lib/workspace/sidebar/types";
|
||||
|
||||
@@ -66,9 +65,7 @@
|
||||
thread: MergeRequestThreadEvent[];
|
||||
};
|
||||
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
|
||||
state !== "done" && state !== "ready" && state !== "queued"
|
||||
);
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
|
||||
|
||||
const { data } = $props<{
|
||||
data: {
|
||||
@@ -118,27 +115,6 @@
|
||||
let resolution = $state("");
|
||||
let busy = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let readyOperationKey = $state<string | null>(null);
|
||||
const selectedRepository = $derived(
|
||||
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
|
||||
);
|
||||
const effectiveRefSelector = $derived(refSelector.trim() || selectedRepository?.default_ref || "");
|
||||
const targetCandidateValid = $derived(
|
||||
ticket.state === "planning" &&
|
||||
selectedRepository !== null &&
|
||||
(selectedRepository.diagnostics ?? []).length === 0 &&
|
||||
effectiveRefSelector.length > 0,
|
||||
);
|
||||
const persistedTargetValid = $derived(
|
||||
ticket.repository_id !== null &&
|
||||
ticket.ref_selector !== null &&
|
||||
(loadedRepositories?.items ?? []).some((repository: RepositorySummary) =>
|
||||
repository.id === ticket.repository_id && (repository.diagnostics ?? []).length === 0
|
||||
),
|
||||
);
|
||||
const implementationStartEligible = $derived(
|
||||
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
|
||||
);
|
||||
|
||||
const ticketPath = $derived(
|
||||
workspaceApiPath(
|
||||
@@ -204,33 +180,6 @@
|
||||
}, "PATCH");
|
||||
}
|
||||
|
||||
async function markReady() {
|
||||
if (!targetCandidateValid || busy) return;
|
||||
if (
|
||||
ticket.repository_id !== repositoryId ||
|
||||
(ticket.ref_selector ?? "") !== refSelector.trim()
|
||||
) {
|
||||
const saved = await mutate("target", "", {
|
||||
target: {
|
||||
action: "set",
|
||||
repository_id: repositoryId,
|
||||
ref_selector: refSelector.trim() || null,
|
||||
},
|
||||
}, "PATCH");
|
||||
if (!saved) return;
|
||||
}
|
||||
readyOperationKey ??= crypto.randomUUID();
|
||||
if (
|
||||
await mutate("ready", "/ready", {
|
||||
operation_key: readyOperationKey,
|
||||
reason: transitionReason.trim() || null,
|
||||
})
|
||||
) {
|
||||
readyOperationKey = null;
|
||||
transitionReason = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
@@ -380,19 +329,16 @@
|
||||
<p class="ticket-assignment-line">
|
||||
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
||||
</p>
|
||||
{#if orchestratorOnline && implementationStartEligible}
|
||||
<p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
|
||||
{#if orchestratorOnline}
|
||||
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
|
||||
<div class="ticket-role-actions">
|
||||
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="workspace-callout">
|
||||
{orchestratorOnline
|
||||
? "Validate and persist the repository target before starting a Ticket Worker."
|
||||
: "Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers."}
|
||||
</p>
|
||||
<p class="workspace-callout">Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers.</p>
|
||||
<div class="ticket-role-actions">
|
||||
<button class="workspace-primary-button" type="button" disabled>Coder</button>
|
||||
<button class="workspace-secondary-button" type="button" disabled>Reviewer</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -401,15 +347,15 @@
|
||||
<header><h2>Repository target</h2></header>
|
||||
<form class="ticket-control-form" onsubmit={saveTarget}>
|
||||
<label>Repository
|
||||
<select bind:value={repositoryId} disabled={ticket.state !== "planning"}>
|
||||
<select bind:value={repositoryId}>
|
||||
<option value="">Not assigned</option>
|
||||
{#each loadedRepositories?.items ?? [] as repository}
|
||||
<option value={repository.id}>{repository.display_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>Ref selector<input bind:value={refSelector} placeholder={selectedRepository?.default_ref ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
|
||||
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}>
|
||||
{busy === "target" ? "Saving…" : "Save target"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -428,15 +374,8 @@
|
||||
Apply state
|
||||
</button>
|
||||
</form>
|
||||
{#if ticket.state === "planning"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy !== null || !targetCandidateValid} onclick={markReady}>
|
||||
{busy === "ready" ? "Marking ready…" : "Mark ready"}
|
||||
</button>
|
||||
{#if !targetCandidateValid}
|
||||
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
|
||||
{/if}
|
||||
{:else if ticket.state === "ready"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline || !persistedTargetValid} onclick={() => mutate("queue", "/queue", {})}>
|
||||
{#if ticket.state === "ready"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline} onclick={() => mutate("queue", "/queue", {})}>
|
||||
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user