//! The wam adapter: GoingsOn's first problem source. //! //! wam (Whack-a-Mole) is the distributed ticket manager under `MNW/wam`. It //! keeps its own SQLite store and its own painhours model; this adapter reads //! `GET /tickets` over its authenticated HTTP API and hands the results to the //! Problems repository. //! //! The two systems already agree on the ranking, because both compute painhours //! from the shared `painhours` crate. So a wam ticket and an `/audit` finding //! land in one list ordered by the same rule, which is the whole reason that //! crate was extracted. use async_trait::async_trait; use chrono::{DateTime, Utc}; use goingson_core::NewProblem; use serde::Deserialize; use tracing::{debug, warn}; use super::{ProblemSource, PullError}; /// The adapter's stable name. Half of every provenance key it produces, so /// changing it would orphan every problem already mirrored from wam. pub const SOURCE: &str = "wam"; /// How long to wait on wam before giving up. /// /// Short on purpose: wam is a tailnet-local service, and the inbox should tell /// the user it is unreachable rather than hang on a view refresh. const TIMEOUT_SECS: u64 = 10; /// One ticket as wam serializes it. /// /// Only the fields this adapter maps are declared; wam's `node_id` and its own /// `source`/`source_ref` (which describe where *wam* got the ticket) are /// deliberately ignored, since re-exporting them would confuse wam's provenance /// with GoingsOn's. #[derive(Debug, Deserialize)] struct Ticket { id: String, title: String, #[serde(default)] body: Option, pain: u8, scale: u8, /// `open` | `in-progress` | `resolved` | `closed`. status: String, /// `system` | `request` | `task`. #[serde(default)] channel: Option, created_at: DateTime, updated_at: DateTime, } #[derive(Debug, Deserialize)] struct ListResponse { data: Vec, } impl Ticket { /// Whether wam considers this ticket finished. /// /// Both terminal states settle the mirrored problem. GoingsOn does not /// distinguish "resolved" from "closed": either way it is no longer a live /// problem here, and the triage state a human already set wins regardless /// (the repository's upsert guards that). fn resolved_upstream(&self) -> bool { matches!(self.status.as_str(), "resolved" | "closed") } fn into_new_problem(self) -> NewProblem { // Read the terminal state before the struct is taken apart below. let resolved_upstream = self.resolved_upstream(); // wam's channel is a useful lens once these sit next to audit findings: // a `system` ticket is an automated alert, a `task` one is a human note. let mut tags = vec![SOURCE.to_string()]; if let Some(channel) = &self.channel { tags.push(format!("channel:{channel}")); } NewProblem { source: SOURCE.to_string(), // wam's ticket id is stable and unique within wam, which is exactly // what the provenance key needs. source_ref: self.id, title: self.title, body: self.body.unwrap_or_default(), pain: self.pain, scale: self.scale, project_id: None, tags, // wam's timestamps, not ours. Age drives the score, so a ticket // filed weeks ago must not read as new because we pulled it today. created_at: self.created_at, updated_at: self.updated_at, resolved_upstream, } } } /// Reads open problems from a wam instance. pub struct WamSource { base_url: String, token: Option, client: reqwest::Client, } impl WamSource { /// Build an adapter for a wam instance. /// /// `base_url` is wam's HTTP root (e.g. `http://fw13:7890`); a trailing slash /// is tolerated. `token` is the shared bearer wam enforces when it was /// started with one. Passing `None` is allowed because wam also runs /// unauthenticated, and it warns loudly on its own side when it does; a /// mismatched token surfaces here as a 401. pub fn new(base_url: impl Into, token: Option) -> Self { let base_url = base_url.into().trim_end_matches('/').to_string(); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) .build() .unwrap_or_default(); Self { base_url, token, client, } } } #[async_trait] impl ProblemSource for WamSource { fn name(&self) -> &str { SOURCE } async fn pull(&self) -> Result, PullError> { if self.base_url.is_empty() { return Err(PullError::NotConfigured(SOURCE)); } // No status filter: terminal tickets have to come back too, or a ticket // fixed in wam would sit open in the inbox forever with nothing to // settle it. let url = format!("{}/tickets", self.base_url); let mut request = self.client.get(&url); if let Some(token) = &self.token { request = request.bearer_auth(token); } let response = request.send().await.map_err(|e| PullError::Unreachable { name: SOURCE, message: e.to_string(), })?; let status = response.status(); if !status.is_success() { return Err(PullError::Unreachable { name: SOURCE, message: if status == reqwest::StatusCode::UNAUTHORIZED { "unauthorized (check the wam token)".to_string() } else { format!("HTTP {status}") }, }); } let body: ListResponse = response.json().await.map_err(|e| PullError::Malformed { name: SOURCE, message: e.to_string(), })?; debug!(count = body.data.len(), "pulled tickets from wam"); // A ticket whose factors are out of range still ranks (the model clamps // on use), so nothing is dropped here. Warn instead, since it means the // producer is writing something wam itself did not intend. for t in &body.data { if !(1..=5).contains(&t.pain) || !(1..=5).contains(&t.scale) { warn!( ticket = %t.id, pain = t.pain, scale = t.scale, "wam ticket has out-of-range painhours factors; clamping" ); } } Ok(body .data .into_iter() .map(Ticket::into_new_problem) .collect()) } } #[cfg(test)] mod tests { use super::*; fn ticket(status: &str) -> Ticket { Ticket { id: "abc123".into(), title: "sync drops attachments".into(), body: Some("seen on two devices".into()), pain: 4, scale: 3, status: status.into(), channel: Some("system".into()), created_at: Utc::now() - chrono::Duration::days(30), updated_at: Utc::now(), } } #[test] fn both_terminal_states_settle_the_mirror() { assert!(!ticket("open").resolved_upstream()); assert!(!ticket("in-progress").resolved_upstream()); assert!(ticket("resolved").resolved_upstream()); assert!(ticket("closed").resolved_upstream()); } #[test] fn mapping_preserves_the_source_timestamps_and_factors() { let t = ticket("open"); let created = t.created_at; let p = t.into_new_problem(); assert_eq!(p.source, "wam"); assert_eq!(p.source_ref, "abc123"); assert_eq!(p.pain, 4); assert_eq!(p.scale, 3); // The ticket's own age, not the pull time: a month-old ticket must // arrive already a month old, or it would restart its climb on import. assert_eq!(p.created_at, created); assert!(p.tags.contains(&"wam".to_string())); assert!(p.tags.contains(&"channel:system".to_string())); } #[test] fn a_channelless_ticket_still_maps() { let mut t = ticket("open"); t.channel = None; let p = t.into_new_problem(); assert_eq!(p.tags, vec!["wam".to_string()]); } #[test] fn base_url_tolerates_a_trailing_slash() { super::super::init_crypto_for_tests(); let a = WamSource::new("http://fw13:7890/", None); let b = WamSource::new("http://fw13:7890", None); assert_eq!(a.base_url, b.base_url); } #[tokio::test] async fn an_empty_url_is_unconfigured_not_an_error() { super::super::init_crypto_for_tests(); let source = WamSource::new("", None); match source.pull().await { Err(PullError::NotConfigured("wam")) => {} other => panic!("expected NotConfigured, got {other:?}"), } } #[test] fn list_response_parses_wam_json() { // The exact shape wam's GET /tickets returns. let raw = r#"{ "data": [{ "id": "t-1", "title": "disk filling", "body": null, "pain": 5, "scale": 5, "status": "open", "channel": "system", "node_id": "node-a", "source": "pom", "source_ref": "alert-9", "created_at": "2026-07-01T12:00:00Z", "updated_at": "2026-07-02T12:00:00Z", "resolved_at": null }], "count": 1 }"#; let parsed: ListResponse = serde_json::from_str(raw).expect("wam JSON must parse"); assert_eq!(parsed.data.len(), 1); let p = parsed.data.into_iter().next().unwrap().into_new_problem(); assert_eq!(p.source_ref, "t-1"); assert_eq!( p.body, "", "a null body maps to empty, not the string 'null'" ); // wam's own source/source_ref describe where WAM got it; they must not // leak into GoingsOn's provenance. assert_eq!(p.source, "wam"); } }