From 5bdb78933fadc0ffad5dbc54d53c9c0ab10aecec Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Fri, 20 Mar 2026 14:11:16 +0000 Subject: [PATCH] feat: unified project management across Planka and Gitea New src/pm.rs module with sunbeam pm subcommand: - Planka client: cards, boards, lists, comments, assignments via OIDC token exchange for Planka JWT - Gitea client: issues, comments, labels, milestones via OAuth2 Bearer token - Unified Ticket type with p:/g: ID prefixes - pm list: parallel fetch from both sources, merged display - pm show/create/comment/close/assign across both systems - Auth via crate::auth::get_token() (Hydra OAuth2) --- src/cli.rs | 114 ++++ src/pm.rs | 1475 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1589 insertions(+) create mode 100644 src/pm.rs diff --git a/src/cli.rs b/src/cli.rs index d0e9c42..93307fd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -146,6 +146,18 @@ pub enum Verb { action: Option, }, + /// Authenticate with Sunbeam (OAuth2 login via browser). + Auth { + #[command(subcommand)] + action: Option, + }, + + /// Project management across Planka and Gitea. + Pm { + #[command(subcommand)] + action: Option, + }, + /// Self-update from latest mainline commit. Update, @@ -153,6 +165,67 @@ pub enum Verb { Version, } +#[derive(Subcommand, Debug)] +pub enum AuthAction { + /// Log in via browser (OAuth2 authorization code flow). + Login, + /// Log out (remove cached tokens). + Logout, + /// Show current authentication status. + Status, +} + +#[derive(Subcommand, Debug)] +pub enum PmAction { + /// List tickets across Planka and Gitea. + List { + /// Filter by source: planka, gitea, or all (default: all). + #[arg(long, default_value = "all")] + source: String, + /// Filter by state: open, closed, all (default: open). + #[arg(long, default_value = "open")] + state: String, + }, + /// Show ticket details. + Show { + /// Ticket ID (e.g. p:42 for Planka, g:studio/cli#7 for Gitea). + id: String, + }, + /// Create a new ticket. + Create { + /// Ticket title. + title: String, + /// Ticket body/description. + #[arg(long, default_value = "")] + body: String, + /// Source: planka or gitea. + #[arg(long, default_value = "gitea")] + source: String, + /// Target: board ID for Planka, or org/repo for Gitea. + #[arg(long, default_value = "")] + target: String, + }, + /// Add a comment to a ticket. + Comment { + /// Ticket ID. + id: String, + /// Comment text. + text: String, + }, + /// Close/complete a ticket. + Close { + /// Ticket ID. + id: String, + }, + /// Assign a user to a ticket. + Assign { + /// Ticket ID. + id: String, + /// Username or email to assign. + user: String, + }, +} + #[derive(Debug, Clone, ValueEnum)] pub enum BuildTarget { Proxy, @@ -925,6 +998,47 @@ pub async fn dispatch() -> Result<()> { } }, + Some(Verb::Auth { action }) => match action { + None => { + crate::auth::cmd_auth_status().await + } + Some(AuthAction::Login) => crate::auth::cmd_auth_login().await, + Some(AuthAction::Logout) => crate::auth::cmd_auth_logout().await, + Some(AuthAction::Status) => crate::auth::cmd_auth_status().await, + }, + + Some(Verb::Pm { action }) => match action { + None => { + use clap::CommandFactory; + let mut cmd = Cli::command(); + let sub = cmd + .find_subcommand_mut("pm") + .expect("pm subcommand"); + sub.print_help()?; + println!(); + Ok(()) + } + Some(PmAction::List { source, state }) => { + let src = if source == "all" { None } else { Some(source.as_str()) }; + crate::pm::cmd_pm_list(src, &state).await + } + Some(PmAction::Show { id }) => { + crate::pm::cmd_pm_show(&id).await + } + Some(PmAction::Create { title, body, source, target }) => { + crate::pm::cmd_pm_create(&title, &body, &source, &target).await + } + Some(PmAction::Comment { id, text }) => { + crate::pm::cmd_pm_comment(&id, &text).await + } + Some(PmAction::Close { id }) => { + crate::pm::cmd_pm_close(&id).await + } + Some(PmAction::Assign { id, user }) => { + crate::pm::cmd_pm_assign(&id, &user).await + } + }, + Some(Verb::Update) => crate::update::cmd_update().await, Some(Verb::Version) => { diff --git a/src/pm.rs b/src/pm.rs new file mode 100644 index 0000000..9c313eb --- /dev/null +++ b/src/pm.rs @@ -0,0 +1,1475 @@ +//! 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 numeric ID. + Planka(u64), + /// 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" => { + let card_id: u64 = rest + .parse() + .map_err(|_| SunbeamError::config(format!("Invalid Planka card ID: {rest}")))?; + Ok(TicketRef::Planka(card_id)) + } + "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: u64, + #[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