diff --git a/Cargo.lock b/Cargo.lock index 2504aea..e2e2d24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,7 +141,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -152,7 +152,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1761,7 +1761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2113,9 +2113,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -2380,7 +2380,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3103,7 +3103,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3822,7 +3822,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -3860,7 +3860,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -4421,14 +4421,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -4463,9 +4463,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -4909,7 +4909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5445,7 +5445,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6252,7 +6252,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/crates/bug/src/adapters/http.rs b/crates/bug/src/adapters/http.rs index d117e74..02e503f 100644 --- a/crates/bug/src/adapters/http.rs +++ b/crates/bug/src/adapters/http.rs @@ -109,6 +109,9 @@ struct BugResponse { /// Handler user id. #[serde(skip_serializing_if = "Option::is_none")] handler: Option, + /// Markdown description body (defect details). + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, /// Last mutator's user id (stamped on every mutation). #[serde(skip_serializing_if = "Option::is_none")] updated_by: Option, @@ -131,6 +134,7 @@ impl From for BugResponse { created_by: b.created_by, severity: b.severity, handler: b.handler, + description: b.description, updated_by: b.updated_by, updated_at: b.updated_at, custom_fields: b.custom_fields, @@ -148,6 +152,8 @@ struct CreateBugRequest { severity: Option, /// Handler user id (optional). handler: Option, + /// Markdown description body (optional). + description: Option, /// Custom field values, key → string value (max 32 keys, key ≤ 64 chars, value ≤ 2000 chars). #[serde(default)] custom_fields: BTreeMap, @@ -171,6 +177,7 @@ async fn create_bug( Some(&user.user_id), req.severity.as_deref(), req.handler.as_deref(), + req.description.as_deref(), &req.custom_fields, ) .await @@ -261,6 +268,8 @@ struct UpdateBugRequest { severity: Option, /// Handler user id; absent clears. handler: Option, + /// Markdown description body; absent keeps the current value. + description: Option, } /// Updates title/severity/handler and stamps updated_by/updated_at with the caller. @@ -281,6 +290,7 @@ async fn update_bug( req.title.as_deref(), req.severity.as_deref(), req.handler.as_deref(), + req.description.as_deref(), Some(&user.user_id), ) .await diff --git a/crates/bug/src/adapters/in_memory.rs b/crates/bug/src/adapters/in_memory.rs index 4cce826..c825557 100644 --- a/crates/bug/src/adapters/in_memory.rs +++ b/crates/bug/src/adapters/in_memory.rs @@ -82,6 +82,7 @@ impl BugRepository for InMemoryBugRepository { created_by: new_bug.created_by.clone(), severity: new_bug.severity.clone(), handler: new_bug.handler.clone(), + description: new_bug.description.clone(), updated_by: new_bug.created_by.clone(), updated_at: Some(tick(seq)), custom_fields: new_bug.custom_fields.clone(), @@ -136,6 +137,7 @@ impl BugRepository for InMemoryBugRepository { title: &str, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, operator: Option<&str>, ) -> Result, RepoError> { let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -145,6 +147,7 @@ impl BugRepository for InMemoryBugRepository { b.title = title.to_string(); b.severity = severity.map(str::to_string); b.handler = handler.map(str::to_string); + b.description = description.map(str::to_string); b.updated_by = operator.map(str::to_string); b.updated_at = Some(tick(seq)); b.clone() diff --git a/crates/bug/src/adapters/pg.rs b/crates/bug/src/adapters/pg.rs index 70f2741..6c93286 100644 --- a/crates/bug/src/adapters/pg.rs +++ b/crates/bug/src/adapters/pg.rs @@ -45,7 +45,7 @@ fn json_to_fields(v: &serde_json::Value) -> BTreeMap { } const BUG_COLS: &str = "id, project_id, title, status, deleted, created_at, created_by, \ - severity, handler, updated_by, updated_at::text AS updated_at, custom_fields"; + severity, handler, description, updated_by, updated_at::text AS updated_at, custom_fields"; fn row_to_bug(row: &sqlx::postgres::PgRow) -> Result { let custom: serde_json::Value = row.try_get("custom_fields").map_err(map_err)?; @@ -59,6 +59,7 @@ fn row_to_bug(row: &sqlx::postgres::PgRow) -> Result { created_by: row.try_get("created_by").map_err(map_err)?, severity: row.try_get("severity").map_err(map_err)?, handler: row.try_get("handler").map_err(map_err)?, + description: row.try_get("description").map_err(map_err)?, updated_by: row.try_get("updated_by").map_err(map_err)?, updated_at: row.try_get("updated_at").map_err(map_err)?, custom_fields: json_to_fields(&custom), @@ -108,8 +109,8 @@ impl BugRepository for PgBugRepository { // updated_by/updated_at seed from the creator so the audit pair is never empty. let row = sqlx::query(&format!( "INSERT INTO ms_bug (project_id, title, status, created_by, custom_fields, \ - severity, handler, updated_by, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $4, now()) RETURNING {BUG_COLS}" + severity, handler, description, updated_by, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $4, now()) RETURNING {BUG_COLS}" )) .bind(&new_bug.project_id) .bind(&new_bug.title) @@ -118,6 +119,7 @@ impl BugRepository for PgBugRepository { .bind(fields_to_json(&new_bug.custom_fields)) .bind(&new_bug.severity) .bind(&new_bug.handler) + .bind(&new_bug.description) .fetch_one(&self.pool) .await .map_err(map_err)?; @@ -171,10 +173,12 @@ impl BugRepository for PgBugRepository { title: &str, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, operator: Option<&str>, ) -> Result, RepoError> { let row = sqlx::query(&format!( "UPDATE ms_bug SET title = $2, severity = $3, handler = $4, \ + description = COALESCE($6, description), \ updated_by = $5, updated_at = now() \ WHERE id = $1 AND deleted = false RETURNING {BUG_COLS}" )) @@ -183,6 +187,7 @@ impl BugRepository for PgBugRepository { .bind(severity) .bind(handler) .bind(operator) + .bind(description) .fetch_optional(&self.pool) .await .map_err(map_err)?; @@ -368,7 +373,7 @@ mod tests { // Meta update replaces severity/handler and stamps the operator. let updated = repo - .update_meta(&bug.id, "login crash (modified)", Some("P0"), None, Some("dave")) + .update_meta(&bug.id, "login crash (modified)", Some("P0"), None, None, Some("dave")) .await .expect("meta") .expect("some"); @@ -376,7 +381,11 @@ mod tests { assert_eq!(updated.severity.as_deref(), Some("P0")); assert_eq!(updated.handler, None); assert_eq!(updated.updated_by.as_deref(), Some("dave")); - assert!(repo.update_meta("ghost", "x", None, None, None).await.expect("meta").is_none()); + assert!(repo + .update_meta("ghost", "x", None, None, None, None) + .await + .expect("meta") + .is_none()); // Full replacement; empty map clears all. repo.set_custom_fields(&bug.id, &cf(&[("env", "prod")]), Some("erin")) diff --git a/crates/bug/src/application/change_status.rs b/crates/bug/src/application/change_status.rs index 639548d..5170a56 100644 --- a/crates/bug/src/application/change_status.rs +++ b/crates/bug/src/application/change_status.rs @@ -52,7 +52,16 @@ mod tests { async fn seed_bug(repo: &InMemoryBugRepository) -> String { let create = CreateBugUseCase::new(Arc::new(repo.clone())); create - .execute("p1", "boom", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "boom", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("seed") .id diff --git a/crates/bug/src/application/create_bug.rs b/crates/bug/src/application/create_bug.rs index 8508fe8..e205041 100644 --- a/crates/bug/src/application/create_bug.rs +++ b/crates/bug/src/application/create_bug.rs @@ -40,6 +40,7 @@ impl CreateBugUseCase { created_by: Option<&str>, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, custom_fields: &BTreeMap, ) -> Result { let custom_fields = normalize_custom_fields(custom_fields)?; @@ -48,6 +49,7 @@ impl CreateBugUseCase { .with_created_by(created_by) .with_severity(severity) .with_handler(normalize_handler(handler)) + .with_description(description.map(|s| s.to_string())) .with_custom_fields(custom_fields); let flow = self.repo.status_flow(project_id).await?; @@ -71,7 +73,7 @@ mod tests { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); let bug = uc - .execute("p1", "login crash", "NEW", Some("alice"), None, None, &BTreeMap::new()) + .execute("p1", "login crash", "NEW", Some("alice"), None, None, None, &BTreeMap::new()) .await .expect("ok"); assert_eq!(bug.status, "NEW"); @@ -95,6 +97,7 @@ mod tests { None, Some(" p0 "), Some(" bob "), + None, &BTreeMap::new(), ) .await @@ -108,7 +111,7 @@ mod tests { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); let err = uc - .execute("p1", "x", "NEW", None, Some("SEV1"), None, &BTreeMap::new()) + .execute("p1", "x", "NEW", None, Some("SEV1"), None, None, &BTreeMap::new()) .await .unwrap_err(); assert_eq!(err, CreateBugError::Validation(BugError::InvalidSeverity("SEV1".into()))); @@ -119,12 +122,15 @@ mod tests { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); let raw = BTreeMap::from([(" severity ".to_string(), "P0".to_string())]); - let bug = uc.execute("p1", "login crash", "NEW", None, None, None, &raw).await.expect("ok"); + let bug = + uc.execute("p1", "login crash", "NEW", None, None, None, None, &raw).await.expect("ok"); assert_eq!(bug.custom_fields, BTreeMap::from([("severity".to_string(), "P0".to_string())])); // Blank key is a validation error. let bad = BTreeMap::from([(" ".to_string(), "v".to_string())]); assert_eq!( - uc.execute("p1", "crashed again", "NEW", None, None, None, &bad).await.unwrap_err(), + uc.execute("p1", "crashed again", "NEW", None, None, None, None, &bad) + .await + .unwrap_err(), CreateBugError::Validation(BugError::EmptyCustomFieldKey) ); } @@ -133,8 +139,10 @@ mod tests { async fn rejects_unknown_initial_status() { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); - let err = - uc.execute("p1", "x", "GHOST", None, None, None, &BTreeMap::new()).await.unwrap_err(); + let err = uc + .execute("p1", "x", "GHOST", None, None, None, None, &BTreeMap::new()) + .await + .unwrap_err(); assert_eq!(err, CreateBugError::Validation(BugError::UnknownStatus("GHOST".into()))); } @@ -142,8 +150,10 @@ mod tests { async fn rejects_blank_title() { let repo = InMemoryBugRepository::with_default_flow("p1"); let uc = CreateBugUseCase::new(Arc::new(repo)); - let err = - uc.execute("p1", " ", "NEW", None, None, None, &BTreeMap::new()).await.unwrap_err(); + let err = uc + .execute("p1", " ", "NEW", None, None, None, None, &BTreeMap::new()) + .await + .unwrap_err(); assert_eq!(err, CreateBugError::Validation(BugError::EmptyTitle)); } } diff --git a/crates/bug/src/application/custom_fields.rs b/crates/bug/src/application/custom_fields.rs index 5132512..8433430 100644 --- a/crates/bug/src/application/custom_fields.rs +++ b/crates/bug/src/application/custom_fields.rs @@ -55,7 +55,7 @@ mod tests { async fn seed_bug(repo: &InMemoryBugRepository) -> String { CreateBugUseCase::new(Arc::new(repo.clone())) - .execute("p1", "boom", "NEW", None, None, None, &fields(&[("severity", "P1")])) + .execute("p1", "boom", "NEW", None, None, None, None, &fields(&[("severity", "P1")])) .await .expect("seed") .id diff --git a/crates/bug/src/application/followers.rs b/crates/bug/src/application/followers.rs index 1742e5c..653f2f1 100644 --- a/crates/bug/src/application/followers.rs +++ b/crates/bug/src/application/followers.rs @@ -60,7 +60,16 @@ mod tests { async fn seed_bug(repo: &InMemoryBugRepository) -> String { CreateBugUseCase::new(Arc::new(repo.clone())) - .execute("p1", "boom", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "boom", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("seed") .id diff --git a/crates/bug/src/application/list_bugs.rs b/crates/bug/src/application/list_bugs.rs index 44a120e..787fbe8 100644 --- a/crates/bug/src/application/list_bugs.rs +++ b/crates/bug/src/application/list_bugs.rs @@ -36,11 +36,29 @@ mod tests { let repo = Arc::new(InMemoryBugRepository::with_default_flow("p1")); let create = crate::application::CreateBugUseCase::new(repo.clone()); create - .execute("p1", "first", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "first", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("ok"); create - .execute("p1", "second", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "second", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("ok"); @@ -55,7 +73,16 @@ mod tests { async fn isolates_by_project() { let repo = Arc::new(InMemoryBugRepository::with_default_flow("p1")); crate::application::CreateBugUseCase::new(repo.clone()) - .execute("p1", "boom", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "boom", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("ok"); diff --git a/crates/bug/src/application/relations.rs b/crates/bug/src/application/relations.rs index 7e83476..05a7884 100644 --- a/crates/bug/src/application/relations.rs +++ b/crates/bug/src/application/relations.rs @@ -82,7 +82,16 @@ mod tests { async fn seed_bug(repo: &InMemoryBugRepository) -> String { CreateBugUseCase::new(Arc::new(repo.clone())) - .execute("p1", "boom", "NEW", None, None, None, &std::collections::BTreeMap::new()) + .execute( + "p1", + "boom", + "NEW", + None, + None, + None, + None, + &std::collections::BTreeMap::new(), + ) .await .expect("seed") .id diff --git a/crates/bug/src/application/update_meta.rs b/crates/bug/src/application/update_meta.rs index 62977ab..d5b7c9f 100644 --- a/crates/bug/src/application/update_meta.rs +++ b/crates/bug/src/application/update_meta.rs @@ -34,6 +34,7 @@ impl UpdateBugMetaUseCase { title: Option<&str>, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, operator: Option<&str>, ) -> Result { let severity = normalize_severity(severity)?; @@ -44,7 +45,14 @@ impl UpdateBugMetaUseCase { Some(t) => t.to_string(), }; self.repo - .update_meta(bug_id, &title, severity.as_deref(), handler.as_deref(), operator) + .update_meta( + bug_id, + &title, + severity.as_deref(), + handler.as_deref(), + description, + operator, + ) .await? .ok_or(UpdateBugMetaError::BugNotFound) } @@ -65,6 +73,7 @@ mod tests { Some("alice"), Some("P2"), Some("bob"), + None, &std::collections::BTreeMap::new(), ) .await @@ -78,7 +87,7 @@ mod tests { let uc = UpdateBugMetaUseCase::new(Arc::new(repo)); let bug = uc - .execute(&seeded.id, Some("boom v2"), Some(" p0 "), Some(" carol "), Some("dave")) + .execute(&seeded.id, Some("boom v2"), Some(" p0 "), Some(" carol "), None, Some("dave")) .await .expect("ok"); assert_eq!(bug.title, "boom v2"); @@ -97,7 +106,7 @@ mod tests { let seeded = seed_bug(&repo).await; let uc = UpdateBugMetaUseCase::new(Arc::new(repo)); - let bug = uc.execute(&seeded.id, None, None, None, Some("dave")).await.expect("ok"); + let bug = uc.execute(&seeded.id, None, None, None, None, Some("dave")).await.expect("ok"); assert_eq!(bug.title, "boom"); assert_eq!(bug.severity, None); assert_eq!(bug.handler, None); @@ -110,11 +119,11 @@ mod tests { let uc = UpdateBugMetaUseCase::new(Arc::new(repo)); assert_eq!( - uc.execute(&seeded.id, None, Some("HIGH"), None, None).await.unwrap_err(), + uc.execute(&seeded.id, None, Some("HIGH"), None, None, None).await.unwrap_err(), UpdateBugMetaError::Validation(BugError::InvalidSeverity("HIGH".into())) ); assert_eq!( - uc.execute("ghost", None, None, None, None).await.unwrap_err(), + uc.execute("ghost", None, None, None, None, None).await.unwrap_err(), UpdateBugMetaError::BugNotFound ); } diff --git a/crates/bug/src/domain/bug.rs b/crates/bug/src/domain/bug.rs index 9af9a28..93b3919 100644 --- a/crates/bug/src/domain/bug.rs +++ b/crates/bug/src/domain/bug.rs @@ -89,6 +89,8 @@ pub struct NewBug { pub severity: Option, /// Handler user id. pub handler: Option, + /// Markdown description body (defect details); blank → None. + pub description: Option, /// Validated custom field values (see `normalize_custom_fields`); field definitions /// live in the project template. pub custom_fields: BTreeMap, @@ -106,6 +108,7 @@ impl NewBug { created_by: None, severity: None, handler: None, + description: None, custom_fields: BTreeMap::new(), }) } @@ -126,6 +129,12 @@ impl NewBug { self } + /// Attaches a markdown description; blank text collapses to None. + pub fn with_description(mut self, description: Option) -> Self { + self.description = description.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + self + } + /// Attaches validated custom fields (caller runs `normalize_custom_fields` first). pub fn with_custom_fields(mut self, custom_fields: BTreeMap) -> Self { self.custom_fields = custom_fields; @@ -146,6 +155,8 @@ pub struct Bug { pub severity: Option, /// Handler user id. pub handler: Option, + /// Markdown description body (defect details). + pub description: Option, /// Last mutator's user id; stamped by every mutation, seeded from created_by on insert. pub updated_by: Option, /// Last mutation time as timestamptz text (adapter-formatted). @@ -198,6 +209,7 @@ mod tests { created_by: None, severity: None, handler: None, + description: None, updated_by: None, updated_at: None, custom_fields: BTreeMap::new(), diff --git a/crates/bug/src/ports/bug_repository.rs b/crates/bug/src/ports/bug_repository.rs index 59b9de5..5d284a2 100644 --- a/crates/bug/src/ports/bug_repository.rs +++ b/crates/bug/src/ports/bug_repository.rs @@ -31,14 +31,16 @@ pub trait BugRepository: Send + Sync { ) -> Result<(), RepoError>; /// Updates title/severity/handler (severity/handler are full replacements, - /// None clears) and stamps updated_by/updated_at. Returns the updated bug, - /// or None when it doesn't exist. + /// None clears) and the markdown description (None keeps the current value, + /// Some sets it, including an empty string to clear). Stamps updated_by/ + /// updated_at. Returns the updated bug, or None when it doesn't exist. async fn update_meta( &self, id: &str, title: &str, severity: Option<&str>, handler: Option<&str>, + description: Option<&str>, operator: Option<&str>, ) -> Result, RepoError>; diff --git a/crates/migrate/migrations/0111_bug_description.sql b/crates/migrate/migrations/0111_bug_description.sql new file mode 100644 index 0000000..af37379 --- /dev/null +++ b/crates/migrate/migrations/0111_bug_description.sql @@ -0,0 +1,2 @@ +-- Bug description: markdown body for defect details (rendered in the bug detail view). +ALTER TABLE ms_bug ADD COLUMN IF NOT EXISTS description TEXT; diff --git a/crates/server/src/jev.rs b/crates/server/src/jev.rs new file mode 100644 index 0000000..a0afb9b --- /dev/null +++ b/crates/server/src/jev.rs @@ -0,0 +1,245 @@ +//! Jev (TypeSafe System One) verification judge: a fast typed-judgment model. +//! +//! Jev answers `noul` (yes/no probability) questions about a blob of state — +//! no prose, no code generation. That makes it a drop-in, cheap alternative to +//! the LLM judge for the delivery verification gate: one `noul` per acceptance +//! criterion, all in a single HTTP call; the gate passes only when every +//! probability reaches the configured threshold (default 0.8). +//! +//! Env vars: `SHEPHERD_JEV_API_KEY` (falls back to `TYPESAFE_API_KEY`), +//! `SHEPHERD_JEV_URL` (default `https://api.typesafe.ai/v1/systemone`), +//! `SHEPHERD_JEV_MODEL` (default `jev-latest`), +//! `SHEPHERD_JEV_THRESHOLD` (default `0.8`). + +use std::collections::BTreeMap; +use std::time::Duration; + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::json; + +use orchestrator::ports::{DeliverableView, Judge, Verdict}; + +const DEFAULT_URL: &str = "https://api.typesafe.ai/v1/systemone"; +const DEFAULT_MODEL: &str = "jev-latest"; +const DEFAULT_THRESHOLD: f64 = 0.8; + +#[derive(Deserialize)] +struct SystemOneResponse { + #[serde(default)] + answers: BTreeMap, +} + +#[derive(Deserialize)] +struct NoulAnswer { + noul: f64, +} + +pub struct JevJudge { + client: reqwest::Client, + url: String, + key: String, + model: String, + threshold: f64, +} + +impl JevJudge { + pub fn from_env() -> Option { + let key = std::env::var("SHEPHERD_JEV_API_KEY") + .ok() + .filter(|s| !s.trim().is_empty()) + .or_else(|| std::env::var("TYPESAFE_API_KEY").ok().filter(|s| !s.trim().is_empty()))?; + let url = env_or("SHEPHERD_JEV_URL", DEFAULT_URL); + let model = env_or("SHEPHERD_JEV_MODEL", DEFAULT_MODEL); + let threshold = std::env::var("SHEPHERD_JEV_THRESHOLD") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|t| (0.0..=1.0).contains(t)) + .unwrap_or(DEFAULT_THRESHOLD); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Some(Self { client, url, key, model, threshold }) + } + + #[cfg(test)] + fn new(url: impl Into, key: impl Into, threshold: f64) -> Self { + Self { + client: reqwest::Client::new(), + url: url.into(), + key: key.into(), + model: DEFAULT_MODEL.into(), + threshold, + } + } + + fn state(criteria: &[String], deliverable: &DeliverableView) -> String { + let criteria = criteria.iter().map(|c| format!("- {c}")).collect::>().join("\n"); + format!( + "Acceptance criteria:\n{criteria}\n\nDeliverable:\nkind: {}\nreference: {}\nsummary: {}", + deliverable.kind, deliverable.reference, deliverable.summary + ) + } +} + +#[async_trait] +impl Judge for JevJudge { + async fn judge(&self, criteria: &[String], deliverable: &DeliverableView) -> Verdict { + if criteria.is_empty() { + return Verdict { passed: true, reason: "jev: no criteria to judge".into() }; + } + let questions: BTreeMap = criteria + .iter() + .enumerate() + .map(|(i, c)| { + ( + format!("c{i}"), + json!({ + "type": "noul", + "instructions": format!("Does the deliverable satisfy this acceptance criterion: {c}"), + }), + ) + }) + .collect(); + let body = json!({ "state": Self::state(criteria, deliverable), "model": self.model, "questions": questions }); + + let resp = match self.client.post(&self.url).bearer_auth(&self.key).json(&body).send().await + { + Ok(r) if r.status().is_success() => r, + Ok(r) => return Verdict { passed: false, reason: format!("jev HTTP {}", r.status()) }, + Err(e) => return Verdict { passed: false, reason: format!("jev unreachable: {e}") }, + }; + let parsed: SystemOneResponse = match resp.json().await { + Ok(p) => p, + Err(e) => { + return Verdict { + passed: false, + reason: format!("failed to parse jev response: {e}"), + } + } + }; + + // Every criterion must clear the threshold; report the weakest one. + let mut weakest = (1.0_f64, String::new()); + for (i, c) in criteria.iter().enumerate() { + let key = format!("c{i}"); + let p = match parsed.answers.get(&key) { + Some(a) => a.noul, + None => { + return Verdict { + passed: false, + reason: format!("jev response missing answer {key}"), + } + } + }; + if p < weakest.0 { + weakest = (p, c.clone()); + } + } + if weakest.0 >= self.threshold { + Verdict { + passed: true, + reason: format!( + "jev: min noul {:.2} >= threshold {:.2}", + weakest.0, self.threshold + ), + } + } else { + Verdict { + passed: false, + reason: format!( + "jev: noul {:.2} < threshold {:.2} for criterion: {}", + weakest.0, self.threshold, weakest.1 + ), + } + } + } +} + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).ok().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| default.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{routing::post, Json, Router}; + + async fn spawn(app: Router, path: &str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + format!("http://{addr}{path}") + } + + fn dv() -> DeliverableView { + DeliverableView { + kind: "DIFF".into(), + reference: "branch:feat".into(), + summary: "done".into(), + } + } + + #[tokio::test] + async fn jev_judge_passes_when_every_criterion_clears_threshold() { + let app = Router::new().route( + "/v1/systemone", + post(|| async { + Json(json!({ + "model": "jev-1.13.0", + "answers": { "c0": { "type": "noul", "noul": 0.95 }, "c1": { "type": "noul", "noul": 0.9 } }, + "usage": { "input_tokens": 1, "output_tokens": 1 } + })) + }), + ); + let url = spawn(app, "/v1/systemone").await; + let j = JevJudge::new(url, "k", 0.8); + let v = j.judge(&["login success".into(), "token issued".into()], &dv()).await; + assert!(v.passed, "got: {v:?}"); + assert!(v.reason.contains("0.90"), "reason names the weakest: {}", v.reason); + } + + #[tokio::test] + async fn jev_judge_fails_on_criterion_below_threshold() { + let app = Router::new().route( + "/v1/systemone", + post(|| async { + Json(json!({ + "model": "jev-1.13.0", + "answers": { "c0": { "type": "noul", "noul": 0.99 }, "c1": { "type": "noul", "noul": 0.5 } }, + "usage": { "input_tokens": 1, "output_tokens": 1 } + })) + }), + ); + let url = spawn(app, "/v1/systemone").await; + let j = JevJudge::new(url, "k", 0.8); + let v = j.judge(&["login success".into(), "token issued".into()], &dv()).await; + assert!(!v.passed); + assert!( + v.reason.contains("token issued"), + "reason names the failing criterion: {}", + v.reason + ); + } + + #[tokio::test] + async fn jev_judge_passes_with_no_criteria() { + let j = JevJudge::new("http://127.0.0.1:9", "k", 0.8); + let v = j.judge(&[], &dv()).await; + assert!(v.passed); + assert_eq!(v.reason, "jev: no criteria to judge"); + } + + #[tokio::test] + async fn jev_judge_fail_closed_on_bad_json() { + let app = Router::new().route("/v1/systemone", post(|| async { "not json at all" })); + let url = spawn(app, "/v1/systemone").await; + let j = JevJudge::new(url, "k", 0.8); + let v = j.judge(&["login success".into()], &dv()).await; + assert!(!v.passed); + assert!(v.reason.contains("parse"), "got: {}", v.reason); + } +} diff --git a/crates/server/src/judge.rs b/crates/server/src/judge.rs index dec3253..ec8174d 100644 --- a/crates/server/src/judge.rs +++ b/crates/server/src/judge.rs @@ -41,6 +41,12 @@ impl Judge for HttpJudge { } pub fn build_judge() -> Arc { + // Jev first: it is the cheap typed-judgment alternative to the LLM judge. + // Setting SHEPHERD_JEV_API_KEY (or TYPESAFE_API_KEY) routes the verification + // gate to Jev even when SHEPHERD_LLM_URL is also set for planner/PRD use. + if let Some(j) = crate::jev::JevJudge::from_env() { + return Arc::new(j); + } if let Some(j) = crate::llm::judge() { return j; } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 9575e27..0ab2bc0 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -11,6 +11,7 @@ mod debug_send; mod decomposition_run; mod design_bridge; mod import_scheduler; +mod jev; mod judge; mod llm; mod mcp_bus; diff --git a/crates/shepherd-cli/src/client.rs b/crates/shepherd-cli/src/client.rs index 53ada95..26f5d44 100644 --- a/crates/shepherd-cli/src/client.rs +++ b/crates/shepherd-cli/src/client.rs @@ -347,7 +347,7 @@ pub fn normalize_agent(t: &str) -> R { "codebuddy" | "code_buddy" => Ok("CODEBUDDY".into()), "deepcode" | "deep_code" => Ok("DEEPCODE".into()), other => Err(format!( - "unknown agent type: {other} (supported: claude-code | codex | opencode | codebuddy)" + "unknown agent type: {other} (supported: claude-code | codex | opencode | codebuddy | deepcode)" ) .into()), } diff --git a/docs/USAGE.md b/docs/USAGE.md index feaab36..86f9f7a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -187,6 +187,23 @@ All server start-up switches are consolidated into a typed config (`crates/serve Advanced/lazy-read switches also exist for the pluggable AI touchpoints — `SHEPHERD_AGENT_URL` / `SHEPHERD_AGENT_CMD` / `SHEPHERD_AGENT_ASYNC` (executor routing), `SHEPHERD_LLM_URL`, `SHEPHERD_PLANNER_URL`, `SHEPHERD_JUDGE_URL`, `SHEPHERD_MAX_REVISIONS`. Defaults need none of them. +#### 5.1.1 Verification judge: Jev + +The delivery verification gate (`Judge`) can run on [Jev](https://learnjev.com), TypeSafe's +fast typed-judgment model (yes/no probability answers, no prose) instead of an LLM. +Set the key and the gate asks one `noul` question per acceptance criterion in a single +call; it passes only when every probability reaches the threshold. + +| Variable | Default | Meaning | +|---|---|---| +| `SHEPHERD_JEV_API_KEY` | *(falls back to `TYPESAFE_API_KEY`)* | TypeSafe API key — presence enables the Jev judge (takes priority over the LLM judge) | +| `SHEPHERD_JEV_URL` | `https://api.typesafe.ai/v1/systemone` | Jev System One endpoint | +| `SHEPHERD_JEV_MODEL` | `jev-latest` | Jev model alias | +| `SHEPHERD_JEV_THRESHOLD` | `0.8` | Minimum `noul` probability per criterion to pass | + +Failures fail closed: any HTTP/parse error returns a not-passed verdict, so a broken +Jev setup blocks delivery instead of silently passing it. + ### 5.2 Agent-runtime (`agent-runtime`) | Variable | Default | Meaning | diff --git a/docs/USAGE.zh-CN.md b/docs/USAGE.zh-CN.md index aab1dee..d3adead 100644 --- a/docs/USAGE.zh-CN.md +++ b/docs/USAGE.zh-CN.md @@ -186,6 +186,22 @@ cargo clippy --workspace -- -D warnings 可插拔 AI 触点还有若干进阶/懒读开关:`SHEPHERD_AGENT_URL` / `SHEPHERD_AGENT_CMD` / `SHEPHERD_AGENT_ASYNC`(执行者路由)、`SHEPHERD_LLM_URL`、`SHEPHERD_PLANNER_URL`、`SHEPHERD_JUDGE_URL`、`SHEPHERD_MAX_REVISIONS`。默认全都无需设置。 +#### 5.1.1 验证门 Judge:Jev + +交付验证门(`Judge`)可以跑在 [Jev](https://learnjev.com)(TypeSafe 的快速类型化判断 +模型,只回答是/否概率,不生成文字)上,替代 LLM judge。配好 key 后,验证门会为每条 +验收标准发一个 `noul` 问题(单次请求),所有概率都达到阈值才判过。 + +| 变量 | 默认 | 含义 | +|---|---|---| +| `SHEPHERD_JEV_API_KEY` | *(缺省回退 `TYPESAFE_API_KEY`)* | TypeSafe API key——设了即启用 Jev judge(优先级高于 LLM judge) | +| `SHEPHERD_JEV_URL` | `https://api.typesafe.ai/v1/systemone` | Jev System One 端点 | +| `SHEPHERD_JEV_MODEL` | `jev-latest` | Jev 模型别名 | +| `SHEPHERD_JEV_THRESHOLD` | `0.8` | 每条标准的最低 `noul` 通过概率 | + +失败一律 fail-closed:任何 HTTP/解析错误都判为不通过,即 Jev 配置坏了会阻断交付, +而不是静默放行。 + ### 5.2 Agent-runtime(`agent-runtime`) | 变量 | 默认 | 含义 | diff --git a/web/src/api.ts b/web/src/api.ts index 1181146..8e74e8f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1303,6 +1303,8 @@ export interface Bug { severity?: string | null /** Handler user id. */ handler?: string | null + /** Markdown description body (defect details). */ + description?: string | null /** Last mutator's user id (stamped on every mutation). */ updatedBy?: string | null /** Last mutation time (timestamptz text). */ @@ -1967,9 +1969,9 @@ export const api = { // Bugs — list/create/status transitions all backend-driven (project-scoped, newest first) bugs: (projectId: string) => projectId ? http.get(`/bug?projectId=${encodeURIComponent(projectId)}`) : Promise.resolve([] as Bug[]), - createBug: (b: { projectId: string; title: string; initialStatus: string; severity?: string; handler?: string; customFields?: Record }) => http.post('/bug', b), - // Meta update: severity/handler are full replacements (omit to clear); omitted title keeps the current one. - updateBug: (id: string, b: { title?: string; severity?: string; handler?: string }) => http.put(`/bug/${encodeURIComponent(id)}`, b), + createBug: (b: { projectId: string; title: string; initialStatus: string; severity?: string; handler?: string; description?: string; customFields?: Record }) => http.post('/bug', b), + // Meta update: severity/handler are full replacements (omit to clear); omitted title keeps the current one; omitted description keeps the current value. + updateBug: (id: string, b: { title?: string; severity?: string; handler?: string; description?: string }) => http.put(`/bug/${encodeURIComponent(id)}`, b), setBugStatus: (id: string, status: string) => http.post(`/bug/${id}/status`, { status }), // In-app notifications (message center): always scoped to the current session user. diff --git a/web/src/pages/Bugs.tsx b/web/src/pages/Bugs.tsx index 6e4ae96..1b7b151 100644 --- a/web/src/pages/Bugs.tsx +++ b/web/src/pages/Bugs.tsx @@ -1,11 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { Button, Empty, Form, Input, Select, Space, Tag, Tooltip } from 'antd' +import { Button, Descriptions, Empty, Form, Input, Select, Space, Tag, Tooltip } from 'antd' import ResizableDrawer from '../components/ResizableDrawer' import { message, modal } from '../feedback' import { LinkOutlined } from '@ant-design/icons' +import { MarkdownEditor } from '../components/MarkdownEditor' +import { MarkdownRenderer } from '../components/MarkdownRenderer' import { api, ApiError, userIdStore, type Bug, type BugRelation } from '../api' import { useApp } from '../context' import { useI18n } from '../i18n' +import { useScopedNavigate } from '../scope' import { SelectProjectEmpty } from '../components/Page' import { Workspace, WorkList, useWorkTabs } from '../components/Workspace' import { useListView, type ListColumn } from '../components/ListView' @@ -15,17 +18,24 @@ import { CF_GROUP, CustomFieldItems, collectCustomValues, useFieldTemplate } fro const STATUSES = ['NEW', 'RESOLVED', 'CLOSED', 'REOPENED', 'REJECTED'] const SEVERITIES = ['P0', 'P1', 'P2', 'P3'] -/** Handler candidates: project members with display names resolved (id fallback). */ +/** Handler candidates: project members first, falling back to (and merged with) the global + * user directory so the assignee list is never empty even for projects with no members. */ function useMemberOptions(projectId: string) { const [options, setOptions] = useState<{ value: string; label: string }[]>([]) useEffect(() => { let alive = true - api - .projectMembers(projectId) - .then(async (ms) => { - const ids = [...new Set(ms.map((m) => m.userId).filter(Boolean))] - const names = ids.length ? await api.userNames(ids).catch(() => ({}) as Record) : {} - if (alive) setOptions(ids.map((id) => ({ value: id, label: names[id] || id }))) + Promise.all([ + api.projectMembers(projectId).catch(() => [] as { userId?: string }[]), + api.users().catch(() => ({ items: [] as { id?: string }[] })), + ]) + .then(async ([ms, us]) => { + const ids = [ + ...ms.map((m) => m.userId).filter(Boolean), + ...us.items.map((u) => u.id).filter(Boolean), + ] as string[] + const uniq = [...new Set(ids)] + const names = uniq.length ? await api.userNames(uniq).catch(() => ({}) as Record) : {} + if (alive) setOptions(uniq.map((id) => ({ value: id, label: names[id] || id }))) }) .catch(() => {}) return () => { alive = false } @@ -127,10 +137,13 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel t: (k: string, d?: string) => string }) { const [editBug, setEditBug] = useState(null) + const [detailBug, setDetailBug] = useState(null) + // Jump to the global user directory so a bug's handler can be inspected in /user. + const nav = useScopedNavigate() // Resolve handler/updater ids in the current rows to display names. const [names, setNames] = useState>({}) useEffect(() => { - const ids = [...new Set(items.flatMap((b) => [b.handler, b.updatedBy]).filter((x): x is string => !!x))] + const ids = [...new Set(items.flatMap((b) => [b.createdBy, b.handler, b.updatedBy]).filter((x): x is string => !!x))] if (!ids.length) { setNames({}); return } api.userNames(ids).then(setNames).catch(() => setNames({})) }, [items]) @@ -157,7 +170,9 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel label: t('bug.handler', '处理人'), title: t('bug.handler', '处理人'), width: 120, - render: (_, r) => nameOf(r.handler), + render: (_, r) => (r.handler + ? nav('/user')} title={t('bug.openUser', '在用户管理中查看')}>{nameOf(r.handler)} + : '-'), }, { key: 'updatedBy', @@ -262,6 +277,7 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel newLabel={t('bug.new', '新建缺陷')} extraActions={lv.toolbar} onRefresh={refresh} + onRowClick={(r) => setDetailBug(r)} columns={lv.columns} data={lv.rows} pagination={lv.pagination} @@ -280,6 +296,7 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel setItems((prev) => prev.map((x) => (x.id === b.id ? { ...x, ...b } : x))) }} /> + setDetailBug(null)} /> ) } @@ -288,17 +305,17 @@ function BugsList({ items, loading, projectId, refresh, setItems, relBug, setRel function EditBugDrawer({ bug, projectId, onClose, onSaved }: { bug: Bug | null; projectId: string; onClose: () => void; onSaved: (b: Bug) => void }) { const { t } = useI18n() const members = useMemberOptions(projectId) - const [form] = Form.useForm<{ title: string; severity?: string; handler?: string }>() + const [form] = Form.useForm<{ title: string; severity?: string; handler?: string; description?: string }>() const [saving, setSaving] = useState(false) useEffect(() => { - if (bug) form.setFieldsValue({ title: bug.title || '', severity: bug.severity || undefined, handler: bug.handler || undefined }) + if (bug) form.setFieldsValue({ title: bug.title || '', severity: bug.severity || undefined, handler: bug.handler || undefined, description: bug.description || undefined }) }, [bug, form]) const save = async () => { const v = await form.validateFields() if (!bug) return setSaving(true) try { - const b = await api.updateBug(bug.id, { title: v.title, severity: v.severity, handler: v.handler }) + const b = await api.updateBug(bug.id, { title: v.title, severity: v.severity, handler: v.handler, description: v.description }) message.success(t('bug.updated', '缺陷已更新')) onSaved(b) } catch (e) { @@ -334,11 +351,66 @@ function EditBugDrawer({ bug, projectId, onClose, onSaved }: { bug: Bug | null; + + + + +