//! 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 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 } // --------------------------------------------------------------------------- // Planka client // --------------------------------------------------------------------------- /// 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, } struct PlankaClient { base_url: String, token: String, http: reqwest::Client, } /// Serde helpers for Planka JSON responses. mod planka_json { use super::*; #[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