feat: add merge request review authority
This commit is contained in:
@@ -76,7 +76,7 @@ Intake は以下を行う。
|
|||||||
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
|
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
|
||||||
- `TicketDoctor`: 必要に応じた整合性確認。
|
- `TicketDoctor`: 必要に応じた整合性確認。
|
||||||
|
|
||||||
Intake は `TicketReview`, `TicketWorkflowState`, `TicketClose` を通常使わない。review / state transition / close は Orchestrator または reviewer / maintainer workflow の責務である。
|
Intake は `MergeRequest*`, `TicketWorkflowState`, `TicketClose` を通常使わない。review authority は assigned Coder が起動した read-only direct-child Reviewer の immutable Merge Request attempt に属し、completion / merge / close は各guarded workflowの責務である。
|
||||||
|
|
||||||
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
|
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
|
||||||
|
|
||||||
|
|||||||
Generated
+12
@@ -2481,6 +2481,17 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "merge-request"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -6164,6 +6175,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
"memory",
|
"memory",
|
||||||
|
"merge-request",
|
||||||
"project-record",
|
"project-record",
|
||||||
"protocol",
|
"protocol",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ members = [
|
|||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
@@ -50,6 +51,7 @@ default-members = [
|
|||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
@@ -67,6 +69,7 @@ manifest = { path = "crates/manifest" }
|
|||||||
mcp = { path = "crates/mcp" }
|
mcp = { path = "crates/mcp" }
|
||||||
lint-common = { path = "crates/lint-common" }
|
lint-common = { path = "crates/lint-common" }
|
||||||
memory = { path = "crates/memory" }
|
memory = { path = "crates/memory" }
|
||||||
|
merge-request = { path = "crates/merge-request" }
|
||||||
ticket = { path = "crates/ticket" }
|
ticket = { path = "crates/ticket" }
|
||||||
project-record = { path = "crates/project-record" }
|
project-record = { path = "crates/project-record" }
|
||||||
worker = { path = "crates/worker" }
|
worker = { path = "crates/worker" }
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "merge-request"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rusqlite.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
sha2.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
|||||||
|
use merge_request::*;
|
||||||
|
use rusqlite::{Connection, params};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn setup() -> (TempDir, SqliteMergeRequestStore) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("server.db");
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(r#"
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id));
|
||||||
|
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));
|
||||||
|
CREATE TABLE typed_ticket_events(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,kind TEXT NOT NULL,author TEXT,at TEXT,status TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));
|
||||||
|
CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,event_index INTEGER NOT NULL,key TEXT NOT NULL,value TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,event_index,key));
|
||||||
|
CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id));
|
||||||
|
CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));
|
||||||
|
"#).unwrap();
|
||||||
|
for ws in ["ws-a", "ws-b"] {
|
||||||
|
conn.execute("INSERT INTO repositories VALUES(?1,'repo')", params![ws])
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO typed_tickets VALUES(?1,'T1','inprogress',1,'t0')",
|
||||||
|
params![ws],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ticket_worker_assignments VALUES(?1,'T1','A1','R1','W1')",
|
||||||
|
params![ws],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ticket_current_worker_assignments VALUES(?1,'T1','A1','R1','W1')",
|
||||||
|
params![ws],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
drop(conn);
|
||||||
|
let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
|
||||||
|
(dir, store)
|
||||||
|
}
|
||||||
|
fn revision(id: &str, ordinal: u64, head: &str) -> MergeRequestRevision {
|
||||||
|
MergeRequestRevision {
|
||||||
|
revision_id: id.into(),
|
||||||
|
ordinal,
|
||||||
|
base_commit: "base".into(),
|
||||||
|
head_commit: head.into(),
|
||||||
|
head_tree: format!("tree-{head}"),
|
||||||
|
diff_digest: format!("sha256:diff-{head}"),
|
||||||
|
changed_paths: vec!["src/lib.rs".into()],
|
||||||
|
summary: format!("revision {id}"),
|
||||||
|
assignment_id: "A1".into(),
|
||||||
|
created_at: format!("t{ordinal}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn open(store: &SqliteMergeRequestStore) {
|
||||||
|
store
|
||||||
|
.open_merge_request(OpenMergeRequest {
|
||||||
|
merge_request_id: "MR1".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
repository_id: "repo".into(),
|
||||||
|
revision: revision("V1", 1, "h1"),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t1".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
fn attempt(store: &SqliteMergeRequestStore, id: &str, revision: &str, token: &str, child: &str) {
|
||||||
|
store
|
||||||
|
.register_reviewer_child_session(RegisterReviewerChildSession {
|
||||||
|
parent_runtime_id: "R1".into(),
|
||||||
|
parent_worker_id: "W1".into(),
|
||||||
|
child_session_id: child.into(),
|
||||||
|
now: "t".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.register_review_attempt(RegisterReviewAttempt {
|
||||||
|
attempt_id: id.into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
revision_id: revision.into(),
|
||||||
|
parent_assignment_id: "A1".into(),
|
||||||
|
parent_runtime_id: "R1".into(),
|
||||||
|
parent_worker_id: "W1".into(),
|
||||||
|
child_session_id: child.into(),
|
||||||
|
capability_token: token.into(),
|
||||||
|
now: "t".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
fn review(
|
||||||
|
store: &SqliteMergeRequestStore,
|
||||||
|
revision: &str,
|
||||||
|
token: &str,
|
||||||
|
decision: ReviewDecision,
|
||||||
|
) -> Result<MergeRequestReview> {
|
||||||
|
store.submit_review(SubmitReview {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
revision_id: revision.into(),
|
||||||
|
capability_token: token.into(),
|
||||||
|
decision,
|
||||||
|
body: "evidence".into(),
|
||||||
|
findings: vec![],
|
||||||
|
now: "tr".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storage_allows_multiple_merge_requests_for_one_ticket() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
store
|
||||||
|
.open_merge_request(OpenMergeRequest {
|
||||||
|
merge_request_id: "MR2".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
repository_id: "repo".into(),
|
||||||
|
revision: revision("V2", 1, "h2"),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t2".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let conn = Connection::open(store.db_path()).unwrap();
|
||||||
|
let count:i64=conn.query_row("SELECT COUNT(*) FROM merge_request_ticket_relations WHERE workspace_id='ws-a' AND ticket_id='T1'",[],|row|row.get(0)).unwrap();
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.show_for_ticket("T1")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.merge_request_id,
|
||||||
|
"MR2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_context_rejects_oversized_revision_evidence() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
let mut oversized = revision("V1", 1, "h1");
|
||||||
|
oversized.changed_paths = (0..=1_000).map(|i| format!("src/{i}.rs")).collect();
|
||||||
|
let result = store.open_merge_request(OpenMergeRequest {
|
||||||
|
merge_request_id: "MR1".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
repository_id: "repo".into(),
|
||||||
|
revision: oversized,
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t".into(),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(MergeRequestError::TooLarge {
|
||||||
|
field: "revision.changed_paths",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_v6_schema_missing_diff_digest_is_archived_before_fresh_v7() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("legacy.db");
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);\
|
||||||
|
INSERT INTO merge_request_schema_migrations(version,name) VALUES(6,'rejected_merge_request_v6');\
|
||||||
|
CREATE TABLE repositories(workspace_id TEXT NOT NULL,repository_id TEXT NOT NULL,PRIMARY KEY(workspace_id,repository_id));\
|
||||||
|
CREATE TABLE typed_tickets(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,workflow_state TEXT NOT NULL,workflow_state_explicit INTEGER NOT NULL DEFAULT 1,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));\
|
||||||
|
CREATE TABLE ticket_worker_assignments(workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,assignment_id TEXT NOT NULL,runtime_id TEXT NOT NULL,worker_id TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id,assignment_id));\
|
||||||
|
CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL,lifecycle_generation INTEGER NOT NULL,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id));\
|
||||||
|
CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id));\
|
||||||
|
CREATE TABLE merge_request_revisions(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,revision_id TEXT NOT NULL,ordinal INTEGER NOT NULL,base_commit TEXT NOT NULL,head_commit TEXT NOT NULL,head_tree TEXT NOT NULL,assignment_id TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,revision_id));",
|
||||||
|
).unwrap();
|
||||||
|
drop(conn);
|
||||||
|
let store = SqliteMergeRequestStore::open(&path, "ws-a").unwrap();
|
||||||
|
assert!(store.show_for_ticket("missing").unwrap().is_none());
|
||||||
|
let conn = Connection::open(&path).unwrap();
|
||||||
|
let archived: i64 = conn.query_row("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_v6_merge_requests'",[],|row|row.get(0)).unwrap();
|
||||||
|
assert_eq!(archived, 1);
|
||||||
|
for table in [
|
||||||
|
"merge_request_review_attempts",
|
||||||
|
"merge_request_completion_operations",
|
||||||
|
] {
|
||||||
|
let present: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
|
||||||
|
params![table],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(present, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_changes_new_revision_resets_and_exact_completion_replay_converges() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
attempt(&store, "AT1", "V1", "tok1", "child1");
|
||||||
|
review(&store, "V1", "tok1", ReviewDecision::RequestChanges).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.show_for_ticket("T1").unwrap().unwrap().review_status,
|
||||||
|
ReviewStatus::ChangesRequested
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.add_revision(AddRevision {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_current_revision_id: "V1".into(),
|
||||||
|
revision: revision("V2", 2, "h2"),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t2".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.show_for_ticket("T1").unwrap().unwrap().review_status,
|
||||||
|
ReviewStatus::Pending
|
||||||
|
);
|
||||||
|
assert!(review(&store, "V1", "tok1", ReviewDecision::Approve).is_err());
|
||||||
|
attempt(&store, "AT2", "V2", "tok2", "child2");
|
||||||
|
review(&store, "V2", "tok2", ReviewDecision::Approve).unwrap();
|
||||||
|
let input = CompleteMergeRequest {
|
||||||
|
operation_id: "OP1".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V2".into(),
|
||||||
|
assignment_id: "A1".into(),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "tc".into(),
|
||||||
|
};
|
||||||
|
let first = store.complete(input.clone()).unwrap();
|
||||||
|
assert!(!first.replayed);
|
||||||
|
let replay = store.complete(input).unwrap();
|
||||||
|
assert!(replay.replayed);
|
||||||
|
assert!(matches!(
|
||||||
|
store.confirm_merge(MergeConfirmation {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V2".into(),
|
||||||
|
authenticated_account_id: "runtime".into(),
|
||||||
|
actor_kind: "worker".into(),
|
||||||
|
explicit_confirmation: true,
|
||||||
|
now: "tm".into()
|
||||||
|
}),
|
||||||
|
Err(MergeRequestError::MergeConfirmationRequired)
|
||||||
|
));
|
||||||
|
let merged = store
|
||||||
|
.confirm_merge(MergeConfirmation {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V2".into(),
|
||||||
|
authenticated_account_id: "account-1".into(),
|
||||||
|
actor_kind: "user".into(),
|
||||||
|
explicit_confirmation: true,
|
||||||
|
now: "tm".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(merged.state, MergeRequestState::Merged);
|
||||||
|
let conn = Connection::open(store.db_path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT workflow_state FROM typed_tickets WHERE workspace_id='ws-a' AND ticket_id='T1'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, String>(0)
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"done"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='ws-a' AND ticket_id='T1'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, i64>(0)
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spoof_self_approval_replay_and_cross_workspace_are_rejected() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
let mut bad = RegisterReviewAttempt {
|
||||||
|
attempt_id: "bad".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
revision_id: "V1".into(),
|
||||||
|
parent_assignment_id: "A1".into(),
|
||||||
|
parent_runtime_id: "R1".into(),
|
||||||
|
parent_worker_id: "W1".into(),
|
||||||
|
child_session_id: "W1".into(),
|
||||||
|
capability_token: "bad".into(),
|
||||||
|
now: "t".into(),
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
store.register_review_attempt(bad.clone()),
|
||||||
|
Err(MergeRequestError::SelfApproval)
|
||||||
|
));
|
||||||
|
bad.child_session_id = "child".into();
|
||||||
|
assert!(matches!(
|
||||||
|
store.register_review_attempt(bad),
|
||||||
|
Err(MergeRequestError::InvalidReviewer)
|
||||||
|
));
|
||||||
|
attempt(&store, "AT", "V1", "secret", "child");
|
||||||
|
assert!(review(&store, "V1", "spoof", ReviewDecision::Approve).is_err());
|
||||||
|
review(&store, "V1", "secret", ReviewDecision::Approve).unwrap();
|
||||||
|
assert!(review(&store, "V1", "secret", ReviewDecision::Approve).is_err());
|
||||||
|
let other = SqliteMergeRequestStore::open_verified(store.db_path(), "ws-b").unwrap();
|
||||||
|
assert!(other.show_for_ticket("T1").unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopen_resets_approval_and_merge_requires_authenticated_explicit_user() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
attempt(&store, "AT", "V1", "token", "child");
|
||||||
|
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
|
||||||
|
store.close("T1", "V1", "tc").unwrap();
|
||||||
|
let reopened = store.reopen("T1", "V1", "tr").unwrap();
|
||||||
|
assert_eq!(reopened.review_status, ReviewStatus::Pending);
|
||||||
|
let denied = store.confirm_merge(MergeConfirmation {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V1".into(),
|
||||||
|
authenticated_account_id: "user".into(),
|
||||||
|
actor_kind: "user".into(),
|
||||||
|
explicit_confirmation: false,
|
||||||
|
now: "tm".into(),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
denied,
|
||||||
|
Err(MergeRequestError::MergeConfirmationRequired)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_exact_completion_replays_commit_one_ticket_side_effect() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
attempt(&store, "AT", "V1", "token", "child");
|
||||||
|
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
|
||||||
|
let input = CompleteMergeRequest {
|
||||||
|
operation_id: "OP-concurrent".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V1".into(),
|
||||||
|
assignment_id: "A1".into(),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t".into(),
|
||||||
|
};
|
||||||
|
let left_store = store.clone();
|
||||||
|
let left_input = input.clone();
|
||||||
|
let left = std::thread::spawn(move || left_store.complete(left_input));
|
||||||
|
let right_store = store.clone();
|
||||||
|
let right = std::thread::spawn(move || right_store.complete(input));
|
||||||
|
let outcomes = [
|
||||||
|
left.join().unwrap().unwrap(),
|
||||||
|
right.join().unwrap().unwrap(),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
outcomes.iter().filter(|outcome| !outcome.replayed).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcomes.iter().filter(|outcome| outcome.replayed).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let conn = Connection::open(store.db_path()).unwrap();
|
||||||
|
let events: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='ws-a' AND ticket_id='T1'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(events, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_key_mismatch_and_assignment_takeover_are_fenced() {
|
||||||
|
let (_dir, store) = setup();
|
||||||
|
open(&store);
|
||||||
|
attempt(&store, "AT", "V1", "token", "child");
|
||||||
|
review(&store, "V1", "token", ReviewDecision::Approve).unwrap();
|
||||||
|
let mut input = CompleteMergeRequest {
|
||||||
|
operation_id: "OP".into(),
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
expected_revision_id: "V1".into(),
|
||||||
|
assignment_id: "A1".into(),
|
||||||
|
authenticated_runtime_id: "R1".into(),
|
||||||
|
authenticated_worker_id: "W1".into(),
|
||||||
|
now: "t".into(),
|
||||||
|
};
|
||||||
|
let conn = Connection::open(store.db_path()).unwrap();
|
||||||
|
conn.execute("UPDATE ticket_current_worker_assignments SET assignment_id='A2',runtime_id='R2',worker_id='W2' WHERE workspace_id='ws-a' AND ticket_id='T1'",[]).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.complete(input.clone()),
|
||||||
|
Err(MergeRequestError::AssignmentMismatch)
|
||||||
|
));
|
||||||
|
conn.execute("UPDATE ticket_current_worker_assignments SET assignment_id='A1',runtime_id='R1',worker_id='W1' WHERE workspace_id='ws-a' AND ticket_id='T1'",[]).unwrap();
|
||||||
|
store.complete(input.clone()).unwrap();
|
||||||
|
input.expected_revision_id = "other".into();
|
||||||
|
assert!(matches!(
|
||||||
|
store.complete(input),
|
||||||
|
Err(MergeRequestError::OperationConflict)
|
||||||
|
));
|
||||||
|
}
|
||||||
+16
-149
@@ -295,7 +295,6 @@ pub enum TicketEventKind {
|
|||||||
Plan,
|
Plan,
|
||||||
Decision,
|
Decision,
|
||||||
ImplementationReport,
|
ImplementationReport,
|
||||||
Review,
|
|
||||||
StateChanged,
|
StateChanged,
|
||||||
IntakeSummary,
|
IntakeSummary,
|
||||||
StatusChanged,
|
StatusChanged,
|
||||||
@@ -311,7 +310,6 @@ impl TicketEventKind {
|
|||||||
Self::Plan => "plan",
|
Self::Plan => "plan",
|
||||||
Self::Decision => "decision",
|
Self::Decision => "decision",
|
||||||
Self::ImplementationReport => "implementation_report",
|
Self::ImplementationReport => "implementation_report",
|
||||||
Self::Review => "review",
|
|
||||||
Self::StateChanged => "state_changed",
|
Self::StateChanged => "state_changed",
|
||||||
Self::IntakeSummary => "intake_summary",
|
Self::IntakeSummary => "intake_summary",
|
||||||
Self::StatusChanged => "status_changed",
|
Self::StatusChanged => "status_changed",
|
||||||
@@ -327,7 +325,6 @@ impl TicketEventKind {
|
|||||||
Self::Plan => "Plan".to_string(),
|
Self::Plan => "Plan".to_string(),
|
||||||
Self::Decision => "Decision".to_string(),
|
Self::Decision => "Decision".to_string(),
|
||||||
Self::ImplementationReport => "Implementation report".to_string(),
|
Self::ImplementationReport => "Implementation report".to_string(),
|
||||||
Self::Review => "Review".to_string(),
|
|
||||||
Self::StateChanged => "State changed".to_string(),
|
Self::StateChanged => "State changed".to_string(),
|
||||||
Self::IntakeSummary => "Intake summary".to_string(),
|
Self::IntakeSummary => "Intake summary".to_string(),
|
||||||
Self::StatusChanged => "Status changed".to_string(),
|
Self::StatusChanged => "Status changed".to_string(),
|
||||||
@@ -345,7 +342,7 @@ impl From<&str> for TicketEventKind {
|
|||||||
"plan" => Self::Plan,
|
"plan" => Self::Plan,
|
||||||
"decision" => Self::Decision,
|
"decision" => Self::Decision,
|
||||||
"implementation_report" => Self::ImplementationReport,
|
"implementation_report" => Self::ImplementationReport,
|
||||||
"review" => Self::Review,
|
"review" => Self::Comment,
|
||||||
"state_changed" => Self::StateChanged,
|
"state_changed" => Self::StateChanged,
|
||||||
"intake_summary" => Self::IntakeSummary,
|
"intake_summary" => Self::IntakeSummary,
|
||||||
"status_changed" => Self::StatusChanged,
|
"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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct TicketReference {
|
pub struct TicketReference {
|
||||||
pub kind: String,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct NewTicket {
|
pub struct NewTicket {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -1578,7 +1514,6 @@ pub trait TicketBackend {
|
|||||||
change: TicketStateChange,
|
change: TicketStateChange,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> 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 close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
|
||||||
fn add_ticket_relation(
|
fn add_ticket_relation(
|
||||||
&self,
|
&self,
|
||||||
@@ -1656,10 +1591,6 @@ pub enum TicketBackendOperation {
|
|||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
queued_by: String,
|
queued_by: String,
|
||||||
},
|
},
|
||||||
Review {
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
review: TicketReview,
|
|
||||||
},
|
|
||||||
Close {
|
Close {
|
||||||
id: TicketIdOrSlug,
|
id: TicketIdOrSlug,
|
||||||
resolution: MarkdownText,
|
resolution: MarkdownText,
|
||||||
@@ -1763,10 +1694,6 @@ where
|
|||||||
backend.queue_ready(id, &queued_by)?;
|
backend.queue_ready(id, &queued_by)?;
|
||||||
TicketBackendOperationResult::Unit
|
TicketBackendOperationResult::Unit
|
||||||
}
|
}
|
||||||
TicketBackendOperation::Review { id, review } => {
|
|
||||||
backend.review(id, review)?;
|
|
||||||
TicketBackendOperationResult::Unit
|
|
||||||
}
|
|
||||||
TicketBackendOperation::Close { id, resolution } => {
|
TicketBackendOperation::Close { id, resolution } => {
|
||||||
backend.close(id, resolution)?;
|
backend.close(id, resolution)?;
|
||||||
TicketBackendOperationResult::Unit
|
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<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
||||||
self.with_write(|conn| {
|
self.with_write(|conn| {
|
||||||
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
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<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> {
|
||||||
let _lock = self.acquire_lock()?;
|
let _lock = self.acquire_lock()?;
|
||||||
self.ensure_backend_dirs()?;
|
self.ensure_backend_dirs()?;
|
||||||
@@ -5337,7 +5221,8 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
|
|||||||
.strip_prefix("<!-- ")
|
.strip_prefix("<!-- ")
|
||||||
.and_then(|v| v.strip_suffix(" -->"))
|
.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
|
let kind = attrs
|
||||||
.get("event")
|
.get("event")
|
||||||
.map(|value| TicketEventKind::from(value.as_str()))
|
.map(|value| TicketEventKind::from(value.as_str()))
|
||||||
@@ -5369,11 +5254,22 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
|
|||||||
while body.ends_with('\n') {
|
while body.ends_with('\n') {
|
||||||
body.pop();
|
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 {
|
events.push(TicketEvent {
|
||||||
kind,
|
kind,
|
||||||
author: attrs.get("author").cloned(),
|
author: attrs.get("author").cloned(),
|
||||||
at: attrs.get("at").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(),
|
from: attrs.get("from").cloned(),
|
||||||
to: attrs.get("to").cloned(),
|
to: attrs.get("to").cloned(),
|
||||||
reason: attrs.get("reason").cloned(),
|
reason: attrs.get("reason").cloned(),
|
||||||
@@ -6379,12 +6275,6 @@ state: planning
|
|||||||
NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."),
|
NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
backend
|
|
||||||
.review(
|
|
||||||
TicketIdOrSlug::Id(created.id.clone()),
|
|
||||||
TicketReview::approve("Looks good."),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
backend
|
backend
|
||||||
.close(
|
.close(
|
||||||
TicketIdOrSlug::Id(created.id.clone()),
|
TicketIdOrSlug::Id(created.id.clone()),
|
||||||
@@ -6404,13 +6294,6 @@ state: planning
|
|||||||
assert!(ticket.events.iter().any(|event| {
|
assert!(ticket.events.iter().any(|event| {
|
||||||
event.kind == TicketEventKind::Comment && event.body.0.contains("Imported into SQLite")
|
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!(
|
assert!(
|
||||||
ticket
|
ticket
|
||||||
.resolution
|
.resolution
|
||||||
@@ -6524,7 +6407,7 @@ state: planning
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 tmp = TempDir::new().unwrap();
|
||||||
let backend = backend(&tmp);
|
let backend = backend(&tmp);
|
||||||
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
|
let ticket = backend.create(NewTicket::new("Flow Ticket")).unwrap();
|
||||||
@@ -6534,12 +6417,6 @@ state: planning
|
|||||||
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
NewTicketEvent::new(TicketEventKind::Plan, "Implementation plan."),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
backend
|
|
||||||
.review(
|
|
||||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
|
||||||
TicketReview::approve("Looks good."),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mut summary = TicketIntakeSummary::new("Ready for queue.");
|
let mut summary = TicketIntakeSummary::new("Ready for queue.");
|
||||||
summary.author = Some("test".to_string());
|
summary.author = Some("test".to_string());
|
||||||
let mut change = TicketStateChange::new(
|
let mut change = TicketStateChange::new(
|
||||||
@@ -6563,8 +6440,6 @@ state: planning
|
|||||||
let closed_dir = tmp.path().join("tickets").join(&ticket.id);
|
let closed_dir = tmp.path().join("tickets").join(&ticket.id);
|
||||||
assert!(closed_dir.join("resolution.md").exists());
|
assert!(closed_dir.join("resolution.md").exists());
|
||||||
let thread = fs::read_to_string(closed_dir.join("thread.md")).unwrap();
|
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"));
|
assert!(thread.contains("<!-- event: close"));
|
||||||
let report = backend.doctor().unwrap();
|
let report = backend.doctor().unwrap();
|
||||||
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
assert!(report.is_ok(), "{:?}", report.diagnostics);
|
||||||
@@ -6592,14 +6467,6 @@ state: planning
|
|||||||
));
|
));
|
||||||
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
|
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(
|
let invalid_kind = NewTicketEvent::new(
|
||||||
TicketEventKind::Other("bad\nevent".into()),
|
TicketEventKind::Other("bad\nevent".into()),
|
||||||
"Invalid event kind.",
|
"Invalid event kind.",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
|
|||||||
|
|
||||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
struct Migration {
|
struct Migration {
|
||||||
@@ -27,6 +27,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "add_ticket_repository_target",
|
name: "add_ticket_repository_target",
|
||||||
apply: 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)]
|
#[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")
|
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(
|
fn add_column_if_missing(
|
||||||
connection: &Connection,
|
connection: &Connection,
|
||||||
table: &str,
|
table: &str,
|
||||||
@@ -809,10 +841,10 @@ mod tests {
|
|||||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||||
|
|
||||||
let versions = load_applied_migrations(&connection).unwrap();
|
let versions = load_applied_migrations(&connection).unwrap();
|
||||||
assert_eq!(versions.len(), 2);
|
assert_eq!(versions.len(), 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
versions.get(&LATEST_SQLITE_TICKET_SCHEMA_VERSION),
|
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()
|
.to_string()
|
||||||
.contains("unsupported Ticket schema migration version 99")
|
.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]
|
#[test]
|
||||||
@@ -1050,6 +1082,31 @@ mod tests {
|
|||||||
assert!(!migration_table_exists);
|
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]
|
#[test]
|
||||||
fn concurrent_migrators_converge_on_one_version_history() {
|
fn concurrent_migrators_converge_on_one_version_history() {
|
||||||
let directory = tempdir().unwrap();
|
let directory = tempdir().unwrap();
|
||||||
@@ -1072,6 +1129,6 @@ mod tests {
|
|||||||
|
|
||||||
let connection = Connection::open(database).unwrap();
|
let connection = Connection::open(database).unwrap();
|
||||||
verify_sqlite_ticket_schema(&connection).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,
|
Result as TicketResult, Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic,
|
||||||
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
|
TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug,
|
||||||
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind,
|
TicketIntakeSummary, TicketListState, TicketRef, TicketRelation, TicketRelationKind,
|
||||||
TicketRelationView, TicketReview, TicketReviewResult, TicketStateChange, TicketSummary,
|
TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author,
|
||||||
TicketWorkflowState, default_author,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
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 DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||||
|
|
||||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
|
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
@@ -43,7 +42,6 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -69,7 +67,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
|||||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||||
|
|
||||||
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
pub const TICKET_TOOL_NAMES: [&str; 18] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketList",
|
"TicketList",
|
||||||
@@ -78,7 +76,6 @@ pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -100,14 +97,13 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
|||||||
"TicketOrchestrationPlanQuery",
|
"TicketOrchestrationPlanQuery",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 12] = [
|
||||||
"TicketCreate",
|
"TicketCreate",
|
||||||
"TicketEditItem",
|
"TicketEditItem",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"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 DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
|
||||||
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||||
"Append a typed Ticket implementation_report event. `body` is Markdown.";
|
"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 \
|
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 \
|
Ticket backend. The tool appends a bounded `intake_summary`, appends a typed `state_changed` event \
|
||||||
for `state`, and transitions state to `ready`.";
|
for `state`, and transitions state to `ready`.";
|
||||||
@@ -175,7 +169,6 @@ fn base_tool_description(name: &str) -> &'static str {
|
|||||||
"TicketPlan" => PLAN_DESCRIPTION,
|
"TicketPlan" => PLAN_DESCRIPTION,
|
||||||
"TicketDecision" => DECISION_DESCRIPTION,
|
"TicketDecision" => DECISION_DESCRIPTION,
|
||||||
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
||||||
"TicketReview" => REVIEW_DESCRIPTION,
|
|
||||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||||
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
"TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION,
|
||||||
@@ -319,10 +312,6 @@ impl TicketBackend for TicketToolBackend {
|
|||||||
self.backend.queue_ready(id, queued_by)
|
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<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
||||||
self.backend.close(id, resolution)
|
self.backend.close(id, resolution)
|
||||||
}
|
}
|
||||||
@@ -554,23 +543,6 @@ struct TicketThreadEventParams {
|
|||||||
body: String,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
struct TicketIntakeReadyParams {
|
struct TicketIntakeReadyParams {
|
||||||
/// Ticket id.
|
/// Ticket id.
|
||||||
@@ -839,11 +811,6 @@ struct TicketImplementationReportTool {
|
|||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct TicketReviewTool {
|
|
||||||
backend: TicketToolBackend,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TicketIntakeReadyTool {
|
struct TicketIntakeReadyTool {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
@@ -1117,34 +1084,6 @@ impl_ticket_thread_event_tool!(
|
|||||||
TicketEventKind::ImplementationReport
|
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]
|
#[async_trait]
|
||||||
impl Tool for TicketIntakeReadyTool {
|
impl Tool for TicketIntakeReadyTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
@@ -1731,7 +1670,6 @@ fn input_schema(name: &str) -> Value {
|
|||||||
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
||||||
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
|
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)),
|
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||||
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||||
"TicketWorkflowState" => {
|
"TicketWorkflowState" => {
|
||||||
@@ -1777,7 +1715,6 @@ impl_from_backend!(TicketCommentTool);
|
|||||||
impl_from_backend!(TicketPlanTool);
|
impl_from_backend!(TicketPlanTool);
|
||||||
impl_from_backend!(TicketDecisionTool);
|
impl_from_backend!(TicketDecisionTool);
|
||||||
impl_from_backend!(TicketImplementationReportTool);
|
impl_from_backend!(TicketImplementationReportTool);
|
||||||
impl_from_backend!(TicketReviewTool);
|
|
||||||
impl_from_backend!(TicketIntakeReadyTool);
|
impl_from_backend!(TicketIntakeReadyTool);
|
||||||
impl_from_backend!(TicketQueueTool);
|
impl_from_backend!(TicketQueueTool);
|
||||||
impl_from_backend!(TicketWorkflowStateTool);
|
impl_from_backend!(TicketWorkflowStateTool);
|
||||||
@@ -1804,7 +1741,6 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
|||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
backend.clone(),
|
backend.clone(),
|
||||||
),
|
),
|
||||||
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
|
|
||||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||||
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||||
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
tool_definition::<TicketWorkflowStateTool>("TicketWorkflowState", backend.clone()),
|
||||||
@@ -1880,7 +1816,6 @@ mod tests {
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
@@ -2373,12 +2308,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 temp = TempDir::new().unwrap();
|
||||||
let backend = backend(&temp);
|
let backend = backend(&temp);
|
||||||
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
||||||
let report = tool_by_name(backend.clone(), "TicketImplementationReport");
|
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 close = tool_by_name(backend.clone(), "TicketClose");
|
||||||
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
||||||
|
|
||||||
@@ -2393,18 +2327,6 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
review
|
|
||||||
.execute(
|
|
||||||
&json!({
|
|
||||||
"ticket": created.id.clone(),
|
|
||||||
"result": "approve",
|
|
||||||
"body": "Looks good."
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
Default::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
close
|
close
|
||||||
.execute(
|
.execute(
|
||||||
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
|
&json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" })
|
||||||
@@ -2427,12 +2349,6 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
.any(|event| event.kind == TicketEventKind::ImplementationReport)
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
closed
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == TicketEventKind::Review)
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
closed
|
closed
|
||||||
.events
|
.events
|
||||||
@@ -2852,7 +2768,6 @@ mod tests {
|
|||||||
"TicketPlan",
|
"TicketPlan",
|
||||||
"TicketDecision",
|
"TicketDecision",
|
||||||
"TicketImplementationReport",
|
"TicketImplementationReport",
|
||||||
"TicketReview",
|
|
||||||
"TicketIntakeReady",
|
"TicketIntakeReady",
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
|
|||||||
@@ -795,13 +795,7 @@ async fn ticket_review_action_does_not_silently_approve() {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("current action is Queue"));
|
assert!(error.to_string().contains("current action is Queue"));
|
||||||
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
let _ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
|
||||||
assert!(
|
|
||||||
!ticket
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.any(|event| event.kind == TicketEventKind::Review)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod manage_workdir;
|
|||||||
pub mod manage_worker;
|
pub mod manage_worker;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod memory_extract;
|
pub mod memory_extract;
|
||||||
|
pub mod merge_request;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
pub mod session_explore;
|
pub mod session_explore;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
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 schemars::JsonSchema;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub const MERGE_REQUEST_COMMON_TOOL_NAMES: &[&str] = &[
|
||||||
|
"MergeRequestShow",
|
||||||
|
"MergeRequestReadinessCheck",
|
||||||
|
"MergeRequestOpen",
|
||||||
|
"MergeRequestAddRevision",
|
||||||
|
"MergeRequestComplete",
|
||||||
|
];
|
||||||
|
pub const MERGE_REQUEST_REVIEW_TOOL_NAME: &str = "MergeRequestReviewSubmit";
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Kind {
|
||||||
|
Show,
|
||||||
|
Readiness,
|
||||||
|
Open,
|
||||||
|
AddRevision,
|
||||||
|
Complete,
|
||||||
|
Review,
|
||||||
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct MergeRequestTool {
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
kind: Kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct ShowInput {
|
||||||
|
ticket: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct OpenInput {
|
||||||
|
ticket: String,
|
||||||
|
repository_id: String,
|
||||||
|
revision_id: String,
|
||||||
|
base_commit: String,
|
||||||
|
head_commit: String,
|
||||||
|
head_tree: String,
|
||||||
|
diff_digest: String,
|
||||||
|
#[serde(default)]
|
||||||
|
changed_paths: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct AddRevisionInput {
|
||||||
|
ticket: String,
|
||||||
|
expected_current_revision_id: String,
|
||||||
|
revision_id: String,
|
||||||
|
base_commit: String,
|
||||||
|
head_commit: String,
|
||||||
|
head_tree: String,
|
||||||
|
diff_digest: String,
|
||||||
|
#[serde(default)]
|
||||||
|
changed_paths: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct CompleteInput {
|
||||||
|
ticket: String,
|
||||||
|
operation_id: String,
|
||||||
|
expected_revision_id: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct ReviewInput {
|
||||||
|
decision: ReviewDecisionInput,
|
||||||
|
#[serde(default)]
|
||||||
|
body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
findings: Vec<ReviewFindingInput>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum ReviewDecisionInput {
|
||||||
|
Approve,
|
||||||
|
RequestChanges,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
struct ReviewFindingInput {
|
||||||
|
severity: String,
|
||||||
|
#[serde(default)]
|
||||||
|
code: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
line: Option<u64>,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Show => "MergeRequestShow",
|
||||||
|
Self::Readiness => "MergeRequestReadinessCheck",
|
||||||
|
Self::Open => "MergeRequestOpen",
|
||||||
|
Self::AddRevision => "MergeRequestAddRevision",
|
||||||
|
Self::Complete => "MergeRequestComplete",
|
||||||
|
Self::Review => "MergeRequestReviewSubmit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn description(self) -> &'static str {
|
||||||
|
description(self.name()).unwrap_or("Merge Request operation.")
|
||||||
|
}
|
||||||
|
fn schema(self) -> serde_json::Value {
|
||||||
|
match self {
|
||||||
|
Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)),
|
||||||
|
Self::Open => json!(schemars::schema_for!(OpenInput)),
|
||||||
|
Self::AddRevision => json!(schemars::schema_for!(AddRevisionInput)),
|
||||||
|
Self::Complete => json!(schemars::schema_for!(CompleteInput)),
|
||||||
|
Self::Review => json!(schemars::schema_for!(ReviewInput)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for MergeRequestTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input: &str,
|
||||||
|
_context: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
|
||||||
|
})?;
|
||||||
|
let (method, path, body) = match self.kind {
|
||||||
|
Kind::Show => {
|
||||||
|
let v: ShowInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::Readiness => {
|
||||||
|
let v: ShowInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{}/merge-request/readiness",
|
||||||
|
v.ticket
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::Open => {
|
||||||
|
let v: OpenInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!("/api/w/{workspace_id}/tickets/{}/merge-request", v.ticket),
|
||||||
|
Some(
|
||||||
|
json!({"repository_id":v.repository_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"head_tree":v.head_tree,"diff_digest":v.diff_digest,"changed_paths":v.changed_paths,"summary":v.summary}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::AddRevision => {
|
||||||
|
let v: AddRevisionInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{}/merge-request/revisions",
|
||||||
|
v.ticket
|
||||||
|
),
|
||||||
|
Some(
|
||||||
|
json!({"expected_current_revision_id":v.expected_current_revision_id,"revision_id":v.revision_id,"base_commit":v.base_commit,"head_commit":v.head_commit,"head_tree":v.head_tree,"diff_digest":v.diff_digest,"changed_paths":v.changed_paths,"summary":v.summary}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::Complete => {
|
||||||
|
let v: CompleteInput = parse(input)?;
|
||||||
|
nonempty(&v.ticket)?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{}/merge-request/complete",
|
||||||
|
v.ticket
|
||||||
|
),
|
||||||
|
Some(
|
||||||
|
json!({"operation_id":v.operation_id,"expected_revision_id":v.expected_revision_id}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Kind::Review => {
|
||||||
|
let v: ReviewInput = parse(input)?;
|
||||||
|
let context = self.client.reviewer_attempt_context().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed(
|
||||||
|
"MergeRequestReviewSubmit is available only to an attested Reviewer child"
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{workspace_id}/tickets/{}/merge-request/reviews",
|
||||||
|
context.ticket_id
|
||||||
|
),
|
||||||
|
Some(
|
||||||
|
json!({"decision":match v.decision{ReviewDecisionInput::Approve=>"approve",ReviewDecisionInput::RequestChanges=>"request_changes"},"body":v.body,"findings":v.findings.into_iter().map(|f|json!({"severity":f.severity,"code":f.code,"path":f.path,"line":f.line,"body":f.body})).collect::<Vec<_>>() }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let request = match body {
|
||||||
|
Some(body) => WorkspaceRequest::json(method, path, body.to_string()),
|
||||||
|
None => WorkspaceRequest::get(path),
|
||||||
|
};
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.execute(request)
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"Merge Request API returned HTTP {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary: self.kind.name().to_string(),
|
||||||
|
content: Some(response.body),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn parse<T: serde::de::DeserializeOwned>(value: &str) -> Result<T, ToolError> {
|
||||||
|
serde_json::from_str(value).map_err(|e| ToolError::InvalidArgument(e.to_string()))
|
||||||
|
}
|
||||||
|
fn nonempty(value: &str) -> Result<(), ToolError> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
Err(ToolError::InvalidArgument(
|
||||||
|
"ticket must not be empty".into(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn definition(client: Arc<dyn WorkspaceClient>, kind: Kind) -> ToolDefinition {
|
||||||
|
Arc::new(move || {
|
||||||
|
let meta = ToolMeta::new(kind.name())
|
||||||
|
.description(kind.description())
|
||||||
|
.input_schema(kind.schema());
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(MergeRequestTool {
|
||||||
|
client: client.clone(),
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn common_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||||
|
vec![
|
||||||
|
definition(client.clone(), Kind::Show),
|
||||||
|
definition(client.clone(), Kind::Readiness),
|
||||||
|
definition(client.clone(), Kind::Open),
|
||||||
|
definition(client.clone(), Kind::AddRevision),
|
||||||
|
definition(client, Kind::Complete),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
pub fn reviewer_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||||
|
if client.reviewer_attempt_context().is_some() {
|
||||||
|
vec![
|
||||||
|
definition(client.clone(), Kind::Show),
|
||||||
|
definition(client, Kind::Review),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn description(name: &str) -> Option<&'static str> {
|
||||||
|
match name {
|
||||||
|
"MergeRequestShow" => Some(
|
||||||
|
"Read the authoritative Merge Request, immutable current revision, and structured review status.",
|
||||||
|
),
|
||||||
|
"MergeRequestReadinessCheck" => {
|
||||||
|
Some("Check derived merge readiness for the current immutable revision.")
|
||||||
|
}
|
||||||
|
"MergeRequestOpen" => {
|
||||||
|
Some("Open an immutable Merge Request revision for the current assigned Coder.")
|
||||||
|
}
|
||||||
|
"MergeRequestAddRevision" => {
|
||||||
|
Some("Append an immutable revision; prior approval cannot carry to the new revision.")
|
||||||
|
}
|
||||||
|
"MergeRequestComplete" => {
|
||||||
|
Some("CAS-complete an approved revision with operation-id replay and crash fencing.")
|
||||||
|
}
|
||||||
|
"MergeRequestReviewSubmit" => Some(
|
||||||
|
"Submit the attested direct-child Reviewer result bound to its immutable revision.",
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,13 @@ use ticket::{
|
|||||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||||
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
||||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||||
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
|
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketStateChange,
|
||||||
TicketStateChange, TicketSummary,
|
TicketSummary,
|
||||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
||||||
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::merge_request;
|
||||||
use crate::feature::{
|
use crate::feature::{
|
||||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||||
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
|
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
|
||||||
@@ -100,7 +101,7 @@ impl TicketFeatureAccess {
|
|||||||
pub const fn review() -> Self {
|
pub const fn review() -> Self {
|
||||||
Self {
|
Self {
|
||||||
authoring: false,
|
authoring: false,
|
||||||
thread: true,
|
thread: false,
|
||||||
intake: false,
|
intake: false,
|
||||||
orchestration_control: false,
|
orchestration_control: false,
|
||||||
}
|
}
|
||||||
@@ -141,7 +142,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketRelationRecord",
|
"TicketRelationRecord",
|
||||||
];
|
];
|
||||||
|
|
||||||
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketReview"];
|
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
||||||
|
|
||||||
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
|
||||||
|
|
||||||
@@ -152,7 +153,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
|
||||||
"TicketQueue",
|
"TicketQueue",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
@@ -167,7 +167,6 @@ const ORCHESTRATION_CONTROL_TOOL_NAMES: &[&str] = &[
|
|||||||
"TicketList",
|
"TicketList",
|
||||||
"TicketShow",
|
"TicketShow",
|
||||||
"TicketComment",
|
"TicketComment",
|
||||||
"TicketReview",
|
|
||||||
"TicketWorkflowState",
|
"TicketWorkflowState",
|
||||||
"TicketClose",
|
"TicketClose",
|
||||||
"TicketDependencyCheck",
|
"TicketDependencyCheck",
|
||||||
@@ -340,6 +339,22 @@ impl FeatureModule for TicketFeature {
|
|||||||
ticket_tool_description(name, self.record_language.as_deref()),
|
ticket_tool_description(name, self.record_language.as_deref()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
|
||||||
|
let names: Vec<&str> = if client.reviewer_attempt_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
|
descriptor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +388,17 @@ impl FeatureModule for TicketFeature {
|
|||||||
}
|
}
|
||||||
tools.register(ToolContribution::new(name, definition))?;
|
tools.register(ToolContribution::new(name, definition))?;
|
||||||
}
|
}
|
||||||
|
if let TicketFeatureBackend::WorkspaceClient(client) = &self.backend {
|
||||||
|
let definitions = if client.reviewer_attempt_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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -611,14 +637,6 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
TicketBackendOperation::Review { id, review } => Self::request_unit(
|
|
||||||
client,
|
|
||||||
WorkspaceRequestMethod::Post,
|
|
||||||
format!("{base}/{}/workflow/review", Self::ticket_path(&id)),
|
|
||||||
Some(serde_json::to_value(review).map_err(|error| {
|
|
||||||
TicketError::Conflict(format!("serialize Ticket review: {error}"))
|
|
||||||
})?),
|
|
||||||
),
|
|
||||||
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
||||||
client,
|
client,
|
||||||
WorkspaceRequestMethod::Post,
|
WorkspaceRequestMethod::Post,
|
||||||
@@ -844,15 +862,6 @@ impl TicketBackend for WorkspaceHttpTicketBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> TicketResult<()> {
|
|
||||||
match self.invoke(TicketBackendOperation::Review { id, review })? {
|
|
||||||
TicketBackendOperationResult::Unit => Ok(()),
|
|
||||||
other => Err(TicketError::Conflict(format!(
|
|
||||||
"unexpected ticket backend response: {other:?}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> {
|
||||||
match self.invoke(TicketBackendOperation::Close { id, resolution })? {
|
match self.invoke(TicketBackendOperation::Close { id, resolution })? {
|
||||||
TicketBackendOperationResult::Unit => Ok(()),
|
TicketBackendOperationResult::Unit => Ok(()),
|
||||||
@@ -1075,7 +1084,6 @@ mod tests {
|
|||||||
.map(|tool| tool.name.as_str())
|
.map(|tool| tool.name.as_str())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert!(work_report_tools.contains(&"TicketComment"));
|
assert!(work_report_tools.contains(&"TicketComment"));
|
||||||
assert!(work_report_tools.contains(&"TicketReview"));
|
|
||||||
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
|
assert!(!work_report_tools.contains(&"TicketWorkflowState"));
|
||||||
|
|
||||||
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
|
let review = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::review());
|
||||||
@@ -1085,7 +1093,6 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|tool| tool.name.as_str())
|
.map(|tool| tool.name.as_str())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert!(review_tools.contains(&"TicketReview"));
|
|
||||||
assert!(!review_tools.contains(&"TicketWorkflowState"));
|
assert!(!review_tools.contains(&"TicketWorkflowState"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ pub(crate) struct InternalWorkerSessionHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InternalWorkerSessionHandle {
|
impl InternalWorkerSessionHandle {
|
||||||
|
pub(crate) fn session_id_string(&self) -> String {
|
||||||
|
self.session_id.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn status(&self) -> InternalWorkerSessionStatus {
|
pub(crate) fn status(&self) -> InternalWorkerSessionStatus {
|
||||||
InternalWorkerSessionStatus::decode(self.status.load(std::sync::atomic::Ordering::Acquire))
|
InternalWorkerSessionStatus::decode(self.status.load(std::sync::atomic::Ordering::Acquire))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ use crate::internal_worker::{
|
|||||||
};
|
};
|
||||||
use crate::prompt::catalog::PromptCatalog;
|
use crate::prompt::catalog::PromptCatalog;
|
||||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||||
use crate::worker::{Worker, WorkerFilesystemAuthority};
|
use crate::worker::{
|
||||||
|
ReviewerAttemptContext, ReviewerChildWorkspaceClient, Worker, WorkerFilesystemAuthority,
|
||||||
|
WorkspaceRequest, WorkspaceRequestMethod,
|
||||||
|
};
|
||||||
use protocol::Method;
|
use protocol::Method;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
@@ -55,6 +58,16 @@ struct SubWorkerSpawnInput {
|
|||||||
/// spawner's explicit delegation authority; direct tool scope alone is not
|
/// spawner's explicit delegation authority; direct tool scope alone is not
|
||||||
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
||||||
scope: Vec<ScopeRuleInput>,
|
scope: Vec<ScopeRuleInput>,
|
||||||
|
/// Binds an actual read-only builtin Reviewer child to an immutable Merge Request revision.
|
||||||
|
/// Review attempt identity and capability material are generated by the trusted spawn layer.
|
||||||
|
#[serde(default)]
|
||||||
|
review: Option<ReviewerHandoffInput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
|
struct ReviewerHandoffInput {
|
||||||
|
ticket_id: String,
|
||||||
|
revision_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
@@ -320,6 +333,32 @@ impl SubWorkerSpawnTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolError> {
|
||||||
|
let Some(review) = &input.review else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if review.ticket_id.trim().is_empty() || review.revision_id.trim().is_empty() {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"reviewer handoff requires non-empty ticket_id and revision_id".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input.profile.as_deref() != Some("builtin:reviewer") {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if input
|
||||||
|
.scope
|
||||||
|
.iter()
|
||||||
|
.any(|rule| matches!(rule.permission, PermissionInput::Write))
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"Merge Request Reviewer SubWorkers must have read-only delegated scope".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Tool for SubWorkerSpawnTool {
|
impl Tool for SubWorkerSpawnTool {
|
||||||
async fn execute(
|
async fn execute(
|
||||||
@@ -340,6 +379,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
input.name
|
input.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
validate_reviewer_handoff(&input)?;
|
||||||
let name_reservation = self
|
let name_reservation = self
|
||||||
.registry
|
.registry
|
||||||
.reserve_internal_name(input.name.clone())
|
.reserve_internal_name(input.name.clone())
|
||||||
@@ -378,6 +418,48 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
|
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
|
||||||
})?;
|
})?;
|
||||||
|
let reviewer_attempt = input.review.as_ref().map(|review| {
|
||||||
|
(
|
||||||
|
review.ticket_id.clone(),
|
||||||
|
review.revision_id.clone(),
|
||||||
|
uuid::Uuid::now_v7().to_string(),
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
uuid::Uuid::now_v7().simple(),
|
||||||
|
uuid::Uuid::now_v7().simple()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let child_workspace_context =
|
||||||
|
if let Some((ticket_id, revision_id, _, capability_token)) = &reviewer_attempt {
|
||||||
|
let workspace_id =
|
||||||
|
self.workspace_context
|
||||||
|
.workspace_id()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::InvalidArgument(
|
||||||
|
"reviewer handoff requires Workspace identity".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let parent_client = self.workspace_context.client_handle();
|
||||||
|
if !parent_client.is_available() {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"reviewer handoff requires Workspace API authority".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let child_client: Arc<dyn crate::worker::WorkspaceClient> =
|
||||||
|
Arc::new(ReviewerChildWorkspaceClient::new(
|
||||||
|
parent_client.clone(),
|
||||||
|
ReviewerAttemptContext {
|
||||||
|
ticket_id: ticket_id.clone(),
|
||||||
|
revision_id: revision_id.clone(),
|
||||||
|
},
|
||||||
|
capability_token.clone(),
|
||||||
|
));
|
||||||
|
crate::worker::WorkerWorkspaceContext::with_client(Some(workspace_id), child_client)
|
||||||
|
} else {
|
||||||
|
self.workspace_context.clone()
|
||||||
|
};
|
||||||
let store = EphemeralSessionStore::default();
|
let store = EphemeralSessionStore::default();
|
||||||
let filesystem_authority =
|
let filesystem_authority =
|
||||||
WorkerFilesystemAuthority::local(self.workspace_root.clone(), child_cwd.clone());
|
WorkerFilesystemAuthority::local(self.workspace_root.clone(), child_cwd.clone());
|
||||||
@@ -385,7 +467,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
child_manifest,
|
child_manifest,
|
||||||
store.clone(),
|
store.clone(),
|
||||||
self.prompt_loader.clone(),
|
self.prompt_loader.clone(),
|
||||||
self.workspace_context.clone(),
|
child_workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
self.internal_client_override
|
self.internal_client_override
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -465,6 +547,66 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some((ticket_id, revision_id, attempt_id, capability_token)) = &reviewer_attempt {
|
||||||
|
let workspace_id = self.workspace_context.workspace_id().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("reviewer attempt lost Workspace identity".to_string())
|
||||||
|
})?;
|
||||||
|
let child_session_id = session.session_id_string();
|
||||||
|
let child_registration = WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{}/internal/reviewer-child-sessions",
|
||||||
|
workspace_id.as_str()
|
||||||
|
),
|
||||||
|
serde_json::json!({"child_session_id": child_session_id}).to_string(),
|
||||||
|
);
|
||||||
|
let child_response = self
|
||||||
|
.workspace_context
|
||||||
|
.client()
|
||||||
|
.execute(child_registration)
|
||||||
|
.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"register Runtime-owned Reviewer child session: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !child_response.is_success() {
|
||||||
|
let _ = session.stop().await;
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"register Runtime-owned Reviewer child session failed with status {}: {}",
|
||||||
|
child_response.status, child_response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"attempt_id": attempt_id,
|
||||||
|
"revision_id": revision_id,
|
||||||
|
"child_session_id": child_session_id,
|
||||||
|
"capability_token": capability_token,
|
||||||
|
});
|
||||||
|
let request = WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
format!(
|
||||||
|
"/api/w/{}/tickets/{}/merge-request/review-attempts",
|
||||||
|
workspace_id.as_str(),
|
||||||
|
ticket_id
|
||||||
|
),
|
||||||
|
body.to_string(),
|
||||||
|
);
|
||||||
|
let response = self
|
||||||
|
.workspace_context
|
||||||
|
.client()
|
||||||
|
.execute(request)
|
||||||
|
.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!("register reviewer attempt: {error}"))
|
||||||
|
})?;
|
||||||
|
if !response.is_success() {
|
||||||
|
let _ = session.stop().await;
|
||||||
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"register reviewer attempt failed with status {}: {}",
|
||||||
|
response.status, response.body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
|
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
|
||||||
input.name.clone(),
|
input.name.clone(),
|
||||||
scope_allow,
|
scope_allow,
|
||||||
@@ -899,6 +1041,31 @@ mod tests {
|
|||||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
|
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
|
||||||
|
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
|
"scope":[{"target":"/tmp/work","permission":"read"}],
|
||||||
|
"review":{"ticket_id":"T1","revision_id":"V1"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(validate_reviewer_handoff(&valid).is_ok());
|
||||||
|
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
|
"name":"reviewer","task":"review","profile":"builtin:coder",
|
||||||
|
"scope":[{"target":"/tmp/work","permission":"read"}],
|
||||||
|
"review":{"ticket_id":"T1","revision_id":"V1"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
||||||
|
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
|
"scope":[{"target":"/tmp/work","permission":"write"}],
|
||||||
|
"review":{"ticket_id":"T1","revision_id":"V1"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(validate_reviewer_handoff(&writable).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
|
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
|
||||||
ScopeRule {
|
ScopeRule {
|
||||||
target: path.to_path_buf(),
|
target: path.to_path_buf(),
|
||||||
|
|||||||
@@ -223,6 +223,94 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
|||||||
fn is_available(&self) -> bool;
|
fn is_available(&self) -> bool;
|
||||||
fn execute(&self, request: WorkspaceRequest)
|
fn execute(&self, request: WorkspaceRequest)
|
||||||
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||||
|
|
||||||
|
/// Trusted review-attempt context is injected by the Internal SubWorker spawn layer.
|
||||||
|
/// It is never accepted from a model-visible tool argument.
|
||||||
|
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ReviewerAttemptContext {
|
||||||
|
pub ticket_id: String,
|
||||||
|
pub revision_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ReviewerChildWorkspaceClient {
|
||||||
|
inner: Arc<dyn WorkspaceClient>,
|
||||||
|
context: ReviewerAttemptContext,
|
||||||
|
capability_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReviewerChildWorkspaceClient {
|
||||||
|
pub fn new(
|
||||||
|
inner: Arc<dyn WorkspaceClient>,
|
||||||
|
context: ReviewerAttemptContext,
|
||||||
|
capability_token: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
context,
|
||||||
|
capability_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
self.inner.workspace_id()
|
||||||
|
}
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"runtime-reviewer-child"
|
||||||
|
}
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
self.inner.is_available()
|
||||||
|
}
|
||||||
|
fn reviewer_attempt_context(&self) -> Option<&ReviewerAttemptContext> {
|
||||||
|
Some(&self.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
mut request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
let expected_path = format!(
|
||||||
|
"/api/w/{}/tickets/{}/merge-request/reviews",
|
||||||
|
self.workspace_id().unwrap_or_default(),
|
||||||
|
self.context.ticket_id
|
||||||
|
);
|
||||||
|
if request.method == WorkspaceRequestMethod::Post && request.path == expected_path {
|
||||||
|
let body = request.body.take().ok_or_else(|| {
|
||||||
|
WorkspaceClientError::Request("review submission requires a JSON body".to_string())
|
||||||
|
})?;
|
||||||
|
let mut value: serde_json::Value = serde_json::from_str(&body)
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||||
|
let object = value.as_object_mut().ok_or_else(|| {
|
||||||
|
WorkspaceClientError::Request(
|
||||||
|
"review submission body must be an object".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
object.insert(
|
||||||
|
"revision_id".to_string(),
|
||||||
|
serde_json::Value::String(self.context.revision_id.clone()),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"capability_token".to_string(),
|
||||||
|
serde_json::Value::String(self.capability_token.clone()),
|
||||||
|
);
|
||||||
|
request.body = Some(
|
||||||
|
serde_json::to_string(&value)
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
|
||||||
|
);
|
||||||
|
} else if request.method != WorkspaceRequestMethod::Get {
|
||||||
|
return Err(WorkspaceClientError::Unavailable(
|
||||||
|
"Reviewer child Workspace authority is read-only except for its one attested Merge Request review submission".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.inner.execute(request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP forwarding client created by Runtime for one concrete Worker execution.
|
/// HTTP forwarding client created by Runtime for one concrete Worker execution.
|
||||||
@@ -365,6 +453,36 @@ impl WorkspaceClient for MarkerWorkspaceClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod reviewer_client_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reviewer_child_client_denies_non_review_workspace_mutations() {
|
||||||
|
let inner: Arc<dyn WorkspaceClient> = Arc::new(MarkerWorkspaceClient {
|
||||||
|
workspace_id: Some("ws".to_string()),
|
||||||
|
kind: "marker".to_string(),
|
||||||
|
available: true,
|
||||||
|
reason: "forwarded".to_string(),
|
||||||
|
});
|
||||||
|
let client = ReviewerChildWorkspaceClient::new(
|
||||||
|
inner,
|
||||||
|
ReviewerAttemptContext {
|
||||||
|
ticket_id: "T1".into(),
|
||||||
|
revision_id: "V1".into(),
|
||||||
|
},
|
||||||
|
"secret".into(),
|
||||||
|
);
|
||||||
|
let request = WorkspaceRequest::json(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
"/api/w/ws/tickets/T1/comments",
|
||||||
|
"{}".to_string(),
|
||||||
|
);
|
||||||
|
let error = client.execute(request).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("read-only"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn unavailable_workspace_client(
|
pub fn unavailable_workspace_client(
|
||||||
workspace_id: Option<&WorkspaceId>,
|
workspace_id: Option<&WorkspaceId>,
|
||||||
reason: impl Into<String>,
|
reason: impl Into<String>,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ sha2.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
ticket.workspace = true
|
ticket.workspace = true
|
||||||
memory.workspace = true
|
memory.workspace = true
|
||||||
|
merge-request.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ pub enum Error {
|
|||||||
Sqlite(#[from] rusqlite::Error),
|
Sqlite(#[from] rusqlite::Error),
|
||||||
#[error("ticket error: {0}")]
|
#[error("ticket error: {0}")]
|
||||||
Ticket(#[from] ticket::TicketError),
|
Ticket(#[from] ticket::TicketError),
|
||||||
|
#[error("merge request error: {0}")]
|
||||||
|
MergeRequest(#[from] merge_request::MergeRequestError),
|
||||||
#[error("yaml error: {0}")]
|
#[error("yaml error: {0}")]
|
||||||
Yaml(#[from] serde_yaml::Error),
|
Yaml(#[from] serde_yaml::Error),
|
||||||
#[error("invalid input: {0}")]
|
#[error("invalid input: {0}")]
|
||||||
@@ -88,6 +90,14 @@ pub enum Error {
|
|||||||
},
|
},
|
||||||
#[error("unknown local repository `{0}`")]
|
#[error("unknown local repository `{0}`")]
|
||||||
UnknownRepository(String),
|
UnknownRepository(String),
|
||||||
|
#[error(
|
||||||
|
"merge confirmation requires an authenticated Browser session; API tokens and Worker actors are not accepted"
|
||||||
|
)]
|
||||||
|
BrowserMergeConfirmationRequired,
|
||||||
|
#[error(
|
||||||
|
"Merge Request reopen requires an authenticated Browser session and explicit confirmation"
|
||||||
|
)]
|
||||||
|
BrowserReopenConfirmationRequired,
|
||||||
#[error("workspace id does not match this Workspace backend")]
|
#[error("workspace id does not match this Workspace backend")]
|
||||||
WorkspaceIdMismatch,
|
WorkspaceIdMismatch,
|
||||||
#[error("Ticket assignment conflict: {0}")]
|
#[error("Ticket assignment conflict: {0}")]
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind,
|
MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind,
|
||||||
TicketIdOrSlug, TicketItemEdit, TicketReview, TicketReviewResult, TicketStateChange,
|
TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetEdit, TicketWorkflowState,
|
||||||
TicketTargetEdit, TicketWorkflowState,
|
|
||||||
};
|
};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
SqliteTicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
SqliteTicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
||||||
@@ -861,8 +860,40 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
post(scoped_queue_ticket_record),
|
post(scoped_queue_ticket_record),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/workflow/review",
|
"/api/w/{workspace_id}/tickets/{id}/merge-request",
|
||||||
post(scoped_review_ticket_record),
|
get(scoped_show_merge_request).post(scoped_open_merge_request),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/merge-request/readiness",
|
||||||
|
get(scoped_merge_request_readiness),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/merge-request/revisions",
|
||||||
|
post(scoped_add_merge_request_revision),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/internal/reviewer-child-sessions",
|
||||||
|
post(scoped_register_reviewer_child_session),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/merge-request/review-attempts",
|
||||||
|
post(scoped_register_merge_request_review_attempt),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/merge-request/reviews",
|
||||||
|
post(scoped_submit_merge_request_review),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/merge-request/complete",
|
||||||
|
post(scoped_complete_merge_request),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/merge-request/reopen",
|
||||||
|
post(scoped_reopen_merge_request),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/merge-request/merge",
|
||||||
|
post(scoped_confirm_merge_request),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/workflow/close",
|
"/api/w/{workspace_id}/tickets/{id}/workflow/close",
|
||||||
@@ -902,10 +933,6 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/tickets/{id}/events",
|
"/api/w/{workspace_id}/tickets/{id}/events",
|
||||||
post(scoped_append_ticket_event),
|
post(scoped_append_ticket_event),
|
||||||
)
|
)
|
||||||
.route(
|
|
||||||
"/api/w/{workspace_id}/tickets/{id}/reviews",
|
|
||||||
post(scoped_review_ticket),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/queue",
|
"/api/w/{workspace_id}/tickets/{id}/queue",
|
||||||
post(scoped_queue_ticket),
|
post(scoped_queue_ticket),
|
||||||
@@ -2419,14 +2446,6 @@ struct BrowserAppendTicketEventRequest {
|
|||||||
author: Option<String>,
|
author: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct BrowserReviewTicketRequest {
|
|
||||||
result: TicketReviewResult,
|
|
||||||
body: String,
|
|
||||||
author: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct BrowserQueueTicketRequest {
|
struct BrowserQueueTicketRequest {
|
||||||
@@ -2505,6 +2524,11 @@ async fn scoped_transition_ticket_state(
|
|||||||
Json(request): Json<BrowserTransitionTicketStateRequest>,
|
Json(request): Json<BrowserTransitionTicketStateRequest>,
|
||||||
) -> ApiResult<Json<TicketDetail>> {
|
) -> ApiResult<Json<TicketDetail>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
if request.state == TicketWorkflowState::Done {
|
||||||
|
return Err(Error::TicketAssignmentConflict(
|
||||||
|
"done is guarded by MergeRequestComplete with an approved immutable revision and operation_id".to_string(),
|
||||||
|
).into());
|
||||||
|
}
|
||||||
let current = api.authority.ticket(&path.id)?;
|
let current = api.authority.ticket(&path.id)?;
|
||||||
let mut change = TicketStateChange::new(
|
let mut change = TicketStateChange::new(
|
||||||
current.state,
|
current.state,
|
||||||
@@ -2535,25 +2559,6 @@ async fn scoped_append_ticket_event(
|
|||||||
browser_ticket_detail(&api, &path.id)
|
browser_ticket_detail(&api, &path.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_review_ticket(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
|
||||||
Json(request): Json<BrowserReviewTicketRequest>,
|
|
||||||
) -> ApiResult<Json<TicketDetail>> {
|
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
|
||||||
browser_ticket_backend(&api)?
|
|
||||||
.review(
|
|
||||||
TicketIdOrSlug::Id(path.id.clone()),
|
|
||||||
TicketReview {
|
|
||||||
result: request.result,
|
|
||||||
body: MarkdownText::new(request.body),
|
|
||||||
author: request.author,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(Error::from)?;
|
|
||||||
browser_ticket_detail(&api, &path.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn scoped_queue_ticket(
|
async fn scoped_queue_ticket(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||||
@@ -2602,6 +2607,16 @@ async fn scoped_close_ticket(
|
|||||||
browser_ticket_detail(&api, &path.id)
|
browser_ticket_detail(&api, &path.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Result<()> {
|
||||||
|
if matches!(operation, TicketBackendOperation::SetWorkflowState { change, .. } if change.to == "done")
|
||||||
|
{
|
||||||
|
return Err(Error::TicketAssignmentConflict(
|
||||||
|
"done is guarded by MergeRequestComplete with an approved immutable revision and operation_id".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute_worker_ticket_rest_operation(
|
async fn execute_worker_ticket_rest_operation(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -2621,6 +2636,7 @@ async fn execute_worker_ticket_rest_operation(
|
|||||||
let is_mutation = operation_kind != "read";
|
let is_mutation = operation_kind != "read";
|
||||||
let target = ticket_mutation_target(&operation).cloned();
|
let target = ticket_mutation_target(&operation).cloned();
|
||||||
let source = authenticate_worker_mutation_source(api, workspace_id, &headers)?;
|
let source = authenticate_worker_mutation_source(api, workspace_id, &headers)?;
|
||||||
|
reject_unguarded_ticket_completion(&operation)?;
|
||||||
validate_ticket_repository_operation(api, &operation)?;
|
validate_ticket_repository_operation(api, &operation)?;
|
||||||
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
|
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
|
||||||
let previous_state = before
|
let previous_state = before
|
||||||
@@ -2969,23 +2985,393 @@ async fn scoped_queue_ticket_record(
|
|||||||
ticket_rest_unit(result)
|
ticket_rest_unit(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_review_ticket_record(
|
#[derive(Debug, serde::Deserialize)]
|
||||||
State(api): State<WorkspaceApi>,
|
struct OpenMergeRequestRequest {
|
||||||
AxumPath((workspace_id, id)): AxumPath<(String, String)>,
|
repository_id: String,
|
||||||
headers: HeaderMap,
|
revision_id: String,
|
||||||
Json(review): Json<TicketReview>,
|
base_commit: String,
|
||||||
) -> ApiResult<StatusCode> {
|
head_commit: String,
|
||||||
let result = execute_worker_ticket_rest_operation(
|
head_tree: String,
|
||||||
&api,
|
diff_digest: String,
|
||||||
&workspace_id,
|
#[serde(default)]
|
||||||
headers,
|
changed_paths: Vec<String>,
|
||||||
TicketBackendOperation::Review {
|
#[serde(default)]
|
||||||
id: TicketIdOrSlug::Query(id),
|
summary: String,
|
||||||
review,
|
}
|
||||||
},
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct AddMergeRequestRevisionRequest {
|
||||||
|
expected_current_revision_id: String,
|
||||||
|
revision_id: String,
|
||||||
|
base_commit: String,
|
||||||
|
head_commit: String,
|
||||||
|
head_tree: String,
|
||||||
|
diff_digest: String,
|
||||||
|
#[serde(default)]
|
||||||
|
changed_paths: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct RegisterReviewerChildSessionRequest {
|
||||||
|
child_session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct RegisterMergeRequestReviewAttemptRequest {
|
||||||
|
attempt_id: String,
|
||||||
|
revision_id: String,
|
||||||
|
child_session_id: String,
|
||||||
|
capability_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct SubmitMergeRequestReviewRequest {
|
||||||
|
revision_id: String,
|
||||||
|
capability_token: String,
|
||||||
|
decision: merge_request::ReviewDecision,
|
||||||
|
#[serde(default)]
|
||||||
|
body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
findings: Vec<merge_request::ReviewFinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct CompleteMergeRequestRequest {
|
||||||
|
operation_id: String,
|
||||||
|
expected_revision_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct RevisionTransitionRequest {
|
||||||
|
expected_revision_id: String,
|
||||||
|
explicit_confirmation: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct ConfirmMergeRequestRequest {
|
||||||
|
expected_revision_id: String,
|
||||||
|
explicit_confirmation: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_workspace_id(value: &str) -> ApiResult<String> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(Error::InvalidInput("workspace_id must not be empty".to_string()).into());
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_workspace_access(workspace_id: &str, api: &WorkspaceApi) -> ApiResult<()> {
|
||||||
|
if workspace_id != api.workspace_id() {
|
||||||
|
return Err(Error::WorkspaceIdMismatch.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_request_store(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> ApiResult<merge_request::SqliteMergeRequestStore> {
|
||||||
|
require_workspace_access(workspace_id, api)?;
|
||||||
|
merge_request::SqliteMergeRequestStore::open_verified(
|
||||||
|
api.config.database_path.clone(),
|
||||||
|
workspace_id,
|
||||||
)
|
)
|
||||||
.await?;
|
.map_err(Error::from)
|
||||||
ticket_rest_unit(result)
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_show_merge_request(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
) -> ApiResult<Json<merge_request::MergeRequest>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
let store = merge_request_store(&api, &workspace_id)?;
|
||||||
|
let value = store
|
||||||
|
.show_for_ticket(&ticket_id)?
|
||||||
|
.ok_or_else(|| Error::from(merge_request::MergeRequestError::NotFound(ticket_id)))?;
|
||||||
|
Ok(Json(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_merge_request_readiness(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
) -> ApiResult<Json<merge_request::MergeRequestReadiness>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
Ok(Json(
|
||||||
|
merge_request_store(&api, &workspace_id)?.readiness_for_ticket(&ticket_id)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_open_merge_request(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
Json(input): Json<OpenMergeRequestRequest>,
|
||||||
|
) -> ApiResult<Json<merge_request::MergeRequest>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
|
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
|
let assignment = api
|
||||||
|
.store
|
||||||
|
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".to_string())
|
||||||
|
})?;
|
||||||
|
if assignment.worker.runtime_id != source.runtime_id
|
||||||
|
|| assignment.worker.worker_id != source.worker_id
|
||||||
|
{
|
||||||
|
return Err(Error::TicketAssignmentConflict(
|
||||||
|
"authenticated Worker is not the current Ticket assignee".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||||
|
let revision = merge_request::MergeRequestRevision {
|
||||||
|
revision_id: input.revision_id,
|
||||||
|
ordinal: 1,
|
||||||
|
base_commit: input.base_commit,
|
||||||
|
head_commit: input.head_commit,
|
||||||
|
head_tree: input.head_tree,
|
||||||
|
diff_digest: input.diff_digest,
|
||||||
|
changed_paths: input.changed_paths,
|
||||||
|
summary: input.summary,
|
||||||
|
assignment_id: assignment.assignment_id.clone(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
};
|
||||||
|
let mr = merge_request_store(&api, &workspace_id)?.open_merge_request(
|
||||||
|
merge_request::OpenMergeRequest {
|
||||||
|
merge_request_id: format!("mr_{}", Uuid::now_v7().simple()),
|
||||||
|
ticket_id,
|
||||||
|
repository_id: input.repository_id,
|
||||||
|
revision,
|
||||||
|
authenticated_runtime_id: source.runtime_id,
|
||||||
|
authenticated_worker_id: source.worker_id,
|
||||||
|
now,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(Json(mr))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_add_merge_request_revision(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
Json(input): Json<AddMergeRequestRevisionRequest>,
|
||||||
|
) -> ApiResult<Json<merge_request::MergeRequest>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
|
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
|
let assignment = api
|
||||||
|
.store
|
||||||
|
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||||
|
})?;
|
||||||
|
if assignment.worker.runtime_id != source.runtime_id
|
||||||
|
|| assignment.worker.worker_id != source.worker_id
|
||||||
|
{
|
||||||
|
return Err(Error::TicketAssignmentConflict(
|
||||||
|
"authenticated Worker is not the current Ticket assignee".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let current = merge_request_store(&api, &workspace_id)?
|
||||||
|
.show_for_ticket(&ticket_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::from(merge_request::MergeRequestError::NotFound(
|
||||||
|
ticket_id.clone(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||||
|
let mr =
|
||||||
|
merge_request_store(&api, &workspace_id)?.add_revision(merge_request::AddRevision {
|
||||||
|
ticket_id,
|
||||||
|
expected_current_revision_id: input.expected_current_revision_id,
|
||||||
|
revision: merge_request::MergeRequestRevision {
|
||||||
|
revision_id: input.revision_id,
|
||||||
|
ordinal: current.current_revision.ordinal + 1,
|
||||||
|
base_commit: input.base_commit,
|
||||||
|
head_commit: input.head_commit,
|
||||||
|
head_tree: input.head_tree,
|
||||||
|
diff_digest: input.diff_digest,
|
||||||
|
changed_paths: input.changed_paths,
|
||||||
|
summary: input.summary,
|
||||||
|
assignment_id: assignment.assignment_id,
|
||||||
|
created_at: now.clone(),
|
||||||
|
},
|
||||||
|
authenticated_runtime_id: source.runtime_id,
|
||||||
|
authenticated_worker_id: source.worker_id,
|
||||||
|
now,
|
||||||
|
})?;
|
||||||
|
Ok(Json(mr))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_register_reviewer_child_session(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumPath(workspace_id): AxumPath<String>,
|
||||||
|
Json(input): Json<RegisterReviewerChildSessionRequest>,
|
||||||
|
) -> ApiResult<StatusCode> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
|
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
|
merge_request_store(&api, &workspace_id)?.register_reviewer_child_session(
|
||||||
|
merge_request::RegisterReviewerChildSession {
|
||||||
|
parent_runtime_id: source.runtime_id,
|
||||||
|
parent_worker_id: source.worker_id,
|
||||||
|
child_session_id: input.child_session_id,
|
||||||
|
now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_register_merge_request_review_attempt(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
Json(input): Json<RegisterMergeRequestReviewAttemptRequest>,
|
||||||
|
) -> ApiResult<StatusCode> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
|
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
|
let assignment = api
|
||||||
|
.store
|
||||||
|
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||||
|
})?;
|
||||||
|
if assignment.worker.runtime_id != source.runtime_id
|
||||||
|
|| assignment.worker.worker_id != source.worker_id
|
||||||
|
{
|
||||||
|
return Err(Error::TicketAssignmentConflict(
|
||||||
|
"authenticated Worker is not the current Ticket assignee".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
merge_request_store(&api, &workspace_id)?.register_review_attempt(
|
||||||
|
merge_request::RegisterReviewAttempt {
|
||||||
|
attempt_id: input.attempt_id,
|
||||||
|
ticket_id,
|
||||||
|
revision_id: input.revision_id,
|
||||||
|
parent_assignment_id: assignment.assignment_id,
|
||||||
|
parent_runtime_id: source.runtime_id,
|
||||||
|
parent_worker_id: source.worker_id,
|
||||||
|
child_session_id: input.child_session_id,
|
||||||
|
capability_token: input.capability_token,
|
||||||
|
now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_submit_merge_request_review(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
Json(input): Json<SubmitMergeRequestReviewRequest>,
|
||||||
|
) -> ApiResult<Json<merge_request::MergeRequestReview>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
let review =
|
||||||
|
merge_request_store(&api, &workspace_id)?.submit_review(merge_request::SubmitReview {
|
||||||
|
ticket_id,
|
||||||
|
revision_id: input.revision_id,
|
||||||
|
capability_token: input.capability_token,
|
||||||
|
decision: input.decision,
|
||||||
|
body: input.body,
|
||||||
|
findings: input.findings,
|
||||||
|
now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
})?;
|
||||||
|
Ok(Json(review))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_complete_merge_request(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
Json(input): Json<CompleteMergeRequestRequest>,
|
||||||
|
) -> ApiResult<Json<merge_request::CompletionOutcome>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
|
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
|
let assignment = api
|
||||||
|
.store
|
||||||
|
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||||
|
})?;
|
||||||
|
if assignment.worker.runtime_id != source.runtime_id
|
||||||
|
|| assignment.worker.worker_id != source.worker_id
|
||||||
|
{
|
||||||
|
return Err(Error::TicketAssignmentConflict(
|
||||||
|
"authenticated Worker is not the current Ticket assignee".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let outcome = merge_request_store(&api, &workspace_id)?.complete(
|
||||||
|
merge_request::CompleteMergeRequest {
|
||||||
|
operation_id: input.operation_id,
|
||||||
|
ticket_id,
|
||||||
|
expected_revision_id: input.expected_revision_id,
|
||||||
|
assignment_id: assignment.assignment_id,
|
||||||
|
authenticated_runtime_id: source.runtime_id,
|
||||||
|
authenticated_worker_id: source.worker_id,
|
||||||
|
now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(Json(outcome))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_reopen_merge_request(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
Json(input): Json<RevisionTransitionRequest>,
|
||||||
|
) -> ApiResult<Json<merge_request::MergeRequest>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
|
reject_non_browser_merge_auth(&headers)
|
||||||
|
.map_err(|_| Error::BrowserReopenConfirmationRequired)?;
|
||||||
|
let _actor = require_actor(&api, &headers).await?;
|
||||||
|
if !input.explicit_confirmation {
|
||||||
|
return Err(Error::BrowserReopenConfirmationRequired.into());
|
||||||
|
}
|
||||||
|
Ok(Json(merge_request_store(&api, &workspace_id)?.reopen(
|
||||||
|
&ticket_id,
|
||||||
|
&input.expected_revision_id,
|
||||||
|
&Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_non_browser_merge_auth(headers: &HeaderMap) -> Result<()> {
|
||||||
|
if headers.contains_key("authorization") {
|
||||||
|
return Err(Error::BrowserMergeConfirmationRequired);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scoped_confirm_merge_request(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>,
|
||||||
|
Json(input): Json<ConfirmMergeRequestRequest>,
|
||||||
|
) -> ApiResult<Json<merge_request::MergeRequest>> {
|
||||||
|
let workspace_id = parse_workspace_id(&workspace_id)?;
|
||||||
|
require_workspace_access(&workspace_id, &api)?;
|
||||||
|
reject_non_browser_merge_auth(&headers)?;
|
||||||
|
let actor = require_actor(&api, &headers).await?;
|
||||||
|
let mr = merge_request_store(&api, &workspace_id)?.confirm_merge(
|
||||||
|
merge_request::MergeConfirmation {
|
||||||
|
ticket_id,
|
||||||
|
expected_revision_id: input.expected_revision_id,
|
||||||
|
authenticated_account_id: actor.account_id,
|
||||||
|
actor_kind: "user".to_string(),
|
||||||
|
explicit_confirmation: input.explicit_confirmation,
|
||||||
|
now: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(Json(mr))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_close_ticket_record(
|
async fn scoped_close_ticket_record(
|
||||||
@@ -3157,7 +3543,6 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
|
|||||||
| TicketBackendOperation::SetWorkflowState { id, .. }
|
| TicketBackendOperation::SetWorkflowState { id, .. }
|
||||||
| TicketBackendOperation::MarkIntakeReady { id, .. }
|
| TicketBackendOperation::MarkIntakeReady { id, .. }
|
||||||
| TicketBackendOperation::QueueReady { id, .. }
|
| TicketBackendOperation::QueueReady { id, .. }
|
||||||
| TicketBackendOperation::Review { id, .. }
|
|
||||||
| TicketBackendOperation::Close { id, .. }
|
| TicketBackendOperation::Close { id, .. }
|
||||||
| TicketBackendOperation::AddTicketRelation { id, .. }
|
| TicketBackendOperation::AddTicketRelation { id, .. }
|
||||||
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
|
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
|
||||||
@@ -3203,7 +3588,6 @@ fn bind_worker_ticket_operation_source(
|
|||||||
change.author = Some(author);
|
change.author = Some(author);
|
||||||
}
|
}
|
||||||
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
|
TicketBackendOperation::QueueReady { queued_by, .. } => *queued_by = author,
|
||||||
TicketBackendOperation::Review { review, .. } => review.author = Some(author),
|
|
||||||
TicketBackendOperation::AddTicketRelation { relation, .. } => {
|
TicketBackendOperation::AddTicketRelation { relation, .. } => {
|
||||||
relation.author = Some(author)
|
relation.author = Some(author)
|
||||||
}
|
}
|
||||||
@@ -3225,7 +3609,6 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
|
|||||||
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
|
TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state",
|
||||||
TicketBackendOperation::MarkIntakeReady { .. } => "mark_intake_ready",
|
TicketBackendOperation::MarkIntakeReady { .. } => "mark_intake_ready",
|
||||||
TicketBackendOperation::QueueReady { .. } => "queue_ready",
|
TicketBackendOperation::QueueReady { .. } => "queue_ready",
|
||||||
TicketBackendOperation::Review { .. } => "review",
|
|
||||||
TicketBackendOperation::Close { .. } => "close",
|
TicketBackendOperation::Close { .. } => "close",
|
||||||
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
|
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
|
||||||
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
|
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
|
||||||
@@ -7299,10 +7682,7 @@ struct RuntimeConfigBundleAvailabilityQuery {
|
|||||||
digest: String,
|
digest: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reject_workdir_for_embedded_runtime(
|
fn reject_workdir_for_embedded_runtime(runtime_id: &str, has_workdir: bool) -> ApiResult<()> {
|
||||||
runtime_id: &str,
|
|
||||||
has_workdir: bool,
|
|
||||||
) -> std::result::Result<(), ApiError> {
|
|
||||||
if runtime_id != EMBEDDED_WORKER_RUNTIME_ID || !has_workdir {
|
if runtime_id != EMBEDDED_WORKER_RUNTIME_ID || !has_workdir {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -7320,9 +7700,7 @@ fn reject_workdir_for_embedded_runtime(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reject_no_workdir_for_non_embedded_runtime(
|
fn reject_no_workdir_for_non_embedded_runtime(runtime_id: &str) -> ApiResult<()> {
|
||||||
runtime_id: &str,
|
|
||||||
) -> std::result::Result<(), ApiError> {
|
|
||||||
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -9974,6 +10352,12 @@ struct ApiErrorLog {
|
|||||||
diagnostics: Vec<RuntimeDiagnostic>,
|
diagnostics: Vec<RuntimeDiagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<merge_request::MergeRequestError> for ApiError {
|
||||||
|
fn from(error: merge_request::MergeRequestError) -> Self {
|
||||||
|
Error::MergeRequest(error).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<Error> for ApiError {
|
impl From<Error> for ApiError {
|
||||||
fn from(error: Error) -> Self {
|
fn from(error: Error) -> Self {
|
||||||
let diagnostics = match &error {
|
let diagnostics = match &error {
|
||||||
@@ -10013,6 +10397,9 @@ impl ApiError {
|
|||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = match &self.error {
|
let status = match &self.error {
|
||||||
|
Error::BrowserMergeConfirmationRequired | Error::BrowserReopenConfirmationRequired => {
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
}
|
||||||
Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) => {
|
Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) => {
|
||||||
StatusCode::CONFLICT
|
StatusCode::CONFLICT
|
||||||
}
|
}
|
||||||
@@ -10020,7 +10407,14 @@ impl IntoResponse for ApiError {
|
|||||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||||
StatusCode::BAD_REQUEST
|
StatusCode::BAD_REQUEST
|
||||||
}
|
}
|
||||||
Error::Ticket(ticket::TicketError::NotFound(_)) => StatusCode::NOT_FOUND,
|
Error::Ticket(ticket::TicketError::NotFound(_))
|
||||||
|
| Error::MergeRequest(merge_request::MergeRequestError::NotFound(_)) => {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
Error::MergeRequest(merge_request::MergeRequestError::Empty(_)) => {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
}
|
||||||
|
Error::MergeRequest(_) => StatusCode::CONFLICT,
|
||||||
Error::Ticket(
|
Error::Ticket(
|
||||||
ticket::TicketError::Ambiguous { .. }
|
ticket::TicketError::Ambiguous { .. }
|
||||||
| ticket::TicketError::Locked { .. }
|
| ticket::TicketError::Locked { .. }
|
||||||
@@ -10161,6 +10555,32 @@ mod tests {
|
|||||||
ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord,
|
ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_confirmation_rejects_api_token_actor_before_session_resolution() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("authorization", "Bearer api-token".parse().unwrap());
|
||||||
|
assert!(matches!(
|
||||||
|
reject_non_browser_merge_auth(&headers),
|
||||||
|
Err(Error::BrowserMergeConfirmationRequired)
|
||||||
|
));
|
||||||
|
assert!(reject_non_browser_merge_auth(&HeaderMap::new()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_or_generic_worker_state_change_is_not_ticket_completion_authority() {
|
||||||
|
let operation = TicketBackendOperation::SetWorkflowState {
|
||||||
|
id: TicketIdOrSlug::Query("T1".to_string()),
|
||||||
|
change: TicketStateChange::new(
|
||||||
|
"inprogress",
|
||||||
|
"done",
|
||||||
|
"flow reached terminal state",
|
||||||
|
"terminal flow state",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let error = reject_unguarded_ticket_completion(&operation).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("MergeRequestComplete"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn failed_api_log_is_structured_and_omits_query_values() {
|
fn failed_api_log_is_structured_and_omits_query_values() {
|
||||||
let uri = "/api/w/workspace/tickets?access_token=secret"
|
let uri = "/api/w/workspace/tickets?access_token=secret"
|
||||||
@@ -12596,21 +13016,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(queued.state, "queued");
|
assert_eq!(queued.state, "queued");
|
||||||
assert_eq!(queued.queued_by.as_deref(), Some("browser-user"));
|
assert_eq!(queued.queued_by.as_deref(), Some("browser-user"));
|
||||||
let Json(reviewed) = scoped_review_ticket(
|
|
||||||
State(api.clone()),
|
|
||||||
AxumPath(path()),
|
|
||||||
Json(BrowserReviewTicketRequest {
|
|
||||||
result: TicketReviewResult::Approve,
|
|
||||||
body: "API review".to_string(),
|
|
||||||
author: Some("reviewer".to_string()),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(reviewed.events.iter().any(|event| {
|
|
||||||
event.kind == "review" && event.body.as_deref() == Some("API review")
|
|
||||||
}));
|
|
||||||
|
|
||||||
let Json(closed) = scoped_close_ticket(
|
let Json(closed) = scoped_close_ticket(
|
||||||
State(api),
|
State(api),
|
||||||
AxumPath(path()),
|
AxumPath(path()),
|
||||||
|
|||||||
@@ -765,6 +765,7 @@ impl SqliteWorkspaceStore {
|
|||||||
configure_sqlite(&conn)?;
|
configure_sqlite(&conn)?;
|
||||||
apply_migrations(&conn)?;
|
apply_migrations(&conn)?;
|
||||||
ticket::migrate_sqlite_ticket_schema(&conn)?;
|
ticket::migrate_sqlite_ticket_schema(&conn)?;
|
||||||
|
merge_request::migrate(&conn).map_err(|error| Error::Store(error.to_string()))?;
|
||||||
validate_workspace_repository_references(&conn)?;
|
validate_workspace_repository_references(&conn)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
conn: Arc::new(Mutex::new(conn)),
|
conn: Arc::new(Mutex::new(conn)),
|
||||||
|
|||||||
+13
-125
@@ -12,8 +12,8 @@ use ticket::config::{
|
|||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
|
LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||||
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
|
SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug,
|
||||||
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketReview,
|
TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketSummary,
|
||||||
TicketReviewResult, TicketSummary, TicketWorkflowState,
|
TicketWorkflowState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIST_LIMIT: usize = 50;
|
const DEFAULT_LIST_LIMIT: usize = 50;
|
||||||
@@ -35,7 +35,6 @@ pub enum TicketCommand {
|
|||||||
List(ListOptions),
|
List(ListOptions),
|
||||||
Show { query: String },
|
Show { query: String },
|
||||||
Comment(CommentOptions),
|
Comment(CommentOptions),
|
||||||
Review(ReviewOptions),
|
|
||||||
State(StateOptions),
|
State(StateOptions),
|
||||||
Close(CloseOptions),
|
Close(CloseOptions),
|
||||||
Relation(RelationOptions),
|
Relation(RelationOptions),
|
||||||
@@ -67,13 +66,6 @@ pub struct CommentOptions {
|
|||||||
pub body: BodySource,
|
pub body: BodySource,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ReviewOptions {
|
|
||||||
pub query: String,
|
|
||||||
pub result: TicketReviewResult,
|
|
||||||
pub body: BodySource,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum StateTarget {
|
pub enum StateTarget {
|
||||||
Planning,
|
Planning,
|
||||||
@@ -194,7 +186,6 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
|
|||||||
query: parse_one_positional("show", &args[1..])?,
|
query: parse_one_positional("show", &args[1..])?,
|
||||||
},
|
},
|
||||||
"comment" => TicketCommand::Comment(parse_comment(&args[1..])?),
|
"comment" => TicketCommand::Comment(parse_comment(&args[1..])?),
|
||||||
"review" => TicketCommand::Review(parse_review(&args[1..])?),
|
|
||||||
"state" => TicketCommand::State(parse_state(&args[1..])?),
|
"state" => TicketCommand::State(parse_state(&args[1..])?),
|
||||||
"close" => TicketCommand::Close(parse_close(&args[1..])?),
|
"close" => TicketCommand::Close(parse_close(&args[1..])?),
|
||||||
"relation" => TicketCommand::Relation(parse_relation(&args[1..])?),
|
"relation" => TicketCommand::Relation(parse_relation(&args[1..])?),
|
||||||
@@ -249,7 +240,6 @@ fn run_command(
|
|||||||
TicketCommand::List(options) => list(backend.as_ref(), options),
|
TicketCommand::List(options) => list(backend.as_ref(), options),
|
||||||
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
||||||
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
||||||
TicketCommand::Review(options) => review(backend.as_ref(), options),
|
|
||||||
TicketCommand::State(options) => state(backend.as_ref(), options),
|
TicketCommand::State(options) => state(backend.as_ref(), options),
|
||||||
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
||||||
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
||||||
@@ -633,23 +623,6 @@ fn comment(
|
|||||||
Ok(success(format!("appended\t{}\t{}\n", options.query, role)))
|
Ok(success(format!("appended\t{}\t{}\n", options.query, role)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review(
|
|
||||||
backend: &dyn TicketBackend,
|
|
||||||
options: ReviewOptions,
|
|
||||||
) -> Result<TicketCliOutput, TicketCliError> {
|
|
||||||
let result = options.result.as_str().to_string();
|
|
||||||
let review = TicketReview {
|
|
||||||
result: options.result,
|
|
||||||
author: Some(default_author()),
|
|
||||||
body: MarkdownText::new(read_body_source(&options.body)?),
|
|
||||||
};
|
|
||||||
backend.review(TicketIdOrSlug::Query(options.query.clone()), review)?;
|
|
||||||
Ok(success(format!(
|
|
||||||
"reviewed\t{}\t{}\n",
|
|
||||||
options.query, result
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state(
|
fn state(
|
||||||
backend: &dyn TicketBackend,
|
backend: &dyn TicketBackend,
|
||||||
options: StateOptions,
|
options: StateOptions,
|
||||||
@@ -660,7 +633,11 @@ fn state(
|
|||||||
StateTarget::Ready => TicketWorkflowState::Ready,
|
StateTarget::Ready => TicketWorkflowState::Ready,
|
||||||
StateTarget::Queued => TicketWorkflowState::Queued,
|
StateTarget::Queued => TicketWorkflowState::Queued,
|
||||||
StateTarget::InProgress => TicketWorkflowState::InProgress,
|
StateTarget::InProgress => TicketWorkflowState::InProgress,
|
||||||
StateTarget::Done => TicketWorkflowState::Done,
|
StateTarget::Done => {
|
||||||
|
return Err(TicketCliError::new(
|
||||||
|
"done is guarded by MergeRequestComplete with an approved immutable revision and operation_id",
|
||||||
|
));
|
||||||
|
}
|
||||||
StateTarget::Closed => {
|
StateTarget::Closed => {
|
||||||
return Err(TicketCliError::new(
|
return Err(TicketCliError::new(
|
||||||
"yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead",
|
"yoi ticket state <ticket> closed cannot write resolution.md; use `yoi ticket close <ticket> --resolution <text>` instead",
|
||||||
@@ -965,64 +942,6 @@ fn parse_comment(args: &[String]) -> Result<CommentOptions, TicketCliError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_review(args: &[String]) -> Result<ReviewOptions, TicketCliError> {
|
|
||||||
if args.is_empty() || args[0].starts_with('-') {
|
|
||||||
return Err(TicketCliError::new("review requires <id>"));
|
|
||||||
}
|
|
||||||
let query = args[0].clone();
|
|
||||||
let mut approve = false;
|
|
||||||
let mut request_changes = false;
|
|
||||||
let mut file = None;
|
|
||||||
let mut message = None;
|
|
||||||
let mut i = 1;
|
|
||||||
while i < args.len() {
|
|
||||||
match args[i].as_str() {
|
|
||||||
"--approve" => {
|
|
||||||
approve = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
"--request-changes" => {
|
|
||||||
request_changes = true;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
_ => match option_with_value(args, &mut i)? {
|
|
||||||
Some(("--file", value)) => file = Some(PathBuf::from(value)),
|
|
||||||
Some(("--message", value)) => message = Some(value),
|
|
||||||
Some((name, _)) => {
|
|
||||||
return Err(TicketCliError::new(format!(
|
|
||||||
"unknown review argument: {name}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
return Err(TicketCliError::new(format!(
|
|
||||||
"unknown review argument: {}",
|
|
||||||
args[i]
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let result = match (approve, request_changes) {
|
|
||||||
(true, false) => TicketReviewResult::Approve,
|
|
||||||
(false, true) => TicketReviewResult::RequestChanges,
|
|
||||||
(false, false) => {
|
|
||||||
return Err(TicketCliError::new(
|
|
||||||
"review requires exactly one of --approve or --request-changes",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
(true, true) => {
|
|
||||||
return Err(TicketCliError::new(
|
|
||||||
"review accepts exactly one of --approve or --request-changes",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(ReviewOptions {
|
|
||||||
query,
|
|
||||||
result,
|
|
||||||
body: exactly_one_body("review", file, message)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_state(args: &[String]) -> Result<StateOptions, TicketCliError> {
|
fn parse_state(args: &[String]) -> Result<StateOptions, TicketCliError> {
|
||||||
if args.len() != 2 {
|
if args.len() != 2 {
|
||||||
return Err(TicketCliError::new(
|
return Err(TicketCliError::new(
|
||||||
@@ -1244,7 +1163,7 @@ fn default_author() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn help_text() -> &'static str {
|
fn help_text() -> &'static str {
|
||||||
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
|
"yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1375,7 +1294,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_cli_create_list_show_comment_review_state_close_and_doctor() {
|
fn ticket_cli_create_list_show_comment_state_close_and_doctor() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
let created = run(&temp, &["create", "--title", "CLI Created"]);
|
let created = run(&temp, &["create", "--title", "CLI Created"]);
|
||||||
@@ -1416,22 +1335,6 @@ mod tests {
|
|||||||
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
|
.contains(&format!("appended\t{}\timplementation_report", ticket_id))
|
||||||
);
|
);
|
||||||
|
|
||||||
let reviewed = run(
|
|
||||||
&temp,
|
|
||||||
&[
|
|
||||||
"review",
|
|
||||||
&ticket_id,
|
|
||||||
"--approve",
|
|
||||||
"--message",
|
|
||||||
"Looks good.",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
reviewed
|
|
||||||
.stdout
|
|
||||||
.contains(&format!("reviewed\t{}\tapprove", ticket_id))
|
|
||||||
);
|
|
||||||
|
|
||||||
let ready = run(&temp, &["state", &ticket_id, "ready"]);
|
let ready = run(&temp, &["state", &ticket_id, "ready"]);
|
||||||
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
|
assert_eq!(ready.stdout, format!("state\t{}\tready\n", ticket_id));
|
||||||
let ready_listed = run(&temp, &["list", "--state", "ready"]);
|
let ready_listed = run(&temp, &["list", "--state", "ready"]);
|
||||||
@@ -1450,10 +1353,10 @@ mod tests {
|
|||||||
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
|
let inprogress_listed = run(&temp, &["list", "--state", "inprogress"]);
|
||||||
assert!(inprogress_listed.stdout.contains(&ticket_id));
|
assert!(inprogress_listed.stdout.contains(&ticket_id));
|
||||||
|
|
||||||
let done = run(&temp, &["state", &ticket_id, "done"]);
|
let done_error = parse_ticket_args(&args(&["state", &ticket_id, "done"]))
|
||||||
assert_eq!(done.stdout, format!("state\t{}\tdone\n", ticket_id));
|
.and_then(|cli| run_in_workspace(cli, temp.path()))
|
||||||
let done_listed = run(&temp, &["list", "--state", "done"]);
|
.unwrap_err();
|
||||||
assert!(done_listed.stdout.contains(&ticket_id));
|
assert!(done_error.to_string().contains("MergeRequestComplete"));
|
||||||
|
|
||||||
let closed = run(
|
let closed = run(
|
||||||
&temp,
|
&temp,
|
||||||
@@ -1469,7 +1372,6 @@ mod tests {
|
|||||||
assert!(final_show.stdout.contains("State: closed"));
|
assert!(final_show.stdout.contains("State: closed"));
|
||||||
assert!(final_show.stdout.contains("Done via yoi ticket."));
|
assert!(final_show.stdout.contains("Done via yoi ticket."));
|
||||||
assert!(final_show.stdout.contains("implementation_report"));
|
assert!(final_show.stdout.contains("implementation_report"));
|
||||||
assert!(final_show.stdout.contains("review"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1594,20 +1496,6 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("exactly one"));
|
assert!(err.to_string().contains("exactly one"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_cli_rejects_ambiguous_review_result() {
|
|
||||||
let err = parse_ticket_args(&args(&[
|
|
||||||
"review",
|
|
||||||
"ticket",
|
|
||||||
"--approve",
|
|
||||||
"--request-changes",
|
|
||||||
"--message",
|
|
||||||
"body",
|
|
||||||
]))
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(err.to_string().contains("exactly one"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_cli_state_closed_requires_close_command() {
|
fn ticket_cli_state_closed_requires_close_command() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Use the highest-level interface that matches the work:
|
|||||||
|
|
||||||
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
- Use `yoi panel` for the Ticket/Intake/Orchestrator workspace Dashboard and role-launch actions.
|
||||||
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
- Use `yoi objective ...` for lightweight medium-term Objective records and their non-blocking canonical Ticket links.
|
||||||
- Inside Workers, use typed Ticket tools to create, inspect, comment, review, and close Tickets.
|
- Inside Workers, use typed Ticket tools for Ticket records and typed Merge Request tools for immutable implementation/review/completion evidence.
|
||||||
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
- For multi-step work, follow the typed Ticket role surfaces and recorded Ticket lifecycle gates.
|
||||||
|
|
||||||
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
Maintainers can inspect the local `.yoi/tickets/` files directly when debugging storage, but normal user instructions should go through `yoi panel`, Ticket tools, or `yoi ticket ...`.
|
||||||
@@ -37,8 +37,8 @@ Workers with the Ticket built-in feature can use typed Ticket tools:
|
|||||||
- `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
|
- `TicketList` — lightweight bounded overview for selecting ids; it returns short summaries only and must not be used as body/thread/artifact authority.
|
||||||
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
|
- `TicketShow` — detailed authority for a single Ticket, including body/thread/artifact metadata/resolution context subject to its own bounds.
|
||||||
- `TicketComment`
|
- `TicketComment`
|
||||||
- `TicketReview`
|
- `MergeRequestShow`, `MergeRequestOpen`, `MergeRequestAddRevision`, `MergeRequestComplete`
|
||||||
- `TicketWorkflowState`
|
- `MergeRequestReviewSubmit` — available only inside the attested direct-child Reviewer attempt; attempt/revision capability material is not model input.
|
||||||
- `TicketClose`
|
- `TicketClose`
|
||||||
- `TicketRelationRecord`
|
- `TicketRelationRecord`
|
||||||
- `TicketRelationQuery`
|
- `TicketRelationQuery`
|
||||||
@@ -52,7 +52,7 @@ Use them when a Worker needs to materialize or update project records:
|
|||||||
|
|
||||||
- Intake creates a new Ticket after user agreement.
|
- Intake creates a new Ticket after user agreement.
|
||||||
- Orchestrator records routing decisions and intent packets.
|
- Orchestrator records routing decisions and intent packets.
|
||||||
- Reviewer records approve/request-changes review results.
|
- Reviewer commits an approve/request-changes result against one immutable Merge Request revision.
|
||||||
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
- Maintainer closes a Ticket with a resolution when merge/validation/cleanup evidence is complete.
|
||||||
|
|
||||||
Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
Do not bypass Ticket lifecycle gates just because Ticket tools are available. Ticket mutation is a project-record operation and should remain auditable.
|
||||||
@@ -241,9 +241,9 @@ Implementation normally happens in a child git worktree created by the Orchestra
|
|||||||
|
|
||||||
### 5. Review
|
### 5. Review
|
||||||
|
|
||||||
Reviewer Workers should be sibling Workers, not children of coder Workers. They should read the Ticket, intent packet, diff, implementation report, and validation evidence.
|
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.
|
||||||
|
|
||||||
Review results should be recorded with the `TicketReview` tool. Maintainers working directly with the local backend can use the `yoi ticket` CLI documented later.
|
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.
|
Blockers must be fixed or explicitly escalated before merge-ready submission.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
states = {
|
states = {
|
||||||
implement = {
|
implement = {
|
||||||
instructions = "Implement the requested Ticket scope, run the narrow and dependent validation required by the changed contracts, and record the concrete repository/test evidence. When the implementation is ready for independent review, request a Flow transition.";
|
instructions = "Open or update the Ticket Merge Request with immutable repository revision evidence, run the narrow and dependent validation required by the changed contracts, and record concrete evidence. When the current MR revision is ready for independent review, request a Flow transition. A Flow transition is never Ticket completion authority.";
|
||||||
transitions = {
|
transitions = {
|
||||||
review = {
|
review = {
|
||||||
target = "review";
|
target = "review";
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
review = {
|
review = {
|
||||||
instructions = "Spawn one independent Reviewer SubWorker with bounded Ticket, repository, diff, and validation context. Read its committed review through worker observation. Do not review your own implementation or treat a prose status as approval. After the Reviewer returns a typed approval or concrete requested changes, 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 = {
|
transitions = {
|
||||||
approved = {
|
approved = {
|
||||||
target = "done";
|
target = "complete";
|
||||||
condition = "The latest independent Reviewer attempt for the current implementation completed and approved it, with no later unresolved request_changes finding.";
|
condition = "The authoritative Merge Request current revision has a structured approve result from its registered direct-child builtin:reviewer attempt, with no later unresolved request_changes finding. The Flow transition itself does not complete the Ticket.";
|
||||||
};
|
};
|
||||||
changes_requested = {
|
changes_requested = {
|
||||||
target = "fix";
|
target = "fix";
|
||||||
@@ -38,8 +38,18 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
complete = {
|
||||||
|
instructions = "Call MergeRequestComplete with a fresh operation_id and the approved current revision. The Server must revalidate current assignment, immutable revision, registered Reviewer attempt, and Ticket inprogress CAS. Only after the authoritative operation returns Ticket state done, request a Flow transition.";
|
||||||
|
transitions = {
|
||||||
|
completed = {
|
||||||
|
target = "done";
|
||||||
|
condition = "MergeRequestComplete durably returned done for this exact operation_id and current approved revision. A Flow state or prose report alone is never sufficient.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
done = {
|
done = {
|
||||||
instructions = "The Coder implementation and independent review loop is complete.";
|
instructions = "The guarded Merge Request completion operation committed Ticket state done. Flow terminal state only reflects that durable authority.";
|
||||||
terminal = true;
|
terminal = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ import "./base.dcdl" // {
|
|||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = false; };
|
sub_worker = { enabled = false; };
|
||||||
worker = { enabled = false; };
|
worker = { enabled = false; };
|
||||||
ticket = { enabled = true; thread = true; };
|
ticket = { enabled = true; thread = false; };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
You are the Ticket Coder role.
|
You are the assigned Coder. Implement the requested scope in the provided Workdir and keep durable evidence on the Ticket and its Merge Request.
|
||||||
|
|
||||||
Keep role behavior here and treat the first committed user message as concrete Ticket/action context only. Implement only within the delegated worktree/branch and authority scope. Treat the Ticket, intent packet, binding decisions/invariants, implementation latitude, validation expectations, and report expectations as the contract.
|
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose.
|
||||||
|
|
||||||
Choose local implementation tactics within that contract. Escalate to the Orchestrator instead of expanding scope when design, permission, dependency, prompt-boundary, or Ticket-boundary questions appear. Do not merge, push, close Tickets, delete worktrees, or create generated memory/local/runtime/log/lock/cache/socket/secret-like `.yoi` state.
|
Before review, open or append an immutable Merge Request revision containing the exact base/head/tree and changed-path evidence. Spawn the Reviewer only as your actual direct-child `builtin:reviewer` SubWorker, delegate read-only scope, and include the structured `review` handoff with the Ticket id and current MR revision id. Reviewer prose is not approval: the child must commit `MergeRequestReviewSubmit` through its injected attempt authority.
|
||||||
|
|
||||||
Keep the repository operational throughout the work unless the Ticket explicitly permits a bounded incomplete state. Report the implementation and proportionate validation through the available typed Ticket tools; do not edit Ticket storage directly.
|
A request-changes result requires a new immutable revision and a fresh Reviewer child attempt. Flow terminal state is not Ticket completion authority. Complete only through `MergeRequestComplete` with a unique operation id and the currently approved revision; the Server revalidates assignment and fences Ticket state side effects.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
You are the Ticket Reviewer role.
|
You are the Ticket Reviewer role running as an actual Runtime-owned direct child of the assigned Coder.
|
||||||
|
|
||||||
Keep role behavior here and treat the first committed user message as concrete Ticket/action context only. Review the implementation against the Ticket intent, binding decisions/invariants, acceptance criteria, and project design boundaries. Prefer read-only inspection and focused validation; do not merge, close, clean up worktrees, or take over implementation unless explicitly asked.
|
Keep role behavior here and treat the first committed user message as bounded Ticket/Merge Request context only. Review the immutable current Merge Request revision 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.
|
||||||
|
|
||||||
Report clear approve/request-changes evidence with risks, validation performed, and any unresolved requirement or design-boundary concern. When a workflow is invoked, follow that workflow as the procedural authority for reviewer handoff and report shape.
|
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. Attempt identity and revision identity are injected by your child Workspace client and are not model inputs. If the authoritative revision changed, submission must fail 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 behavior or compatibility.
|
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.
|
||||||
|
|||||||
@@ -224,7 +224,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
ticketDetailLoad.includes("/repositories") &&
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
ticketDetailPage.includes('mutate("queue", "/queue"') &&
|
||||||
ticketDetailPage.includes('mutate("review", "/review"') &&
|
ticketDetailPage.includes("/merge-request/merge") &&
|
||||||
|
ticketDetailPage.includes("explicit_confirmation: true") &&
|
||||||
|
!ticketDetailPage.includes('mutate("review", "/review"') &&
|
||||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||||
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
|
||||||
ticketDetailPage.includes("ticket.relations.outgoing"),
|
ticketDetailPage.includes("ticket.relations.outgoing"),
|
||||||
|
|||||||
@@ -17,6 +17,16 @@
|
|||||||
TicketDetail,
|
TicketDetail,
|
||||||
} from "$lib/workspace/sidebar/types";
|
} from "$lib/workspace/sidebar/types";
|
||||||
|
|
||||||
|
type MergeRequestDetail = {
|
||||||
|
state: "draft" | "open" | "closed" | "merged";
|
||||||
|
review_status: "pending" | "approved" | "changes_requested";
|
||||||
|
current_revision: { revision_id: string; head_commit: string; head_tree: string; diff_digest: string; changed_paths: string[]; summary: string };
|
||||||
|
current_review?: { decision: string; body: string; reviewer_effective_profile: string } | null;
|
||||||
|
merged_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) => state !== "done");
|
||||||
|
|
||||||
const { data } = $props<{
|
const { data } = $props<{
|
||||||
data: {
|
data: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -24,6 +34,7 @@
|
|||||||
ticket: ApiResult<TicketDetail>;
|
ticket: ApiResult<TicketDetail>;
|
||||||
repositories: ApiResult<RepositoryListResponse>;
|
repositories: ApiResult<RepositoryListResponse>;
|
||||||
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
|
||||||
|
mergeRequest: ApiResult<MergeRequestDetail | null>;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -34,6 +45,7 @@
|
|||||||
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
||||||
|
|
||||||
let ticket = $state<TicketDetail>(loadedTicket);
|
let ticket = $state<TicketDetail>(loadedTicket);
|
||||||
|
let mergeRequest = $state<MergeRequestDetail | null>(initialData.mergeRequest.data ?? null);
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editTitle = $state(loadedTicket.title);
|
let editTitle = $state(loadedTicket.title);
|
||||||
let editBody = $state(loadedTicket.body);
|
let editBody = $state(loadedTicket.body);
|
||||||
@@ -43,8 +55,7 @@
|
|||||||
let transitionReason = $state("");
|
let transitionReason = $state("");
|
||||||
let threadRole = $state("comment");
|
let threadRole = $state("comment");
|
||||||
let threadBody = $state("");
|
let threadBody = $state("");
|
||||||
let reviewResult = $state("approve");
|
let confirmMerge = $state(false);
|
||||||
let reviewBody = $state("");
|
|
||||||
let resolution = $state("");
|
let resolution = $state("");
|
||||||
let busy = $state<string | null>(null);
|
let busy = $state<string | null>(null);
|
||||||
let errorMessage = $state<string | null>(null);
|
let errorMessage = $state<string | null>(null);
|
||||||
@@ -134,15 +145,27 @@
|
|||||||
) threadBody = "";
|
) threadBody = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function review(event: SubmitEvent) {
|
async function mergeConfirmedRevision() {
|
||||||
event.preventDefault();
|
if (!mergeRequest || !confirmMerge || busy) return;
|
||||||
if (!reviewBody.trim()) return;
|
busy = "merge";
|
||||||
if (
|
errorMessage = null;
|
||||||
await mutate("review", "/review", {
|
try {
|
||||||
result: reviewResult,
|
mergeRequest = await workspaceApiJsonWithBody<MergeRequestDetail>(
|
||||||
body: reviewBody.trim(),
|
`${ticketPath}/merge-request/merge`,
|
||||||
})
|
{
|
||||||
) reviewBody = "";
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision_id: mergeRequest.current_revision.revision_id,
|
||||||
|
explicit_confirmation: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
confirmMerge = false;
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeTicket(event: SubmitEvent) {
|
async function closeTicket(event: SubmitEvent) {
|
||||||
@@ -277,7 +300,6 @@
|
|||||||
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
|
<p>The Orchestrator is online. Start a role-specific Worker with the Ticket target below.</p>
|
||||||
<div class="ticket-role-actions">
|
<div class="ticket-role-actions">
|
||||||
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
||||||
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="workspace-callout">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>
|
||||||
@@ -311,7 +333,7 @@
|
|||||||
<form class="ticket-control-form" onsubmit={transition}>
|
<form class="ticket-control-form" onsubmit={transition}>
|
||||||
<label>State
|
<label>State
|
||||||
<select bind:value={nextState}>
|
<select bind:value={nextState}>
|
||||||
{#each TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
|
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
|
||||||
@@ -340,17 +362,27 @@
|
|||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="ticket-control-card">
|
<section class="ticket-control-card">
|
||||||
<summary>Record review</summary>
|
<header><h2>Merge Request</h2></header>
|
||||||
<form class="ticket-control-form" onsubmit={review}>
|
{#if data.mergeRequest.error}
|
||||||
<label>Result<select bind:value={reviewResult}>
|
<p class="workspace-callout is-error">{data.mergeRequest.error}</p>
|
||||||
<option value="approve">Approve</option>
|
{:else if mergeRequest}
|
||||||
<option value="request_changes">Request changes</option>
|
<p><strong>{mergeRequest.state}</strong> · {mergeRequest.review_status}</p>
|
||||||
</select></label>
|
<p><code>{mergeRequest.current_revision.revision_id}</code></p>
|
||||||
<label>Review body<textarea bind:value={reviewBody} rows="5" required></textarea></label>
|
<p>Head <code>{mergeRequest.current_revision.head_commit}</code></p>
|
||||||
<button class="workspace-secondary-button" type="submit" disabled={busy === "review" || !reviewBody.trim()}>Record review</button>
|
{#if mergeRequest.current_revision.summary}<p>{mergeRequest.current_revision.summary}</p>{/if}
|
||||||
</form>
|
{#if mergeRequest.current_review}
|
||||||
</details>
|
<p><strong>{mergeRequest.current_review.decision}</strong> by {mergeRequest.current_review.reviewer_effective_profile}</p>
|
||||||
|
{#if mergeRequest.current_review.body}<RichMarkdown text={mergeRequest.current_review.body} />{/if}
|
||||||
|
{/if}
|
||||||
|
{#if mergeRequest.state === "open" && mergeRequest.review_status === "approved"}
|
||||||
|
<label><input type="checkbox" bind:checked={confirmMerge} /> Explicitly confirm merge of this revision</label>
|
||||||
|
<button class="workspace-primary-button" type="button" disabled={!confirmMerge || busy !== null} onclick={mergeConfirmedRevision}>Confirm merge</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if ticket.state !== "closed"}
|
{#if ticket.state !== "closed"}
|
||||||
<details class="ticket-control-card ticket-close-card">
|
<details class="ticket-control-card ticket-close-card">
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type {
|
import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types";
|
||||||
RepositoryListResponse,
|
|
||||||
TicketDetail,
|
|
||||||
} from "$lib/workspace/sidebar/types";
|
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise<{ data: T | null; error: string | null }> {
|
||||||
const [ticket, repositories, orchestrator] = await Promise.all([
|
try {
|
||||||
loadJson<TicketDetail>(
|
const response = await fetcher(path);
|
||||||
fetch,
|
if (response.status === 404) return { data: null, error: null };
|
||||||
workspaceApiPath(
|
if (!response.ok) return { data: null, error: await response.text() || `HTTP ${response.status}` };
|
||||||
params.workspaceId,
|
return { data: await response.json() as T, error: null };
|
||||||
`/tickets/${encodeURIComponent(params.ticketId)}`,
|
} catch (error) {
|
||||||
),
|
return { data: null, error: error instanceof Error ? error.message : String(error) };
|
||||||
),
|
}
|
||||||
loadJson<RepositoryListResponse>(
|
}
|
||||||
fetch,
|
|
||||||
workspaceApiPath(params.workspaceId, "/repositories"),
|
|
||||||
),
|
|
||||||
loadJson<WorkspaceOrchestratorStatus>(
|
|
||||||
fetch,
|
|
||||||
workspaceApiPath(params.workspaceId, "/orchestrator"),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
export const load = (async ({ fetch, params }) => {
|
||||||
workspaceId: params.workspaceId,
|
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(params.ticketId)}`);
|
||||||
ticketId: params.ticketId,
|
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
|
||||||
ticket,
|
loadJson<TicketDetail>(fetch, ticketPath),
|
||||||
repositories,
|
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
|
||||||
orchestrator,
|
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
|
||||||
};
|
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
|
||||||
|
]);
|
||||||
|
return { workspaceId: params.workspaceId, ticketId: params.ticketId, ticket, repositories, orchestrator, mergeRequest };
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
Reference in New Issue
Block a user