From 6c7e1cd0640769cf790a6bbb1528c168b49c491e Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 21 Mar 2026 14:38:18 +0000 Subject: [PATCH] refactor: SDK users, pm, and checks modules with submodule splits Split users.rs (1157L) into mod.rs + provisioning.rs (mailbox, projects user, welcome email). Split pm.rs (1664L) into mod.rs + planka.rs (PlankaClient) + gitea_issues.rs (GiteaClient). Split checks.rs (1214L) into mod.rs + probes.rs (11 check functions + S3). --- sunbeam-sdk/src/checks/mod.rs | 792 ++++++++++++++++++++++++++ sunbeam-sdk/src/checks/probes.rs | 433 ++++++++++++++ sunbeam-sdk/src/pm/gitea_issues.rs | 420 ++++++++++++++ sunbeam-sdk/src/pm/mod.rs | 729 ++++++++++++++++++++++++ sunbeam-sdk/src/pm/planka.rs | 546 ++++++++++++++++++ sunbeam-sdk/src/users/mod.rs | 656 +++++++++++++++++++++ sunbeam-sdk/src/users/provisioning.rs | 516 +++++++++++++++++ 7 files changed, 4092 insertions(+) create mode 100644 sunbeam-sdk/src/checks/mod.rs create mode 100644 sunbeam-sdk/src/checks/probes.rs create mode 100644 sunbeam-sdk/src/pm/gitea_issues.rs create mode 100644 sunbeam-sdk/src/pm/mod.rs create mode 100644 sunbeam-sdk/src/pm/planka.rs create mode 100644 sunbeam-sdk/src/users/mod.rs create mode 100644 sunbeam-sdk/src/users/provisioning.rs diff --git a/sunbeam-sdk/src/checks/mod.rs b/sunbeam-sdk/src/checks/mod.rs new file mode 100644 index 0000000..c4e2b54 --- /dev/null +++ b/sunbeam-sdk/src/checks/mod.rs @@ -0,0 +1,792 @@ +//! Service-level health checks -- functional probes beyond pod readiness. + +mod probes; + +use crate::error::Result; +use crate::kube::parse_target; +use crate::output::{ok, step, warn}; + +// --------------------------------------------------------------------------- +// CheckResult +// --------------------------------------------------------------------------- + +/// Result of a single health check. +#[derive(Debug, Clone)] +pub struct CheckResult { + pub name: String, + pub ns: String, + pub svc: String, + pub passed: bool, + pub detail: String, +} + +impl CheckResult { + fn ok(name: &str, ns: &str, svc: &str, detail: &str) -> Self { + Self { + name: name.into(), + ns: ns.into(), + svc: svc.into(), + passed: true, + detail: detail.into(), + } + } + + fn fail(name: &str, ns: &str, svc: &str, detail: &str) -> Self { + Self { + name: name.into(), + ns: ns.into(), + svc: svc.into(), + passed: false, + detail: detail.into(), + } + } +} + +// --------------------------------------------------------------------------- +// HTTP client builder +// --------------------------------------------------------------------------- + +/// Build a reqwest client that trusts the mkcert local CA if available, +/// does not follow redirects, and has a 5s timeout. +fn build_http_client() -> Result { + let mut builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(5)); + + // Try mkcert root CA + if let Ok(output) = std::process::Command::new("mkcert") + .arg("-CAROOT") + .output() + { + if output.status.success() { + let ca_root = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let ca_file = std::path::Path::new(&ca_root).join("rootCA.pem"); + if ca_file.exists() { + if let Ok(pem_bytes) = std::fs::read(&ca_file) { + if let Ok(cert) = reqwest::Certificate::from_pem(&pem_bytes) { + builder = builder.add_root_certificate(cert); + } + } + } + } + } + + Ok(builder.build()?) +} + +/// Helper: GET a URL, return (status_code, body_bytes). Does not follow redirects. +async fn http_get( + client: &reqwest::Client, + url: &str, + headers: Option<&[(&str, &str)]>, +) -> std::result::Result<(u16, Vec), String> { + let mut req = client.get(url); + if let Some(hdrs) = headers { + for (k, v) in hdrs { + req = req.header(*k, *v); + } + } + match req.send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.bytes().await.unwrap_or_default().to_vec(); + Ok((status, body)) + } + Err(e) => Err(format!("{e}")), + } +} + +/// Read a K8s secret field, returning empty string on failure. +async fn kube_secret(ns: &str, name: &str, key: &str) -> String { + crate::kube::kube_get_secret_field(ns, name, key) + .await + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Check registry -- function pointer + metadata +// --------------------------------------------------------------------------- + +type CheckFn = for<'a> fn( + &'a str, + &'a reqwest::Client, +) -> std::pin::Pin + Send + 'a>>; + +struct CheckEntry { + func: CheckFn, + ns: &'static str, + svc: &'static str, +} + +fn check_registry() -> Vec { + use probes::*; + vec![ + CheckEntry { + func: |d, c| Box::pin(check_gitea_version(d, c)), + ns: "devtools", + svc: "gitea", + }, + CheckEntry { + func: |d, c| Box::pin(check_gitea_auth(d, c)), + ns: "devtools", + svc: "gitea", + }, + CheckEntry { + func: |d, c| Box::pin(check_postgres(d, c)), + ns: "data", + svc: "postgres", + }, + CheckEntry { + func: |d, c| Box::pin(check_valkey(d, c)), + ns: "data", + svc: "valkey", + }, + CheckEntry { + func: |d, c| Box::pin(check_openbao(d, c)), + ns: "data", + svc: "openbao", + }, + CheckEntry { + func: |d, c| Box::pin(check_seaweedfs(d, c)), + ns: "storage", + svc: "seaweedfs", + }, + CheckEntry { + func: |d, c| Box::pin(check_kratos(d, c)), + ns: "ory", + svc: "kratos", + }, + CheckEntry { + func: |d, c| Box::pin(check_hydra_oidc(d, c)), + ns: "ory", + svc: "hydra", + }, + CheckEntry { + func: |d, c| Box::pin(check_people(d, c)), + ns: "lasuite", + svc: "people", + }, + CheckEntry { + func: |d, c| Box::pin(check_people_api(d, c)), + ns: "lasuite", + svc: "people", + }, + CheckEntry { + func: |d, c| Box::pin(check_livekit(d, c)), + ns: "media", + svc: "livekit", + }, + ] +} + +// --------------------------------------------------------------------------- +// cmd_check -- concurrent execution +// --------------------------------------------------------------------------- + +/// Run service-level health checks, optionally scoped to a namespace or service. +pub async fn cmd_check(target: Option<&str>) -> Result<()> { + step("Service health checks..."); + + let domain = crate::kube::get_domain().await?; + let http_client = build_http_client()?; + + let (ns_filter, svc_filter) = parse_target(target)?; + + let all_checks = check_registry(); + let selected: Vec<&CheckEntry> = all_checks + .iter() + .filter(|e| { + (ns_filter.is_none() || ns_filter == Some(e.ns)) + && (svc_filter.is_none() || svc_filter == Some(e.svc)) + }) + .collect(); + + if selected.is_empty() { + warn(&format!( + "No checks match target: {}", + target.unwrap_or("(none)") + )); + return Ok(()); + } + + // Run all checks concurrently + let mut join_set = tokio::task::JoinSet::new(); + for entry in &selected { + let domain = domain.clone(); + let client = http_client.clone(); + let func = entry.func; + join_set.spawn(async move { func(&domain, &client).await }); + } + + let mut results: Vec = Vec::new(); + while let Some(res) = join_set.join_next().await { + match res { + Ok(cr) => results.push(cr), + Err(e) => results.push(CheckResult::fail("unknown", "?", "?", &format!("{e}"))), + } + } + + // Sort to match the registry order for consistent output + let registry = check_registry(); + results.sort_by(|a, b| { + let idx_a = registry + .iter() + .position(|e| e.ns == a.ns && e.svc == a.svc) + .unwrap_or(usize::MAX); + let idx_b = registry + .iter() + .position(|e| e.ns == b.ns && e.svc == b.svc) + .unwrap_or(usize::MAX); + idx_a.cmp(&idx_b).then_with(|| a.name.cmp(&b.name)) + }); + + // Print grouped by namespace + let name_w = results.iter().map(|r| r.name.len()).max().unwrap_or(0); + let mut cur_ns: Option<&str> = None; + for r in &results { + if cur_ns != Some(&r.ns) { + println!(" {}:", r.ns); + cur_ns = Some(&r.ns); + } + let icon = if r.passed { "\u{2713}" } else { "\u{2717}" }; + let detail = if r.detail.is_empty() { + String::new() + } else { + format!(" {}", r.detail) + }; + println!(" {icon} {: = results.iter().filter(|r| !r.passed).collect(); + if failed.is_empty() { + ok(&format!("All {} check(s) passed.", results.len())); + } else { + warn(&format!("{} check(s) failed.", failed.len())); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use super::probes::*; + use hmac::{Hmac, Mac}; + use sha2::{Digest, Sha256}; + + type HmacSha256 = Hmac; + + // ── S3 auth header tests ───────────────────────────────────────────── + + #[test] + fn test_s3_auth_headers_format() { + let (auth, amzdate) = s3_auth_headers( + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "s3.example.com", + ); + + // Verify header structure + assert!(auth.starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")); + assert!(auth.contains("us-east-1/s3/aws4_request")); + assert!(auth.contains("SignedHeaders=host;x-amz-date")); + assert!(auth.contains("Signature=")); + + // amzdate format: YYYYMMDDTHHMMSSZ + assert_eq!(amzdate.len(), 16); + assert!(amzdate.ends_with('Z')); + assert!(amzdate.contains('T')); + } + + #[test] + fn test_s3_auth_headers_signature_changes_with_key() { + let (auth1, _) = s3_auth_headers("key1", "secret1", "host1"); + let (auth2, _) = s3_auth_headers("key2", "secret2", "host2"); + // Different keys produce different signatures + let sig1 = auth1.split("Signature=").nth(1).unwrap(); + let sig2 = auth2.split("Signature=").nth(1).unwrap(); + assert_ne!(sig1, sig2); + } + + #[test] + fn test_s3_auth_headers_credential_scope() { + let (auth, amzdate) = s3_auth_headers("AK", "SK", "s3.example.com"); + let datestamp = &amzdate[..8]; + let expected_scope = format!("{datestamp}/us-east-1/s3/aws4_request"); + assert!(auth.contains(&expected_scope)); + } + + // ── hex encoding ──────────────────────────────────────────────────── + + #[test] + fn test_hex_encode_empty() { + assert_eq!(hex_encode(b""), ""); + } + + #[test] + fn test_hex_encode_zero() { + assert_eq!(hex_encode(b"\x00"), "00"); + } + + #[test] + fn test_hex_encode_ff() { + assert_eq!(hex_encode(b"\xff"), "ff"); + } + + #[test] + fn test_hex_encode_deadbeef() { + assert_eq!(hex_encode(b"\xde\xad\xbe\xef"), "deadbeef"); + } + + #[test] + fn test_hex_encode_hello() { + assert_eq!(hex_encode(b"hello"), "68656c6c6f"); + } + + // ── CheckResult ───────────────────────────────────────────────────── + + #[test] + fn test_check_result_ok() { + let r = CheckResult::ok("gitea-version", "devtools", "gitea", "v1.21.0"); + assert!(r.passed); + assert_eq!(r.name, "gitea-version"); + assert_eq!(r.ns, "devtools"); + assert_eq!(r.svc, "gitea"); + assert_eq!(r.detail, "v1.21.0"); + } + + #[test] + fn test_check_result_fail() { + let r = CheckResult::fail("postgres", "data", "postgres", "cluster not found"); + assert!(!r.passed); + assert_eq!(r.detail, "cluster not found"); + } + + // ── Check registry ────────────────────────────────────────────────── + + #[test] + fn test_check_registry_has_all_checks() { + let registry = check_registry(); + assert_eq!(registry.len(), 11); + + // Verify order matches Python CHECKS list + assert_eq!(registry[0].ns, "devtools"); + assert_eq!(registry[0].svc, "gitea"); + assert_eq!(registry[1].ns, "devtools"); + assert_eq!(registry[1].svc, "gitea"); + assert_eq!(registry[2].ns, "data"); + assert_eq!(registry[2].svc, "postgres"); + assert_eq!(registry[3].ns, "data"); + assert_eq!(registry[3].svc, "valkey"); + assert_eq!(registry[4].ns, "data"); + assert_eq!(registry[4].svc, "openbao"); + assert_eq!(registry[5].ns, "storage"); + assert_eq!(registry[5].svc, "seaweedfs"); + assert_eq!(registry[6].ns, "ory"); + assert_eq!(registry[6].svc, "kratos"); + assert_eq!(registry[7].ns, "ory"); + assert_eq!(registry[7].svc, "hydra"); + assert_eq!(registry[8].ns, "lasuite"); + assert_eq!(registry[8].svc, "people"); + assert_eq!(registry[9].ns, "lasuite"); + assert_eq!(registry[9].svc, "people"); + assert_eq!(registry[10].ns, "media"); + assert_eq!(registry[10].svc, "livekit"); + } + + #[test] + fn test_check_registry_filter_namespace() { + let all = check_registry(); + let filtered: Vec<&CheckEntry> = all.iter().filter(|e| e.ns == "ory").collect(); + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_check_registry_filter_service() { + let all = check_registry(); + let filtered: Vec<&CheckEntry> = all + .iter() + .filter(|e| e.ns == "ory" && e.svc == "kratos") + .collect(); + assert_eq!(filtered.len(), 1); + } + + #[test] + fn test_check_registry_filter_no_match() { + let all = check_registry(); + let filtered: Vec<&CheckEntry> = + all.iter().filter(|e| e.ns == "nonexistent").collect(); + assert!(filtered.is_empty()); + } + + // ── HMAC-SHA256 verification ──────────────────────────────────────── + + #[test] + fn test_hmac_sha256_known_vector() { + // RFC 4231 Test Case 2 + let key = b"Jefe"; + let data = b"what do ya want for nothing?"; + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key"); + mac.update(data); + let result = hex_encode(mac.finalize().into_bytes()); + assert_eq!( + result, + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" + ); + } + + // ── SHA256 verification ───────────────────────────────────────────── + + #[test] + fn test_sha256_empty() { + let hash = hex_encode(Sha256::digest(b"")); + assert_eq!( + hash, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn test_sha256_hello() { + let hash = hex_encode(Sha256::digest(b"hello")); + assert_eq!( + hash, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + // ── Additional CheckResult tests ────────────────────────────────── + + #[test] + fn test_check_result_ok_empty_detail() { + let r = CheckResult::ok("test", "ns", "svc", ""); + assert!(r.passed); + assert!(r.detail.is_empty()); + } + + #[test] + fn test_check_result_fail_contains_status_code() { + let r = CheckResult::fail("gitea-version", "devtools", "gitea", "HTTP 502"); + assert!(!r.passed); + assert!(r.detail.contains("502")); + } + + #[test] + fn test_check_result_fail_contains_secret_message() { + let r = CheckResult::fail( + "gitea-auth", + "devtools", + "gitea", + "password not found in secret", + ); + assert!(!r.passed); + assert!(r.detail.contains("secret")); + } + + #[test] + fn test_check_result_ok_with_version() { + let r = CheckResult::ok("gitea-version", "devtools", "gitea", "v1.21.0"); + assert!(r.passed); + assert!(r.detail.contains("1.21.0")); + } + + #[test] + fn test_check_result_ok_with_login() { + let r = CheckResult::ok("gitea-auth", "devtools", "gitea", "user=gitea_admin"); + assert!(r.passed); + assert!(r.detail.contains("gitea_admin")); + } + + #[test] + fn test_check_result_ok_authenticated() { + let r = CheckResult::ok("seaweedfs", "storage", "seaweedfs", "S3 authenticated"); + assert!(r.passed); + assert!(r.detail.contains("authenticated")); + } + + // ── Additional registry tests ───────────────────────────────────── + + #[test] + fn test_check_registry_expected_namespaces() { + let registry = check_registry(); + let namespaces: std::collections::HashSet<&str> = + registry.iter().map(|e| e.ns).collect(); + for expected in &["devtools", "data", "storage", "ory", "lasuite", "media"] { + assert!( + namespaces.contains(expected), + "registry missing namespace: {expected}" + ); + } + } + + #[test] + fn test_check_registry_expected_services() { + let registry = check_registry(); + let services: std::collections::HashSet<&str> = + registry.iter().map(|e| e.svc).collect(); + for expected in &[ + "gitea", "postgres", "valkey", "openbao", "seaweedfs", "kratos", "hydra", + "people", "livekit", + ] { + assert!( + services.contains(expected), + "registry missing service: {expected}" + ); + } + } + + #[test] + fn test_check_registry_devtools_has_two_gitea_entries() { + let registry = check_registry(); + let gitea: Vec<_> = registry + .iter() + .filter(|e| e.ns == "devtools" && e.svc == "gitea") + .collect(); + assert_eq!(gitea.len(), 2); + } + + #[test] + fn test_check_registry_lasuite_has_two_people_entries() { + let registry = check_registry(); + let people: Vec<_> = registry + .iter() + .filter(|e| e.ns == "lasuite" && e.svc == "people") + .collect(); + assert_eq!(people.len(), 2); + } + + #[test] + fn test_check_registry_data_has_three_entries() { + let registry = check_registry(); + let data: Vec<_> = registry.iter().filter(|e| e.ns == "data").collect(); + assert_eq!(data.len(), 3); // postgres, valkey, openbao + } + + // ── Filter logic (mirrors Python TestCmdCheck) ──────────────────── + + /// Helper: apply the same filter logic as cmd_check to the registry. + fn filter_registry( + ns_filter: Option<&str>, + svc_filter: Option<&str>, + ) -> Vec<(&'static str, &'static str)> { + let all = check_registry(); + all.into_iter() + .filter(|e| ns_filter.map_or(true, |ns| e.ns == ns)) + .filter(|e| svc_filter.map_or(true, |svc| e.svc == svc)) + .map(|e| (e.ns, e.svc)) + .collect() + } + + #[test] + fn test_no_target_runs_all() { + let selected = filter_registry(None, None); + assert_eq!(selected.len(), 11); + } + + #[test] + fn test_ns_filter_devtools_selects_two() { + let selected = filter_registry(Some("devtools"), None); + assert_eq!(selected.len(), 2); + assert!(selected.iter().all(|(ns, _)| *ns == "devtools")); + } + + #[test] + fn test_ns_filter_skips_other_namespaces() { + let selected = filter_registry(Some("devtools"), None); + // Should NOT contain data/postgres + assert!(selected.iter().all(|(ns, _)| *ns != "data")); + } + + #[test] + fn test_svc_filter_ory_kratos() { + let selected = filter_registry(Some("ory"), Some("kratos")); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0], ("ory", "kratos")); + } + + #[test] + fn test_svc_filter_ory_hydra() { + let selected = filter_registry(Some("ory"), Some("hydra")); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0], ("ory", "hydra")); + } + + #[test] + fn test_svc_filter_people_returns_both() { + let selected = filter_registry(Some("lasuite"), Some("people")); + assert_eq!(selected.len(), 2); + assert!(selected.iter().all(|(ns, svc)| *ns == "lasuite" && *svc == "people")); + } + + #[test] + fn test_filter_nonexistent_ns_returns_empty() { + let selected = filter_registry(Some("nonexistent"), None); + assert!(selected.is_empty()); + } + + #[test] + fn test_filter_ns_match_svc_mismatch_returns_empty() { + // ory namespace exists but postgres service does not live there + let selected = filter_registry(Some("ory"), Some("postgres")); + assert!(selected.is_empty()); + } + + #[test] + fn test_filter_data_namespace() { + let selected = filter_registry(Some("data"), None); + assert_eq!(selected.len(), 3); + let svcs: Vec<&str> = selected.iter().map(|(_, svc)| *svc).collect(); + assert!(svcs.contains(&"postgres")); + assert!(svcs.contains(&"valkey")); + assert!(svcs.contains(&"openbao")); + } + + #[test] + fn test_filter_storage_namespace() { + let selected = filter_registry(Some("storage"), None); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0], ("storage", "seaweedfs")); + } + + #[test] + fn test_filter_media_namespace() { + let selected = filter_registry(Some("media"), None); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0], ("media", "livekit")); + } + + // ── S3 auth AWS reference vector test ───────────────────────────── + + #[test] + fn test_s3_auth_headers_aws_reference_vector() { + // Uses AWS test values with a fixed timestamp to verify signature + // correctness against a known reference (AWS SigV4 documentation). + use chrono::TimeZone; + + let access_key = "AKIAIOSFODNN7EXAMPLE"; + let secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let host = "examplebucket.s3.amazonaws.com"; + let now = chrono::Utc.with_ymd_and_hms(2013, 5, 24, 0, 0, 0).unwrap(); + + let (auth, amzdate) = s3_auth_headers_at(access_key, secret_key, host, now); + + // 1. Verify the date header + assert_eq!(amzdate, "20130524T000000Z"); + + // 2. Verify canonical request intermediate values. + // Canonical request for GET / with empty body: + // GET\n/\n\nhost:examplebucket.s3.amazonaws.com\n + // x-amz-date:20130524T000000Z\n\nhost;x-amz-date\n + let payload_hash = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let canonical = format!( + "GET\n/\n\nhost:{host}\nx-amz-date:{amzdate}\n\nhost;x-amz-date\n{payload_hash}" + ); + let canonical_hash = hex_encode(&Sha256::digest(canonical.as_bytes())); + + // 3. Verify the string to sign + let credential_scope = "20130524/us-east-1/s3/aws4_request"; + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{amzdate}\n{credential_scope}\n{canonical_hash}" + ); + + // 4. Compute the expected signing key and signature to pin the value. + fn hmac_sign(key: &[u8], msg: &[u8]) -> Vec { + let mut mac = + HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(msg); + mac.finalize().into_bytes().to_vec() + } + + let k = hmac_sign( + format!("AWS4{secret_key}").as_bytes(), + b"20130524", + ); + let k = hmac_sign(&k, b"us-east-1"); + let k = hmac_sign(&k, b"s3"); + let k = hmac_sign(&k, b"aws4_request"); + + let expected_sig = { + let mut mac = + HmacSha256::new_from_slice(&k).expect("HMAC accepts any key length"); + mac.update(string_to_sign.as_bytes()); + hex_encode(&mac.finalize().into_bytes()) + }; + + // 5. Verify the full Authorization header matches + let expected_auth = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, \ + SignedHeaders=host;x-amz-date, Signature={expected_sig}" + ); + assert_eq!(auth, expected_auth); + + // 6. Pin the exact signature value so any regression is caught + // immediately without needing to recompute. + let sig = auth.split("Signature=").nth(1).unwrap(); + assert_eq!(sig, expected_sig); + assert_eq!(sig.len(), 64, "SHA-256 HMAC signature must be 64 hex chars"); + } + + // ── Additional S3 auth header tests ─────────────────────────────── + + #[test] + fn test_s3_auth_headers_deterministic() { + // Same inputs at the same point in time produce identical output. + // (Time may advance between calls, but the format is still valid.) + let (auth1, date1) = s3_auth_headers("AK", "SK", "host"); + let (auth2, date2) = s3_auth_headers("AK", "SK", "host"); + // If both calls happen within the same second, they must be identical. + if date1 == date2 { + assert_eq!(auth1, auth2, "same inputs at same time must produce same signature"); + } + } + + #[test] + fn test_s3_auth_headers_different_hosts_differ() { + let (auth1, d1) = s3_auth_headers("AK", "SK", "s3.a.com"); + let (auth2, d2) = s3_auth_headers("AK", "SK", "s3.b.com"); + let sig1 = auth1.split("Signature=").nth(1).unwrap(); + let sig2 = auth2.split("Signature=").nth(1).unwrap(); + // Different hosts -> different canonical request -> different signature + // (only guaranteed when timestamps match) + if d1 == d2 { + assert_ne!(sig1, sig2); + } + } + + #[test] + fn test_s3_auth_headers_signature_is_64_hex_chars() { + let (auth, _) = s3_auth_headers("AK", "SK", "host"); + let sig = auth.split("Signature=").nth(1).unwrap(); + assert_eq!(sig.len(), 64, "SHA-256 HMAC hex signature is 64 chars"); + assert!( + sig.chars().all(|c| c.is_ascii_hexdigit()), + "signature must be lowercase hex: {sig}" + ); + } + + // ── hex_encode edge cases ───────────────────────────────────────── + + #[test] + fn test_hex_encode_all_byte_values() { + // Verify 0x00..0xff all produce 2-char lowercase hex + for b in 0u8..=255 { + let encoded = hex_encode([b]); + assert_eq!(encoded.len(), 2); + assert!(encoded.chars().all(|c| c.is_ascii_hexdigit())); + } + } + + #[test] + fn test_hex_encode_matches_format() { + // Cross-check against Rust's built-in formatting + let bytes: Vec = (0..32).collect(); + let expected: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex_encode(&bytes), expected); + } +} diff --git a/sunbeam-sdk/src/checks/probes.rs b/sunbeam-sdk/src/checks/probes.rs new file mode 100644 index 0000000..f0bc3fa --- /dev/null +++ b/sunbeam-sdk/src/checks/probes.rs @@ -0,0 +1,433 @@ +//! Individual service health check probe functions. + +use base64::Engine; +use hmac::{Hmac, Mac}; +use k8s_openapi::api::core::v1::Pod; +use kube::api::{Api, ListParams}; +use kube::ResourceExt; +use sha2::{Digest, Sha256}; + +use super::{CheckResult, http_get, kube_secret}; +use crate::kube::{get_client, kube_exec}; + +type HmacSha256 = Hmac; + +// --------------------------------------------------------------------------- +// Individual checks +// --------------------------------------------------------------------------- + +/// GET /api/v1/version -> JSON with version field. +pub(super) async fn check_gitea_version(domain: &str, client: &reqwest::Client) -> CheckResult { + let url = format!("https://src.{domain}/api/v1/version"); + match http_get(client, &url, None).await { + Ok((200, body)) => { + let ver = serde_json::from_slice::(&body) + .ok() + .and_then(|v| v.get("version").and_then(|v| v.as_str()).map(String::from)) + .unwrap_or_else(|| "?".into()); + CheckResult::ok("gitea-version", "devtools", "gitea", &format!("v{ver}")) + } + Ok((status, _)) => { + CheckResult::fail("gitea-version", "devtools", "gitea", &format!("HTTP {status}")) + } + Err(e) => CheckResult::fail("gitea-version", "devtools", "gitea", &e), + } +} + +/// GET /api/v1/user with admin credentials -> 200 and login field. +pub(super) async fn check_gitea_auth(domain: &str, client: &reqwest::Client) -> CheckResult { + let username = { + let u = kube_secret("devtools", "gitea-admin-credentials", "username").await; + if u.is_empty() { + "gitea_admin".to_string() + } else { + u + } + }; + let password = + kube_secret("devtools", "gitea-admin-credentials", "password").await; + if password.is_empty() { + return CheckResult::fail( + "gitea-auth", + "devtools", + "gitea", + "password not found in secret", + ); + } + + let creds = + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + let auth_hdr = format!("Basic {creds}"); + let url = format!("https://src.{domain}/api/v1/user"); + + match http_get(client, &url, Some(&[("Authorization", &auth_hdr)])).await { + Ok((200, body)) => { + let login = serde_json::from_slice::(&body) + .ok() + .and_then(|v| v.get("login").and_then(|v| v.as_str()).map(String::from)) + .unwrap_or_else(|| "?".into()); + CheckResult::ok("gitea-auth", "devtools", "gitea", &format!("user={login}")) + } + Ok((status, _)) => { + CheckResult::fail("gitea-auth", "devtools", "gitea", &format!("HTTP {status}")) + } + Err(e) => CheckResult::fail("gitea-auth", "devtools", "gitea", &e), + } +} + +/// CNPG Cluster readyInstances == instances. +pub(super) async fn check_postgres(_domain: &str, _client: &reqwest::Client) -> CheckResult { + let kube_client = match get_client().await { + Ok(c) => c, + Err(e) => { + return CheckResult::fail("postgres", "data", "postgres", &format!("{e}")); + } + }; + + let ar = kube::api::ApiResource { + group: "postgresql.cnpg.io".into(), + version: "v1".into(), + api_version: "postgresql.cnpg.io/v1".into(), + kind: "Cluster".into(), + plural: "clusters".into(), + }; + + let api: Api = + Api::namespaced_with(kube_client.clone(), "data", &ar); + + match api.get_opt("postgres").await { + Ok(Some(obj)) => { + let ready = obj + .data + .get("status") + .and_then(|s| s.get("readyInstances")) + .and_then(|v| v.as_i64()) + .map(|v| v.to_string()) + .unwrap_or_default(); + let total = obj + .data + .get("status") + .and_then(|s| s.get("instances")) + .and_then(|v| v.as_i64()) + .map(|v| v.to_string()) + .unwrap_or_default(); + + if !ready.is_empty() && !total.is_empty() && ready == total { + CheckResult::ok( + "postgres", + "data", + "postgres", + &format!("{ready}/{total} ready"), + ) + } else { + let r = if ready.is_empty() { "?" } else { &ready }; + let t = if total.is_empty() { "?" } else { &total }; + CheckResult::fail("postgres", "data", "postgres", &format!("{r}/{t} ready")) + } + } + Ok(None) => CheckResult::fail("postgres", "data", "postgres", "cluster not found"), + Err(e) => CheckResult::fail("postgres", "data", "postgres", &format!("{e}")), + } +} + +/// kubectl exec valkey pod -- valkey-cli ping -> PONG. +pub(super) async fn check_valkey(_domain: &str, _client: &reqwest::Client) -> CheckResult { + let kube_client = match get_client().await { + Ok(c) => c, + Err(e) => return CheckResult::fail("valkey", "data", "valkey", &format!("{e}")), + }; + + let api: Api = Api::namespaced(kube_client.clone(), "data"); + let lp = ListParams::default().labels("app=valkey"); + let pod_list = match api.list(&lp).await { + Ok(l) => l, + Err(e) => return CheckResult::fail("valkey", "data", "valkey", &format!("{e}")), + }; + + let pod_name = match pod_list.items.first() { + Some(p) => p.name_any(), + None => return CheckResult::fail("valkey", "data", "valkey", "no valkey pod"), + }; + + match kube_exec("data", &pod_name, &["valkey-cli", "ping"], Some("valkey")).await { + Ok((_, out)) => { + let passed = out == "PONG"; + let detail = if out.is_empty() { + "no response".to_string() + } else { + out + }; + CheckResult { + name: "valkey".into(), + ns: "data".into(), + svc: "valkey".into(), + passed, + detail, + } + } + Err(e) => CheckResult::fail("valkey", "data", "valkey", &format!("{e}")), + } +} + +/// kubectl exec openbao-0 -- bao status -format=json -> initialized + unsealed. +pub(super) async fn check_openbao(_domain: &str, _client: &reqwest::Client) -> CheckResult { + match kube_exec( + "data", + "openbao-0", + &["bao", "status", "-format=json"], + Some("openbao"), + ) + .await + { + Ok((_, out)) => { + if out.is_empty() { + return CheckResult::fail("openbao", "data", "openbao", "no response"); + } + match serde_json::from_str::(&out) { + Ok(data) => { + let init = data + .get("initialized") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let sealed = data + .get("sealed") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let passed = init && !sealed; + CheckResult { + name: "openbao".into(), + ns: "data".into(), + svc: "openbao".into(), + passed, + detail: format!("init={init}, sealed={sealed}"), + } + } + Err(_) => { + let truncated: String = out.chars().take(80).collect(); + CheckResult::fail("openbao", "data", "openbao", &truncated) + } + } + } + Err(e) => CheckResult::fail("openbao", "data", "openbao", &format!("{e}")), + } +} + +// --------------------------------------------------------------------------- +// S3 auth (AWS4-HMAC-SHA256) +// --------------------------------------------------------------------------- + +/// Generate AWS4-HMAC-SHA256 Authorization and x-amz-date headers for an unsigned +/// GET / request, matching the Python `_s3_auth_headers` function exactly. +pub(crate) fn s3_auth_headers(access_key: &str, secret_key: &str, host: &str) -> (String, String) { + s3_auth_headers_at(access_key, secret_key, host, chrono::Utc::now()) +} + +/// Deterministic inner implementation that accepts an explicit timestamp. +pub(crate) fn s3_auth_headers_at( + access_key: &str, + secret_key: &str, + host: &str, + now: chrono::DateTime, +) -> (String, String) { + let amzdate = now.format("%Y%m%dT%H%M%SZ").to_string(); + let datestamp = now.format("%Y%m%d").to_string(); + + let payload_hash = hex_encode(&Sha256::digest(b"")); + let canonical = format!( + "GET\n/\n\nhost:{host}\nx-amz-date:{amzdate}\n\nhost;x-amz-date\n{payload_hash}" + ); + let credential_scope = format!("{datestamp}/us-east-1/s3/aws4_request"); + let canonical_hash = hex_encode(&Sha256::digest(canonical.as_bytes())); + let string_to_sign = + format!("AWS4-HMAC-SHA256\n{amzdate}\n{credential_scope}\n{canonical_hash}"); + + fn hmac_sign(key: &[u8], msg: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(msg); + mac.finalize().into_bytes().to_vec() + } + + let k = hmac_sign( + format!("AWS4{secret_key}").as_bytes(), + datestamp.as_bytes(), + ); + let k = hmac_sign(&k, b"us-east-1"); + let k = hmac_sign(&k, b"s3"); + let k = hmac_sign(&k, b"aws4_request"); + + let sig = { + let mut mac = HmacSha256::new_from_slice(&k).expect("HMAC accepts any key length"); + mac.update(string_to_sign.as_bytes()); + hex_encode(&mac.finalize().into_bytes()) + }; + + let auth = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}, SignedHeaders=host;x-amz-date, Signature={sig}" + ); + (auth, amzdate) +} + +/// GET https://s3.{domain}/ with S3 credentials -> 200 list-buckets response. +pub(super) async fn check_seaweedfs(domain: &str, client: &reqwest::Client) -> CheckResult { + let access_key = + kube_secret("storage", "seaweedfs-s3-credentials", "S3_ACCESS_KEY").await; + let secret_key = + kube_secret("storage", "seaweedfs-s3-credentials", "S3_SECRET_KEY").await; + + if access_key.is_empty() || secret_key.is_empty() { + return CheckResult::fail( + "seaweedfs", + "storage", + "seaweedfs", + "credentials not found in seaweedfs-s3-credentials secret", + ); + } + + let host = format!("s3.{domain}"); + let url = format!("https://{host}/"); + let (auth, amzdate) = s3_auth_headers(&access_key, &secret_key, &host); + + match http_get( + client, + &url, + Some(&[("Authorization", &auth), ("x-amz-date", &amzdate)]), + ) + .await + { + Ok((200, _)) => { + CheckResult::ok("seaweedfs", "storage", "seaweedfs", "S3 authenticated") + } + Ok((status, _)) => CheckResult::fail( + "seaweedfs", + "storage", + "seaweedfs", + &format!("HTTP {status}"), + ), + Err(e) => CheckResult::fail("seaweedfs", "storage", "seaweedfs", &e), + } +} + +/// GET /kratos/health/ready -> 200. +pub(super) async fn check_kratos(domain: &str, client: &reqwest::Client) -> CheckResult { + let url = format!("https://auth.{domain}/kratos/health/ready"); + match http_get(client, &url, None).await { + Ok((status, body)) => { + let ok_flag = status == 200; + let mut detail = format!("HTTP {status}"); + if !ok_flag && !body.is_empty() { + let body_str: String = + String::from_utf8_lossy(&body).chars().take(80).collect(); + detail = format!("{detail}: {body_str}"); + } + CheckResult { + name: "kratos".into(), + ns: "ory".into(), + svc: "kratos".into(), + passed: ok_flag, + detail, + } + } + Err(e) => CheckResult::fail("kratos", "ory", "kratos", &e), + } +} + +/// GET /.well-known/openid-configuration -> 200 with issuer field. +pub(super) async fn check_hydra_oidc(domain: &str, client: &reqwest::Client) -> CheckResult { + let url = format!("https://auth.{domain}/.well-known/openid-configuration"); + match http_get(client, &url, None).await { + Ok((200, body)) => { + let issuer = serde_json::from_slice::(&body) + .ok() + .and_then(|v| v.get("issuer").and_then(|v| v.as_str()).map(String::from)) + .unwrap_or_else(|| "?".into()); + CheckResult::ok("hydra-oidc", "ory", "hydra", &format!("issuer={issuer}")) + } + Ok((status, _)) => { + CheckResult::fail("hydra-oidc", "ory", "hydra", &format!("HTTP {status}")) + } + Err(e) => CheckResult::fail("hydra-oidc", "ory", "hydra", &e), + } +} + +/// GET https://people.{domain}/ -> any response < 500 (302 to OIDC is fine). +pub(super) async fn check_people(domain: &str, client: &reqwest::Client) -> CheckResult { + let url = format!("https://people.{domain}/"); + match http_get(client, &url, None).await { + Ok((status, _)) => CheckResult { + name: "people".into(), + ns: "lasuite".into(), + svc: "people".into(), + passed: status < 500, + detail: format!("HTTP {status}"), + }, + Err(e) => CheckResult::fail("people", "lasuite", "people", &e), + } +} + +/// GET /api/v1.0/config/ -> any response < 500 (401 auth-required is fine). +pub(super) async fn check_people_api(domain: &str, client: &reqwest::Client) -> CheckResult { + let url = format!("https://people.{domain}/api/v1.0/config/"); + match http_get(client, &url, None).await { + Ok((status, _)) => CheckResult { + name: "people-api".into(), + ns: "lasuite".into(), + svc: "people".into(), + passed: status < 500, + detail: format!("HTTP {status}"), + }, + Err(e) => CheckResult::fail("people-api", "lasuite", "people", &e), + } +} + +/// kubectl exec livekit-server pod -- wget localhost:7880/ -> rc 0. +pub(super) async fn check_livekit(_domain: &str, _client: &reqwest::Client) -> CheckResult { + let kube_client = match get_client().await { + Ok(c) => c, + Err(e) => return CheckResult::fail("livekit", "media", "livekit", &format!("{e}")), + }; + + let api: Api = Api::namespaced(kube_client.clone(), "media"); + let lp = ListParams::default().labels("app.kubernetes.io/name=livekit-server"); + let pod_list = match api.list(&lp).await { + Ok(l) => l, + Err(e) => return CheckResult::fail("livekit", "media", "livekit", &format!("{e}")), + }; + + let pod_name = match pod_list.items.first() { + Some(p) => p.name_any(), + None => return CheckResult::fail("livekit", "media", "livekit", "no livekit pod"), + }; + + match kube_exec( + "media", + &pod_name, + &["wget", "-qO-", "http://localhost:7880/"], + None, + ) + .await + { + Ok((exit_code, _)) => { + if exit_code == 0 { + CheckResult::ok("livekit", "media", "livekit", "server responding") + } else { + CheckResult::fail("livekit", "media", "livekit", "server not responding") + } + } + Err(e) => CheckResult::fail("livekit", "media", "livekit", &format!("{e}")), + } +} + +// --------------------------------------------------------------------------- +// hex encoding helper (avoids adding the `hex` crate) +// --------------------------------------------------------------------------- + +pub(crate) fn hex_encode(bytes: impl AsRef<[u8]>) -> String { + const HEX_CHARS: &[u8; 16] = b"0123456789abcdef"; + let bytes = bytes.as_ref(); + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + s.push(HEX_CHARS[(b >> 4) as usize] as char); + s.push(HEX_CHARS[(b & 0xf) as usize] as char); + } + s +} diff --git a/sunbeam-sdk/src/pm/gitea_issues.rs b/sunbeam-sdk/src/pm/gitea_issues.rs new file mode 100644 index 0000000..6db1aa4 --- /dev/null +++ b/sunbeam-sdk/src/pm/gitea_issues.rs @@ -0,0 +1,420 @@ +//! Gitea issues client. + +use serde::{Deserialize, Serialize}; + +use crate::error::{Result, SunbeamError}; +use super::{Ticket, Source, Status}; + +// --------------------------------------------------------------------------- +// IssueUpdate +// --------------------------------------------------------------------------- + +/// Update payload for a Gitea issue. +#[derive(Debug, Default, Serialize)] +pub struct IssueUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, +} + +// --------------------------------------------------------------------------- +// GiteaClient +// --------------------------------------------------------------------------- + +pub(super) struct GiteaClient { + base_url: String, + token: String, + http: reqwest::Client, +} + +/// Serde helpers for Gitea JSON responses. +pub(super) mod gitea_json { + use super::*; + + #[derive(Debug, Deserialize)] + pub struct Issue { + pub number: u64, + #[serde(default)] + pub title: String, + #[serde(default)] + pub body: Option, + #[serde(default)] + pub state: String, + #[serde(default)] + pub assignees: Option>, + #[serde(default)] + pub labels: Option>, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub html_url: Option, + #[serde(default)] + pub repository: Option, + } + + #[derive(Debug, Deserialize)] + pub struct GiteaUser { + #[serde(default)] + pub login: String, + } + + #[derive(Debug, Deserialize)] + pub struct GiteaLabel { + #[serde(default)] + pub name: String, + } + + #[derive(Debug, Deserialize)] + pub struct Repository { + #[serde(default)] + pub full_name: Option, + } + + impl Issue { + pub fn to_ticket(self, base_url: &str, org: &str, repo: &str) -> Ticket { + let status = state_to_status(&self.state); + let assignees = self + .assignees + .unwrap_or_default() + .into_iter() + .map(|u| u.login) + .collect(); + let labels = self + .labels + .unwrap_or_default() + .into_iter() + .map(|l| l.name) + .collect(); + let web_base = base_url.trim_end_matches("/api/v1"); + let url = self + .html_url + .unwrap_or_else(|| format!("{web_base}/{org}/{repo}/issues/{}", self.number)); + + Ticket { + id: format!("g:{org}/{repo}#{}", self.number), + source: Source::Gitea, + title: self.title, + description: self.body.unwrap_or_default(), + status, + assignees, + labels, + created_at: self.created_at.unwrap_or_default(), + updated_at: self.updated_at.unwrap_or_default(), + url, + } + } + } + + /// Map Gitea issue state to normalised status. + pub fn state_to_status(state: &str) -> Status { + match state { + "open" => Status::Open, + "closed" => Status::Closed, + _ => Status::Open, + } + } +} + +impl GiteaClient { + /// Create a new Gitea client using the Hydra OAuth2 token. + pub(super) async fn new(domain: &str) -> Result { + let base_url = format!("https://src.{domain}/api/v1"); + // Gitea needs its own PAT, not the Hydra access token + let token = crate::auth::get_gitea_token()?; + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?; + + Ok(Self { + base_url, + token, + http, + }) + } + + /// Build a request with Gitea PAT auth (`Authorization: token `). + #[allow(dead_code)] + fn authed_get(&self, url: &str) -> reqwest::RequestBuilder { + self.http + .get(url) + .header("Authorization", format!("token {}", self.token)) + } + + #[allow(dead_code)] + fn authed_post(&self, url: &str) -> reqwest::RequestBuilder { + self.http + .post(url) + .header("Authorization", format!("token {}", self.token)) + } + + #[allow(dead_code)] + fn authed_patch(&self, url: &str) -> reqwest::RequestBuilder { + self.http + .patch(url) + .header("Authorization", format!("token {}", self.token)) + } + + /// List issues for an org/repo (or search across an org). + pub(super) fn list_issues<'a>( + &'a self, + org: &'a str, + repo: Option<&'a str>, + state: &'a str, + ) -> futures::future::BoxFuture<'a, Result>> { + Box::pin(self.list_issues_inner(org, repo, state)) + } + + async fn list_issues_inner( + &self, + org: &str, + repo: Option<&str>, + state: &str, + ) -> Result> { + match repo { + Some(r) => { + let url = format!("{}/repos/{org}/{r}/issues", self.base_url); + let resp = self + .http + .get(&url) + .header("Authorization", format!("token {}", self.token)) + .query(&[("state", state), ("type", "issues"), ("limit", "50")]) + .send() + .await + .map_err(|e| SunbeamError::network(format!("Gitea list_issues: {e}")))?; + + if !resp.status().is_success() { + return Err(SunbeamError::network(format!( + "Gitea GET issues for {org}/{r} returned {}", + resp.status() + ))); + } + + let issues: Vec = resp + .json() + .await + .map_err(|e| SunbeamError::network(format!("Gitea issues parse error: {e}")))?; + + Ok(issues + .into_iter() + .map(|i| i.to_ticket(&self.base_url, org, r)) + .collect()) + } + None => { + // Search across the entire org by listing org repos, then issues. + let repos_url = format!("{}/orgs/{org}/repos", self.base_url); + let repos_resp = self + .http + .get(&repos_url) + .header("Authorization", format!("token {}", self.token)) + .query(&[("limit", "50")]) + .send() + .await + .map_err(|e| SunbeamError::network(format!("Gitea list org repos: {e}")))?; + + if !repos_resp.status().is_success() { + return Err(SunbeamError::network(format!( + "Gitea GET repos for org {org} returned {}", + repos_resp.status() + ))); + } + + #[derive(Deserialize)] + struct Repo { + name: String, + } + let repos: Vec = repos_resp + .json() + .await + .map_err(|e| SunbeamError::network(format!("Gitea repos parse: {e}")))?; + + let mut all = Vec::new(); + for r in &repos { + match self.list_issues(org, Some(&r.name), state).await { + Ok(mut tickets) => all.append(&mut tickets), + Err(_) => continue, // skip repos we cannot read + } + } + Ok(all) + } + } + } + + /// GET /api/v1/repos/{org}/{repo}/issues/{index} + pub(super) async fn get_issue(&self, org: &str, repo: &str, index: u64) -> Result { + let url = format!("{}/repos/{org}/{repo}/issues/{index}", self.base_url); + let resp = self + .http + .get(&url) + .header("Authorization", format!("token {}", self.token)) + .send() + .await + .map_err(|e| SunbeamError::network(format!("Gitea get_issue: {e}")))?; + + if !resp.status().is_success() { + return Err(SunbeamError::network(format!( + "Gitea GET issue {org}/{repo}#{index} returned {}", + resp.status() + ))); + } + + let issue: gitea_json::Issue = resp + .json() + .await + .map_err(|e| SunbeamError::network(format!("Gitea issue parse: {e}")))?; + + Ok(issue.to_ticket(&self.base_url, org, repo)) + } + + /// POST /api/v1/repos/{org}/{repo}/issues + pub(super) async fn create_issue( + &self, + org: &str, + repo: &str, + title: &str, + body: &str, + ) -> Result { + let url = format!("{}/repos/{org}/{repo}/issues", self.base_url); + let payload = serde_json::json!({ + "title": title, + "body": body, + }); + + let resp = self + .http + .post(&url) + .header("Authorization", format!("token {}", self.token)) + .json(&payload) + .send() + .await + .map_err(|e| SunbeamError::network(format!("Gitea create_issue: {e}")))?; + + if !resp.status().is_success() { + return Err(SunbeamError::network(format!( + "Gitea POST issue to {org}/{repo} returned {}", + resp.status() + ))); + } + + let issue: gitea_json::Issue = resp + .json() + .await + .map_err(|e| SunbeamError::network(format!("Gitea issue create parse: {e}")))?; + + Ok(issue.to_ticket(&self.base_url, org, repo)) + } + + /// PATCH /api/v1/repos/{org}/{repo}/issues/{index} + pub(super) async fn update_issue( + &self, + org: &str, + repo: &str, + index: u64, + updates: &IssueUpdate, + ) -> Result<()> { + let url = format!("{}/repos/{org}/{repo}/issues/{index}", self.base_url); + let resp = self + .http + .patch(&url) + .header("Authorization", format!("token {}", self.token)) + .json(updates) + .send() + .await + .map_err(|e| SunbeamError::network(format!("Gitea update_issue: {e}")))?; + + if !resp.status().is_success() { + return Err(SunbeamError::network(format!( + "Gitea PATCH issue {org}/{repo}#{index} returned {}", + resp.status() + ))); + } + Ok(()) + } + + /// Close an issue. + pub(super) async fn close_issue(&self, org: &str, repo: &str, index: u64) -> Result<()> { + self.update_issue( + org, + repo, + index, + &IssueUpdate { + state: Some("closed".to_string()), + ..Default::default() + }, + ) + .await + } + + /// POST /api/v1/repos/{org}/{repo}/issues/{index}/comments + pub(super) async fn comment_issue( + &self, + org: &str, + repo: &str, + index: u64, + body: &str, + ) -> Result<()> { + let url = format!( + "{}/repos/{org}/{repo}/issues/{index}/comments", + self.base_url + ); + let payload = serde_json::json!({ "body": body }); + + let resp = self + .http + .post(&url) + .header("Authorization", format!("token {}", self.token)) + .json(&payload) + .send() + .await + .map_err(|e| SunbeamError::network(format!("Gitea comment_issue: {e}")))?; + + if !resp.status().is_success() { + return Err(SunbeamError::network(format!( + "Gitea POST comment on {org}/{repo}#{index} returned {}", + resp.status() + ))); + } + Ok(()) + } + + /// POST /api/v1/repos/{org}/{repo}/issues/{index}/assignees + #[allow(dead_code)] + pub(super) async fn assign_issue( + &self, + org: &str, + repo: &str, + index: u64, + assignee: &str, + ) -> Result<()> { + // Use PATCH on the issue itself -- the /assignees endpoint requires + // the user to be an explicit collaborator, while PATCH works for + // any org member with write access. + let url = format!( + "{}/repos/{org}/{repo}/issues/{index}", + self.base_url + ); + let payload = serde_json::json!({ "assignees": [assignee] }); + + let resp = self + .http + .patch(&url) + .header("Authorization", format!("token {}", self.token)) + .json(&payload) + .send() + .await + .map_err(|e| SunbeamError::network(format!("Gitea assign_issue: {e}")))?; + + if !resp.status().is_success() { + return Err(SunbeamError::network(format!( + "Gitea assign on {org}/{repo}#{index} returned {}", + resp.status() + ))); + } + Ok(()) + } +} diff --git a/sunbeam-sdk/src/pm/mod.rs b/sunbeam-sdk/src/pm/mod.rs new file mode 100644 index 0000000..9905c16 --- /dev/null +++ b/sunbeam-sdk/src/pm/mod.rs @@ -0,0 +1,729 @@ +//! Unified project management across Planka (kanban boards) and Gitea (issues). +//! +//! Ticket IDs use a prefix format: +//! - `p:42` or `planka:42` -- Planka card +//! - `g:studio/cli#7` or `gitea:studio/cli#7` -- Gitea issue + +mod planka; +mod gitea_issues; + +use planka::PlankaClient; +use gitea_issues::GiteaClient; + +use crate::error::{Result, ResultExt, SunbeamError}; +use crate::output; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- + +/// Unified ticket representation across both systems. +#[derive(Debug, Clone)] +pub struct Ticket { + pub id: String, + pub source: Source, + pub title: String, + pub description: String, + pub status: Status, + pub assignees: Vec, + pub labels: Vec, + pub created_at: String, + pub updated_at: String, + pub url: String, +} + +/// Which backend a ticket originates from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Source { + Planka, + Gitea, +} + +/// Normalised ticket status across both systems. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Status { + Open, + InProgress, + Done, + Closed, +} + +impl std::fmt::Display for Source { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Source::Planka => write!(f, "planka"), + Source::Gitea => write!(f, "gitea"), + } + } +} + +impl std::fmt::Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Status::Open => write!(f, "open"), + Status::InProgress => write!(f, "in-progress"), + Status::Done => write!(f, "done"), + Status::Closed => write!(f, "closed"), + } + } +} + +// --------------------------------------------------------------------------- +// Ticket ID parsing +// --------------------------------------------------------------------------- + +/// A parsed ticket reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TicketRef { + /// Planka card by ID (snowflake string). + Planka(String), + /// Gitea issue: (org, repo, issue number). + Gitea { + org: String, + repo: String, + number: u64, + }, +} + +/// Parse a prefixed ticket ID string. +/// +/// Accepted formats: +/// - `p:42`, `planka:42` +/// - `g:studio/cli#7`, `gitea:studio/cli#7` +pub fn parse_ticket_id(id: &str) -> Result { + let (prefix, rest) = id + .split_once(':') + .ctx("Invalid ticket ID: expected 'p:ID' or 'g:org/repo#num'")?; + + match prefix { + "p" | "planka" => { + if rest.is_empty() { + return Err(SunbeamError::config("Empty Planka card ID")); + } + Ok(TicketRef::Planka(rest.to_string())) + } + "g" | "gitea" => { + // Expected: org/repo#number + let (org_repo, num_str) = rest + .rsplit_once('#') + .ctx("Invalid Gitea ticket ID: expected org/repo#number")?; + let (org, repo) = org_repo + .split_once('/') + .ctx("Invalid Gitea ticket ID: expected org/repo#number")?; + let number: u64 = num_str + .parse() + .map_err(|_| SunbeamError::config(format!("Invalid issue number: {num_str}")))?; + Ok(TicketRef::Gitea { + org: org.to_string(), + repo: repo.to_string(), + number, + }) + } + _ => Err(SunbeamError::config(format!( + "Unknown ticket prefix '{prefix}': use 'p'/'planka' or 'g'/'gitea'" + ))), + } +} + +// --------------------------------------------------------------------------- +// Auth helper +// --------------------------------------------------------------------------- + +/// Retrieve the user's Hydra OAuth2 access token via the auth module. +async fn get_token() -> Result { + crate::auth::get_token().await +} + +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +/// Format a list of tickets as a table. +fn display_ticket_list(tickets: &[Ticket]) { + if tickets.is_empty() { + output::ok("No tickets found."); + return; + } + + let rows: Vec> = tickets + .iter() + .map(|t| { + vec![ + t.id.clone(), + t.status.to_string(), + t.title.clone(), + t.assignees.join(", "), + t.source.to_string(), + ] + }) + .collect(); + + let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]); + println!("{tbl}"); +} + +/// Print a single ticket in detail. +fn display_ticket_detail(t: &Ticket) { + println!("{} ({})", t.title, t.id); + println!(" Status: {}", t.status); + println!(" Source: {}", t.source); + if !t.assignees.is_empty() { + println!(" Assignees: {}", t.assignees.join(", ")); + } + if !t.labels.is_empty() { + println!(" Labels: {}", t.labels.join(", ")); + } + if !t.created_at.is_empty() { + println!(" Created: {}", t.created_at); + } + if !t.updated_at.is_empty() { + println!(" Updated: {}", t.updated_at); + } + println!(" URL: {}", t.url); + if !t.description.is_empty() { + println!(); + println!("{}", t.description); + } +} + +// --------------------------------------------------------------------------- +// Unified commands +// --------------------------------------------------------------------------- + +/// List tickets, optionally filtering by source and state. +/// +/// When `source` is `None`, both Planka and Gitea are queried in parallel. +#[allow(dead_code)] +pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> { + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } + + let fetch_planka = source.is_none() || matches!(source, Some("planka" | "p")); + let fetch_gitea = source.is_none() || matches!(source, Some("gitea" | "g")); + + let planka_fut = async { + if fetch_planka { + let client = PlankaClient::new(&domain).await?; + client.list_all_cards().await + } else { + Ok(vec![]) + } + }; + + let gitea_fut = async { + if fetch_gitea { + let client = GiteaClient::new(&domain).await?; + client.list_issues("studio", None, state).await + } else { + Ok(vec![]) + } + }; + + let (planka_result, gitea_result) = tokio::join!(planka_fut, gitea_fut); + + let mut tickets = Vec::new(); + + match planka_result { + Ok(mut t) => tickets.append(&mut t), + Err(e) => output::warn(&format!("Planka: {e}")), + } + + match gitea_result { + Ok(mut t) => tickets.append(&mut t), + Err(e) => output::warn(&format!("Gitea: {e}")), + } + + // Filter by state if looking at Planka results too. + if state == "closed" { + tickets.retain(|t| matches!(t.status, Status::Closed | Status::Done)); + } else if state == "open" { + tickets.retain(|t| matches!(t.status, Status::Open | Status::InProgress)); + } + + display_ticket_list(&tickets); + Ok(()) +} + +/// Show details for a single ticket by ID. +#[allow(dead_code)] +pub async fn cmd_pm_show(id: &str) -> Result<()> { + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } + let ticket_ref = parse_ticket_id(id)?; + + let ticket = match ticket_ref { + TicketRef::Planka(card_id) => { + let client = PlankaClient::new(&domain).await?; + client.get_card(&card_id).await? + } + TicketRef::Gitea { org, repo, number } => { + let client = GiteaClient::new(&domain).await?; + client.get_issue(&org, &repo, number).await? + } + }; + + display_ticket_detail(&ticket); + Ok(()) +} + +/// Create a new ticket. +/// +/// `source` must be `"planka"` or `"gitea"`. +/// `target` is source-specific: for Planka it is `"board_id/list_id"`, +/// for Gitea it is `"org/repo"`. +#[allow(dead_code)] +pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str) -> Result<()> { + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } + + let ticket = match source { + "planka" | "p" => { + let client = PlankaClient::new(&domain).await?; + + // Fetch all boards + let projects_url = format!("{}/projects", client.base_url); + let resp = client.http.get(&projects_url).bearer_auth(&client.token).send().await?; + let projects_body: serde_json::Value = resp.json().await?; + let boards = projects_body.get("included").and_then(|i| i.get("boards")) + .and_then(|b| b.as_array()) + .ok_or_else(|| SunbeamError::config("No Planka boards found"))?; + + // Find the board: by name (--target "Board Name") or by ID, or use first + let board = if target.is_empty() { + boards.first() + } else { + boards.iter().find(|b| { + let name = b.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let id = b.get("id").and_then(|v| v.as_str()).unwrap_or(""); + name.eq_ignore_ascii_case(target) || id == target + }).or_else(|| boards.first()) + }.ok_or_else(|| SunbeamError::config("No Planka boards found"))?; + + let board_id = board.get("id").and_then(|v| v.as_str()) + .ok_or_else(|| SunbeamError::config("Board has no ID"))?; + let board_name = board.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + + // Fetch the board to get its lists, use the first list + let board_url = format!("{}/boards/{board_id}", client.base_url); + let board_resp = client.http.get(&board_url).bearer_auth(&client.token).send().await?; + let board_body: serde_json::Value = board_resp.json().await?; + let list_id = board_body.get("included").and_then(|i| i.get("lists")) + .and_then(|l| l.as_array()).and_then(|a| a.first()) + .and_then(|l| l.get("id")).and_then(|v| v.as_str()) + .ok_or_else(|| SunbeamError::config(format!("No lists in board '{board_name}'")))?; + + client.create_card(board_id, list_id, title, body).await? + } + "gitea" | "g" => { + if target.is_empty() { + return Err(SunbeamError::config( + "Gitea target required: --target org/repo (e.g. studio/marathon)", + )); + } + let parts: Vec<&str> = target.splitn(2, '/').collect(); + if parts.len() != 2 { + return Err(SunbeamError::config("Gitea target must be 'org/repo'")); + } + let client = GiteaClient::new(&domain).await?; + client.create_issue(parts[0], parts[1], title, body).await? + } + _ => { + return Err(SunbeamError::config(format!( + "Unknown source '{source}': use 'planka' or 'gitea'" + ))); + } + }; + + output::ok(&format!("Created: {} ({})", ticket.title, ticket.id)); + println!(" {}", ticket.url); + Ok(()) +} + +/// Add a comment to a ticket. +#[allow(dead_code)] +pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> { + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } + let ticket_ref = parse_ticket_id(id)?; + + match ticket_ref { + TicketRef::Planka(card_id) => { + let client = PlankaClient::new(&domain).await?; + client.comment_card(&card_id, text).await?; + } + TicketRef::Gitea { org, repo, number } => { + let client = GiteaClient::new(&domain).await?; + client.comment_issue(&org, &repo, number, text).await?; + } + } + + output::ok(&format!("Comment added to {id}.")); + Ok(()) +} + +/// Close a ticket. +#[allow(dead_code)] +pub async fn cmd_pm_close(id: &str) -> Result<()> { + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } + let ticket_ref = parse_ticket_id(id)?; + + match ticket_ref { + TicketRef::Planka(card_id) => { + let client = PlankaClient::new(&domain).await?; + // Get the card to find its board, then find a "Done"/"Closed" list + let ticket = client.get_card(&card_id).await?; + // Try to find the board and its lists + let url = format!("{}/cards/{card_id}", client.base_url); + let resp = client.http.get(&url).bearer_auth(&client.token).send().await + .map_err(|e| SunbeamError::network(format!("Planka get card: {e}")))?; + let body: serde_json::Value = resp.json().await?; + let board_id = body.get("item").and_then(|i| i.get("boardId")) + .and_then(|v| v.as_str()).unwrap_or(""); + + if !board_id.is_empty() { + // Fetch the board to get its lists + let board_url = format!("{}/boards/{board_id}", client.base_url); + let board_resp = client.http.get(&board_url).bearer_auth(&client.token).send().await + .map_err(|e| SunbeamError::network(format!("Planka get board: {e}")))?; + let board_body: serde_json::Value = board_resp.json().await?; + let lists = board_body.get("included") + .and_then(|i| i.get("lists")) + .and_then(|l| l.as_array()); + + if let Some(lists) = lists { + // Find a list named "Done", "Closed", "Complete", or similar + let done_list = lists.iter().find(|l| { + let name = l.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase(); + name.contains("done") || name.contains("closed") || name.contains("complete") + }); + + if let Some(done_list) = done_list { + let list_id = done_list.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if !list_id.is_empty() { + client.update_card(&card_id, &planka::CardUpdate { + list_id: Some(serde_json::json!(list_id)), + ..Default::default() + }).await?; + output::ok(&format!("Moved p:{card_id} to Done.")); + return Ok(()); + } + } + } + } + output::warn(&format!("Could not find a Done list for p:{card_id}. Move it manually.")); + } + TicketRef::Gitea { org, repo, number } => { + let client = GiteaClient::new(&domain).await?; + client.close_issue(&org, &repo, number).await?; + output::ok(&format!("Closed gitea:{org}/{repo}#{number}.")); + } + } + + Ok(()) +} + +/// Assign a user to a ticket. +#[allow(dead_code)] +pub async fn cmd_pm_assign(id: &str, user: &str) -> Result<()> { + let domain = crate::config::domain(); + if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); } + let ticket_ref = parse_ticket_id(id)?; + + match ticket_ref { + TicketRef::Planka(card_id) => { + let client = PlankaClient::new(&domain).await?; + client.assign_card(&card_id, user).await?; + } + TicketRef::Gitea { org, repo, number } => { + let client = GiteaClient::new(&domain).await?; + client.assign_issue(&org, &repo, number, user).await?; + } + } + + output::ok(&format!("Assigned {user} to {id}.")); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- Ticket ID parsing -------------------------------------------------- + + #[test] + fn test_parse_planka_short() { + let r = parse_ticket_id("p:42").unwrap(); + assert_eq!(r, TicketRef::Planka("42".to_string())); + } + + #[test] + fn test_parse_planka_long() { + let r = parse_ticket_id("planka:100").unwrap(); + assert_eq!(r, TicketRef::Planka("100".to_string())); + } + + #[test] + fn test_parse_gitea_short() { + let r = parse_ticket_id("g:studio/cli#7").unwrap(); + assert_eq!( + r, + TicketRef::Gitea { + org: "studio".to_string(), + repo: "cli".to_string(), + number: 7, + } + ); + } + + #[test] + fn test_parse_gitea_long() { + let r = parse_ticket_id("gitea:internal/infra#123").unwrap(); + assert_eq!( + r, + TicketRef::Gitea { + org: "internal".to_string(), + repo: "infra".to_string(), + number: 123, + } + ); + } + + #[test] + fn test_parse_missing_colon() { + assert!(parse_ticket_id("noprefix").is_err()); + } + + #[test] + fn test_parse_unknown_prefix() { + assert!(parse_ticket_id("jira:FOO-1").is_err()); + } + + #[test] + fn test_parse_invalid_planka_id() { + // Empty ID should fail + assert!(parse_ticket_id("p:").is_err()); + } + + #[test] + fn test_parse_gitea_missing_hash() { + assert!(parse_ticket_id("g:studio/cli").is_err()); + } + + #[test] + fn test_parse_gitea_missing_slash() { + assert!(parse_ticket_id("g:repo#1").is_err()); + } + + #[test] + fn test_parse_gitea_invalid_number() { + assert!(parse_ticket_id("g:studio/cli#abc").is_err()); + } + + // -- Status mapping ----------------------------------------------------- + + #[test] + fn test_gitea_state_open() { + assert_eq!(gitea_issues::gitea_json::state_to_status("open"), Status::Open); + } + + #[test] + fn test_gitea_state_closed() { + assert_eq!(gitea_issues::gitea_json::state_to_status("closed"), Status::Closed); + } + + #[test] + fn test_gitea_state_unknown_defaults_open() { + assert_eq!(gitea_issues::gitea_json::state_to_status("weird"), Status::Open); + } + + #[test] + fn test_status_display() { + assert_eq!(Status::Open.to_string(), "open"); + assert_eq!(Status::InProgress.to_string(), "in-progress"); + assert_eq!(Status::Done.to_string(), "done"); + assert_eq!(Status::Closed.to_string(), "closed"); + } + + #[test] + fn test_source_display() { + assert_eq!(Source::Planka.to_string(), "planka"); + assert_eq!(Source::Gitea.to_string(), "gitea"); + } + + // -- Display formatting ------------------------------------------------- + + #[test] + fn test_display_ticket_list_table() { + let tickets = vec![ + Ticket { + id: "p:1".to_string(), + source: Source::Planka, + title: "Fix login".to_string(), + description: String::new(), + status: Status::Open, + assignees: vec!["alice".to_string()], + labels: vec![], + created_at: "2025-01-01".to_string(), + updated_at: "2025-01-02".to_string(), + url: "https://projects.example.com/cards/1".to_string(), + }, + Ticket { + id: "g:studio/cli#7".to_string(), + source: Source::Gitea, + title: "Add tests".to_string(), + description: "We need more tests.".to_string(), + status: Status::InProgress, + assignees: vec!["bob".to_string(), "carol".to_string()], + labels: vec!["enhancement".to_string()], + created_at: "2025-02-01".to_string(), + updated_at: "2025-02-05".to_string(), + url: "https://src.example.com/studio/cli/issues/7".to_string(), + }, + ]; + + let rows: Vec> = tickets + .iter() + .map(|t| { + vec![ + t.id.clone(), + t.status.to_string(), + t.title.clone(), + t.assignees.join(", "), + t.source.to_string(), + ] + }) + .collect(); + + let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]); + assert!(tbl.contains("p:1")); + assert!(tbl.contains("g:studio/cli#7")); + assert!(tbl.contains("open")); + assert!(tbl.contains("in-progress")); + assert!(tbl.contains("Fix login")); + assert!(tbl.contains("Add tests")); + assert!(tbl.contains("alice")); + assert!(tbl.contains("bob, carol")); + assert!(tbl.contains("planka")); + assert!(tbl.contains("gitea")); + } + + #[test] + fn test_display_ticket_list_empty() { + let rows: Vec> = vec![]; + let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]); + // Should have header + separator but no data rows. + assert!(tbl.contains("ID")); + assert_eq!(tbl.lines().count(), 2); + } + + #[test] + fn test_card_update_serialization() { + let update = planka::CardUpdate { + name: Some("New name".to_string()), + description: None, + list_id: Some(serde_json::json!(5)), + }; + let json = serde_json::to_value(&update).unwrap(); + assert_eq!(json["name"], "New name"); + assert_eq!(json["listId"], 5); + assert!(json.get("description").is_none()); + } + + #[test] + fn test_issue_update_serialization() { + let update = gitea_issues::IssueUpdate { + title: None, + body: Some("Updated body".to_string()), + state: Some("closed".to_string()), + }; + let json = serde_json::to_value(&update).unwrap(); + assert!(json.get("title").is_none()); + assert_eq!(json["body"], "Updated body"); + assert_eq!(json["state"], "closed"); + } + + #[test] + fn test_planka_list_name_to_status() { + // Test via Card::to_ticket with synthetic included data. + use planka::planka_json::*; + + let inc = BoardIncluded { + cards: vec![], + card_memberships: vec![], + card_labels: vec![], + labels: vec![], + lists: vec![ + List { id: serde_json::json!(1), name: "To Do".to_string() }, + List { id: serde_json::json!(2), name: "In Progress".to_string() }, + List { id: serde_json::json!(3), name: "Done".to_string() }, + List { id: serde_json::json!(4), name: "Archived / Closed".to_string() }, + ], + users: vec![], + }; + + let make_card = |list_id: u64| Card { + id: serde_json::json!(1), + name: "test".to_string(), + description: None, + list_id: Some(serde_json::json!(list_id)), + created_at: None, + updated_at: None, + }; + + assert_eq!( + make_card(1).to_ticket("https://x/api", Some(&inc)).status, + Status::Open + ); + assert_eq!( + make_card(2).to_ticket("https://x/api", Some(&inc)).status, + Status::InProgress + ); + assert_eq!( + make_card(3).to_ticket("https://x/api", Some(&inc)).status, + Status::Done + ); + assert_eq!( + make_card(4).to_ticket("https://x/api", Some(&inc)).status, + Status::Closed + ); + } + + #[test] + fn test_gitea_issue_to_ticket() { + let issue = gitea_issues::gitea_json::Issue { + number: 42, + title: "Bug report".to_string(), + body: Some("Something broke".to_string()), + state: "open".to_string(), + assignees: Some(vec![gitea_issues::gitea_json::GiteaUser { + login: "dev1".to_string(), + }]), + labels: Some(vec![gitea_issues::gitea_json::GiteaLabel { + name: "bug".to_string(), + }]), + created_at: Some("2025-03-01T00:00:00Z".to_string()), + updated_at: Some("2025-03-02T00:00:00Z".to_string()), + html_url: Some("https://src.example.com/studio/app/issues/42".to_string()), + repository: None, + }; + + let ticket = issue.to_ticket("https://src.example.com/api/v1", "studio", "app"); + assert_eq!(ticket.id, "g:studio/app#42"); + assert_eq!(ticket.source, Source::Gitea); + assert_eq!(ticket.title, "Bug report"); + assert_eq!(ticket.description, "Something broke"); + assert_eq!(ticket.status, Status::Open); + assert_eq!(ticket.assignees, vec!["dev1"]); + assert_eq!(ticket.labels, vec!["bug"]); + assert_eq!( + ticket.url, + "https://src.example.com/studio/app/issues/42" + ); + } +} diff --git a/sunbeam-sdk/src/pm/planka.rs b/sunbeam-sdk/src/pm/planka.rs new file mode 100644 index 0000000..28fb531 --- /dev/null +++ b/sunbeam-sdk/src/pm/planka.rs @@ -0,0 +1,546 @@ +//! Planka (kanban board) client. + +use serde::Serialize; + +use crate::error::{Result, SunbeamError}; +use super::{get_token, Ticket, Source, Status}; + +// --------------------------------------------------------------------------- +// CardUpdate +// --------------------------------------------------------------------------- + +/// Update payload for a Planka card. +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CardUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub list_id: Option, +} + +// --------------------------------------------------------------------------- +// PlankaClient +// --------------------------------------------------------------------------- + +pub(super) struct PlankaClient { + pub(super) base_url: String, + pub(super) token: String, + pub(super) http: reqwest::Client, +} + +/// Serde helpers for Planka JSON responses. +pub(super) mod planka_json { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ExchangeResponse { + #[serde(default)] + pub token: Option, + // Planka may also return the token in `item` + #[serde(default)] + pub item: Option, + } + + #[derive(Debug, Clone, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Card { + pub id: serde_json::Value, + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub list_id: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct BoardResponse { + #[serde(default)] + pub included: Option, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct BoardIncluded { + #[serde(default)] + pub cards: Vec, + #[serde(default)] + pub card_memberships: Vec, + #[serde(default)] + pub card_labels: Vec, + #[serde(default)] + pub labels: Vec