feat: add merge request review authority
This commit is contained in:
+16
-149
@@ -295,7 +295,6 @@ pub enum TicketEventKind {
|
||||
Plan,
|
||||
Decision,
|
||||
ImplementationReport,
|
||||
Review,
|
||||
StateChanged,
|
||||
IntakeSummary,
|
||||
StatusChanged,
|
||||
@@ -311,7 +310,6 @@ impl TicketEventKind {
|
||||
Self::Plan => "plan",
|
||||
Self::Decision => "decision",
|
||||
Self::ImplementationReport => "implementation_report",
|
||||
Self::Review => "review",
|
||||
Self::StateChanged => "state_changed",
|
||||
Self::IntakeSummary => "intake_summary",
|
||||
Self::StatusChanged => "status_changed",
|
||||
@@ -327,7 +325,6 @@ impl TicketEventKind {
|
||||
Self::Plan => "Plan".to_string(),
|
||||
Self::Decision => "Decision".to_string(),
|
||||
Self::ImplementationReport => "Implementation report".to_string(),
|
||||
Self::Review => "Review".to_string(),
|
||||
Self::StateChanged => "State changed".to_string(),
|
||||
Self::IntakeSummary => "Intake summary".to_string(),
|
||||
Self::StatusChanged => "Status changed".to_string(),
|
||||
@@ -345,7 +342,7 @@ impl From<&str> for TicketEventKind {
|
||||
"plan" => Self::Plan,
|
||||
"decision" => Self::Decision,
|
||||
"implementation_report" => Self::ImplementationReport,
|
||||
"review" => Self::Review,
|
||||
"review" => Self::Comment,
|
||||
"state_changed" => Self::StateChanged,
|
||||
"intake_summary" => Self::IntakeSummary,
|
||||
"status_changed" => Self::StatusChanged,
|
||||
@@ -355,42 +352,6 @@ impl From<&str> for TicketEventKind {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketReviewResult {
|
||||
Approve,
|
||||
RequestChanges,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl TicketReviewResult {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Approve => "approve",
|
||||
Self::RequestChanges => "request_changes",
|
||||
Self::Other(value) => value.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn heading(&self) -> String {
|
||||
match self {
|
||||
Self::Approve => "Review: approve".to_string(),
|
||||
Self::RequestChanges => "Review: request changes".to_string(),
|
||||
Self::Other(value) => format!("Review: {value}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for TicketReviewResult {
|
||||
fn from(value: &str) -> Self {
|
||||
match value {
|
||||
"approve" => Self::Approve,
|
||||
"request_changes" => Self::RequestChanges,
|
||||
other => Self::Other(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketReference {
|
||||
pub kind: String,
|
||||
@@ -461,31 +422,6 @@ impl TicketIntakeSummary {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TicketReview {
|
||||
pub result: TicketReviewResult,
|
||||
pub author: Option<String>,
|
||||
pub body: MarkdownText,
|
||||
}
|
||||
|
||||
impl TicketReview {
|
||||
pub fn approve(body: impl Into<MarkdownText>) -> Self {
|
||||
Self {
|
||||
result: TicketReviewResult::Approve,
|
||||
author: None,
|
||||
body: body.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_changes(body: impl Into<MarkdownText>) -> Self {
|
||||
Self {
|
||||
result: TicketReviewResult::RequestChanges,
|
||||
author: None,
|
||||
body: body.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NewTicket {
|
||||
pub title: String,
|
||||
@@ -1578,7 +1514,6 @@ pub trait TicketBackend {
|
||||
change: TicketStateChange,
|
||||
) -> Result<()>;
|
||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()>;
|
||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
||||
fn add_ticket_relation(
|
||||
&self,
|
||||
@@ -1656,10 +1591,6 @@ pub enum TicketBackendOperation {
|
||||
id: TicketIdOrSlug,
|
||||
queued_by: String,
|
||||
},
|
||||
Review {
|
||||
id: TicketIdOrSlug,
|
||||
review: TicketReview,
|
||||
},
|
||||
Close {
|
||||
id: TicketIdOrSlug,
|
||||
resolution: MarkdownText,
|
||||
@@ -1763,10 +1694,6 @@ where
|
||||
backend.queue_ready(id, &queued_by)?;
|
||||
TicketBackendOperationResult::Unit
|
||||
}
|
||||
TicketBackendOperation::Review { id, review } => {
|
||||
backend.review(id, review)?;
|
||||
TicketBackendOperationResult::Unit
|
||||
}
|
||||
TicketBackendOperation::Close { id, resolution } => {
|
||||
backend.close(id, resolution)?;
|
||||
TicketBackendOperationResult::Unit
|
||||
@@ -3201,34 +3128,6 @@ impl TicketBackend for SqliteTicketBackend {
|
||||
})
|
||||
}
|
||||
|
||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
|
||||
self.with_write(|conn| {
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
let at = now_utc();
|
||||
let mut attributes = BTreeMap::new();
|
||||
attributes.insert("result".to_string(), review.result.as_str().to_string());
|
||||
self.insert_event(
|
||||
conn,
|
||||
&ticket_id,
|
||||
&TicketEvent {
|
||||
kind: TicketEventKind::Review,
|
||||
author: Some(review.author.unwrap_or_else(default_author)),
|
||||
at: Some(at.clone()),
|
||||
status: Some(review.result.as_str().to_string()),
|
||||
from: None,
|
||||
to: None,
|
||||
reason: None,
|
||||
state_field: None,
|
||||
heading: Some(review.result.heading()),
|
||||
body: review.body,
|
||||
references: Vec::new(),
|
||||
attributes,
|
||||
},
|
||||
)?;
|
||||
self.touch_ticket(conn, &ticket_id, &at)
|
||||
})
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
||||
self.with_write(|conn| {
|
||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||
@@ -3804,21 +3703,6 @@ impl TicketBackend for LocalTicketBackend {
|
||||
)
|
||||
}
|
||||
|
||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
|
||||
let _lock = self.acquire_lock()?;
|
||||
let dir = self.find_ticket_dir(&id)?;
|
||||
let author = review.author.unwrap_or_else(default_author);
|
||||
self.append_thread_event(
|
||||
&dir,
|
||||
"review",
|
||||
&review.result.heading(),
|
||||
&author,
|
||||
Some(review.result.as_str()),
|
||||
&[],
|
||||
&review.body,
|
||||
)
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
||||
let _lock = self.acquire_lock()?;
|
||||
self.ensure_backend_dirs()?;
|
||||
@@ -5337,7 +5221,8 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
|
||||
.strip_prefix("<!-- ")
|
||||
.and_then(|v| v.strip_suffix(" -->"))
|
||||
{
|
||||
let attrs = parse_event_comment(comment);
|
||||
let mut attrs = parse_event_comment(comment);
|
||||
let legacy_review = attrs.get("event").is_some_and(|value| value == "review");
|
||||
let kind = attrs
|
||||
.get("event")
|
||||
.map(|value| TicketEventKind::from(value.as_str()))
|
||||
@@ -5369,11 +5254,22 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
|
||||
while body.ends_with('\n') {
|
||||
body.pop();
|
||||
}
|
||||
if legacy_review {
|
||||
heading = Some("Legacy review (non-authoritative)".to_string());
|
||||
attrs.remove("status");
|
||||
attrs.remove("result");
|
||||
attrs.insert("event".to_string(), "comment".to_string());
|
||||
attrs.insert("legacy_event_kind".to_string(), "review".to_string());
|
||||
}
|
||||
events.push(TicketEvent {
|
||||
kind,
|
||||
author: attrs.get("author").cloned(),
|
||||
at: attrs.get("at").cloned(),
|
||||
status: attrs.get("status").cloned(),
|
||||
status: if legacy_review {
|
||||
None
|
||||
} else {
|
||||
attrs.get("status").cloned()
|
||||
},
|
||||
from: attrs.get("from").cloned(),
|
||||
to: attrs.get("to").cloned(),
|
||||
reason: attrs.get("reason").cloned(),
|
||||
@@ -6379,12 +6275,6 @@ state: planning
|
||||
NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."),
|
||||
)
|
||||
.unwrap();
|
||||
backend
|
||||
.review(
|
||||
TicketIdOrSlug::Id(created.id.clone()),
|
||||
TicketReview::approve("Looks good."),
|
||||
)
|
||||
.unwrap();
|
||||
backend
|
||||
.close(
|
||||
TicketIdOrSlug::Id(created.id.clone()),
|
||||
@@ -6404,13 +6294,6 @@ state: planning
|
||||
assert!(ticket.events.iter().any(|event| {
|
||||
event.kind == TicketEventKind::Comment && event.body.0.contains("Imported into SQLite")
|
||||
}));
|
||||
assert!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::Review
|
||||
&& event.body.0.contains("Looks good"))
|
||||
);
|
||||
assert!(
|
||||
ticket
|
||||
.resolution
|
||||
@@ -6524,7 +6407,7 @@ state: planning
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_event_review_status_and_close_preserve_local_layout() {
|
||||
fn add_event_status_and_close_preserve_local_layout() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
|
||||
@@ -6534,12 +6417,6 @@ state: planning
|
||||
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
||||
)
|
||||
.unwrap();
|
||||
backend
|
||||
.review(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
TicketReview::approve("Looks good."),
|
||||
)
|
||||
.unwrap();
|
||||
let mut summary = TicketIntakeSummary::new("Ready for queue.");
|
||||
summary.author = Some("test".to_string());
|
||||
let mut change = TicketStateChange::new(
|
||||
@@ -6563,8 +6440,6 @@ state: planning
|
||||
let closed_dir = tmp.path().join("tickets").join(&ticket.id);
|
||||
assert!(closed_dir.join("resolution.md").exists());
|
||||
let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap();
|
||||
assert!(thread.contains("<!-- event: review"));
|
||||
assert!(thread.contains("status: approve"));
|
||||
assert!(thread.contains("<!-- event: close"));
|
||||
let report = backend.doctor().unwrap();
|
||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
||||
@@ -6592,14 +6467,6 @@ state: planning
|
||||
));
|
||||
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
|
||||
|
||||
let mut review = TicketReview::approve("This must not append either.");
|
||||
review.author = Some("bad-->author".into());
|
||||
assert!(matches!(
|
||||
backend.review(TicketIdOrSlug::Id(ticket.id.clone()), review),
|
||||
Err(TicketError::Conflict(_))
|
||||
));
|
||||
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
|
||||
|
||||
let invalid_kind = NewTicketEvent::new(
|
||||
TicketEventKind::Other("bad\nevent".into()),
|
||||
"Invalid event kind.",
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
|
||||
|
||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
||||
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 2;
|
||||
pub const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 3;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Migration {
|
||||
@@ -27,6 +27,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "add_ticket_repository_target",
|
||||
apply: add_ticket_repository_target,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "convert_legacy_reviews_to_comments",
|
||||
apply: retire_legacy_ticket_review_events,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -475,6 +480,33 @@ fn add_ticket_repository_target(connection: &Connection) -> Result<()> {
|
||||
add_column_if_missing(connection, "typed_tickets", "ref_selector", "TEXT")
|
||||
}
|
||||
|
||||
fn retire_legacy_ticket_review_events(connection: &Connection) -> Result<()> {
|
||||
// Historical prose remains visible for audit, but it is explicitly converted to a
|
||||
// non-authoritative comment. Approval authority now lives only in Merge Requests.
|
||||
connection
|
||||
.execute_batch(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO typed_ticket_event_attributes
|
||||
(workspace_id, ticket_id, event_index, key, value)
|
||||
SELECT workspace_id, ticket_id, event_index, 'legacy_event_kind', 'review'
|
||||
FROM typed_ticket_events WHERE kind = 'review';
|
||||
UPDATE typed_ticket_events
|
||||
SET kind = 'comment', status = NULL, heading = 'Legacy review (non-authoritative)'
|
||||
WHERE kind = 'review';
|
||||
DELETE FROM typed_ticket_event_attributes
|
||||
WHERE key IN ('result', 'review_result', 'status')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM typed_ticket_events event
|
||||
WHERE event.workspace_id = typed_ticket_event_attributes.workspace_id
|
||||
AND event.ticket_id = typed_ticket_event_attributes.ticket_id
|
||||
AND event.event_index = typed_ticket_event_attributes.event_index
|
||||
AND event.heading = 'Legacy review (non-authoritative)'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn add_column_if_missing(
|
||||
connection: &Connection,
|
||||
table: &str,
|
||||
@@ -809,10 +841,10 @@ mod tests {
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
|
||||
let versions = load_applied_migrations(&connection).unwrap();
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert_eq!(versions.len(), 3);
|
||||
assert_eq!(
|
||||
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
|
||||
Some(&"add_ticket_repository_target".to_string())
|
||||
Some(&"convert_legacy_reviews_to_comments".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -957,7 +989,7 @@ mod tests {
|
||||
.to_string()
|
||||
.contains("unsupported Ticket schema migration version 99")
|
||||
);
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 3);
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1050,6 +1082,31 @@ mod tests {
|
||||
assert!(!migration_table_exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_review_upgrade_preserves_prose_as_non_authoritative_comment() {
|
||||
let connection = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
connection.execute("INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES ('workspace-1','ticket-1','ticket-1','title','open','task','medium','body','inprogress',1)",[]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,at,status,heading,body) VALUES ('workspace-1','ticket-1',0,'review','reviewer','2026-08-11T00:00:00Z','approve','Review','legacy evidence')",[]).unwrap();
|
||||
connection.execute("INSERT INTO typed_ticket_event_attributes (workspace_id,ticket_id,event_index,key,value) VALUES ('workspace-1','ticket-1',0,'result','approve')",[]).unwrap();
|
||||
connection
|
||||
.execute("DELETE FROM ticket_schema_migrations WHERE version=3", [])
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&connection).unwrap();
|
||||
let (kind,status,heading,body):(String,Option<String>,Option<String>,Option<String>)=connection.query_row("SELECT kind,status,heading,body FROM typed_ticket_events WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND event_index=0",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
|
||||
assert_eq!(kind, "comment");
|
||||
assert_eq!(status, None);
|
||||
assert_eq!(
|
||||
heading.as_deref(),
|
||||
Some("Legacy review (non-authoritative)")
|
||||
);
|
||||
assert_eq!(body.as_deref(), Some("legacy evidence"));
|
||||
let attributes:i64=connection.query_row("SELECT COUNT(*) FROM typed_ticket_event_attributes WHERE workspace_id='workspace-1' AND ticket_id='ticket-1'",[],|row|row.get(0)).unwrap();
|
||||
assert_eq!(attributes, 1);
|
||||
let legacy:String=connection.query_row("SELECT value FROM typed_ticket_event_attributes WHERE workspace_id='workspace-1' AND ticket_id='ticket-1' AND key='legacy_event_kind'",[],|row|row.get(0)).unwrap();
|
||||
assert_eq!(legacy, "review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_migrators_converge_on_one_version_history() {
|
||||
let directory = tempdir().unwrap();
|
||||
@@ -1072,6 +1129,6 @@ mod tests {
|
||||
|
||||
let connection = Connection::open(database).unwrap();
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ use crate::{
|
||||
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
|
||||
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind,
|
||||
TicketRelationView, TicketReview, TicketReviewResult, TicketStateChange, TicketSummary,
|
||||
TicketWorkflowState, default_author,
|
||||
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||
@@ -34,7 +33,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
||||
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||
|
||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
|
||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
@@ -43,7 +42,6 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -69,7 +67,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||
|
||||
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
pub const TICKET_TOOL_NAMES: [&str; 18] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
@@ -78,7 +76,6 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -100,14 +97,13 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -134,8 +130,6 @@ 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 REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
|
||||
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
|
||||
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`.";
|
||||
@@ -175,7 +169,6 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||
"TicketPlan" => PLAN_DESCRIPTION,
|
||||
"TicketDecision" => DECISION_DESCRIPTION,
|
||||
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
||||
"TicketReview" => REVIEW_DESCRIPTION,
|
||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||
@@ -319,10 +312,6 @@ impl TicketBackend for TicketToolBackend {
|
||||
self.backend.queue_ready(id, queued_by)
|
||||
}
|
||||
|
||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> TicketResult<()> {
|
||||
self.backend.review(id, review)
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
||||
self.backend.close(id, resolution)
|
||||
}
|
||||
@@ -554,23 +543,6 @@ struct TicketThreadEventParams {
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketReviewResultParam {
|
||||
Approve,
|
||||
RequestChanges,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketReviewParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Review result: `approve` or `request_changes`.
|
||||
result: TicketReviewResultParam,
|
||||
/// Markdown review body.
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketIntakeReadyParams {
|
||||
/// Ticket id.
|
||||
@@ -839,11 +811,6 @@ struct TicketImplementationReportTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketReviewTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketIntakeReadyTool {
|
||||
backend: TicketToolBackend,
|
||||
@@ -1117,34 +1084,6 @@ impl_ticket_thread_event_tool!(
|
||||
TicketEventKind::ImplementationReport
|
||||
);
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketReviewTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketReviewParams = parse_input("TicketReview", input_json)?;
|
||||
let result = match params.result {
|
||||
TicketReviewResultParam::Approve => TicketReviewResult::Approve,
|
||||
TicketReviewResultParam::RequestChanges => TicketReviewResult::RequestChanges,
|
||||
};
|
||||
let result_str = result.as_str().to_string();
|
||||
let review = TicketReview {
|
||||
result,
|
||||
author: None,
|
||||
body: MarkdownText::new(params.body),
|
||||
};
|
||||
self.backend
|
||||
.review(TicketIdOrSlug::Query(params.ticket.clone()), review)
|
||||
.map_err(|error| backend_error("TicketReview", error))?;
|
||||
Ok(json_output(
|
||||
format!("Appended {result_str} review to ticket {}", params.ticket),
|
||||
json!({ "ticket": params.ticket, "review": result_str, "ok": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketIntakeReadyTool {
|
||||
async fn execute(
|
||||
@@ -1731,7 +1670,6 @@ fn input_schema(name: &str) -> Value {
|
||||
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
|
||||
}
|
||||
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
|
||||
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||
"TicketWorkflowState" => {
|
||||
@@ -1777,7 +1715,6 @@ impl_from_backend!(TicketCommentTool);
|
||||
impl_from_backend!(TicketPlanTool);
|
||||
impl_from_backend!(TicketDecisionTool);
|
||||
impl_from_backend!(TicketImplementationReportTool);
|
||||
impl_from_backend!(TicketReviewTool);
|
||||
impl_from_backend!(TicketIntakeReadyTool);
|
||||
impl_from_backend!(TicketQueueTool);
|
||||
impl_from_backend!(TicketWorkflowStateTool);
|
||||
@@ -1804,7 +1741,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||
"TicketImplementationReport",
|
||||
backend.clone(),
|
||||
),
|
||||
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
|
||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
||||
@@ -1880,7 +1816,6 @@ mod tests {
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketWorkflowState",
|
||||
@@ -2373,12 +2308,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_tools_comment_review_state_and_close_are_doctor_clean() {
|
||||
async fn ticket_tools_report_state_and_close_are_doctor_clean() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
||||
let report = tool_by_name(backend.clone(), "TicketImplementationReport");
|
||||
let review = tool_by_name(backend.clone(), "TicketReview");
|
||||
let close = tool_by_name(backend.clone(), "TicketClose");
|
||||
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
||||
|
||||
@@ -2393,18 +2327,6 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
review
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.id.clone(),
|
||||
"result": "approve",
|
||||
"body": "Looks good."
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
close
|
||||
.execute(
|
||||
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
|
||||
@@ -2427,12 +2349,6 @@ mod tests {
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
||||
);
|
||||
assert!(
|
||||
closed
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.kind == TicketEventKind::Review)
|
||||
);
|
||||
assert!(
|
||||
closed
|
||||
.events
|
||||
@@ -2852,7 +2768,6 @@ mod tests {
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketRelationRecord",
|
||||
|
||||
Reference in New Issue
Block a user