Planka: - Board discovery via GET /api/projects (no hardcoded IDs) - String IDs (snowflake) throughout — TicketRef::Planka holds String - Create auto-discovers first board/list, or matches --target by name - Close finds "Done"/"Closed" list and moves card automatically - Assign resolves users via search, supports "me" for self-assign - Ticket IDs use p:/g: short prefixes Gitea: - Assign uses PATCH on issue (not POST /assignees which needs collaborator) - Create requires --target org/repo All pm subcommands tested against live Planka + Gitea instances.
1665 lines
55 KiB
Rust
1665 lines
55 KiB
Rust
//! 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<String>,
|
|
pub labels: Vec<String>,
|
|
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<TicketRef> {
|
|
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<String> {
|
|
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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub list_id: Option<serde_json::Value>,
|
|
}
|
|
|
|
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<String>,
|
|
// Planka may also return the token in `item`
|
|
#[serde(default)]
|
|
pub item: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[serde(default)]
|
|
pub list_id: Option<serde_json::Value>,
|
|
#[serde(default)]
|
|
pub created_at: Option<String>,
|
|
#[serde(default)]
|
|
pub updated_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BoardResponse {
|
|
#[serde(default)]
|
|
pub included: Option<BoardIncluded>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BoardIncluded {
|
|
#[serde(default)]
|
|
pub cards: Vec<Card>,
|
|
#[serde(default)]
|
|
pub card_memberships: Vec<CardMembership>,
|
|
#[serde(default)]
|
|
pub card_labels: Vec<CardLabel>,
|
|
#[serde(default)]
|
|
pub labels: Vec<Label>,
|
|
#[serde(default)]
|
|
pub lists: Vec<List>,
|
|
#[serde(default)]
|
|
pub users: Vec<User>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CardMembership {
|
|
pub card_id: serde_json::Value,
|
|
pub user_id: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CardLabel {
|
|
pub card_id: serde_json::Value,
|
|
pub label_id: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Label {
|
|
pub id: serde_json::Value,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct List {
|
|
pub id: serde_json::Value,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct User {
|
|
pub id: serde_json::Value,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
#[serde(default)]
|
|
pub username: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CardDetailResponse {
|
|
pub item: Card,
|
|
#[serde(default)]
|
|
pub included: Option<BoardIncluded>,
|
|
}
|
|
|
|
impl Card {
|
|
pub fn to_ticket(self, base_url: &str, included: Option<&BoardIncluded>) -> Ticket {
|
|
let status = match included {
|
|
Some(inc) => list_name_to_status(
|
|
self.list_id
|
|
.and_then(|lid| inc.lists.iter().find(|l| l.id == lid))
|
|
.map(|l| l.name.as_str())
|
|
.unwrap_or(""),
|
|
),
|
|
None => Status::Open,
|
|
};
|
|
|
|
let assignees = match included {
|
|
Some(inc) => inc
|
|
.card_memberships
|
|
.iter()
|
|
.filter(|m| m.card_id == self.id)
|
|
.filter_map(|m| {
|
|
inc.users.iter().find(|u| u.id == m.user_id).map(|u| {
|
|
u.username
|
|
.clone()
|
|
.or_else(|| u.name.clone())
|
|
.unwrap_or_else(|| m.user_id.to_string())
|
|
})
|
|
})
|
|
.collect(),
|
|
None => vec![],
|
|
};
|
|
|
|
let labels = match included {
|
|
Some(inc) => inc
|
|
.card_labels
|
|
.iter()
|
|
.filter(|cl| cl.card_id == self.id)
|
|
.filter_map(|cl| {
|
|
inc.labels.iter().find(|l| l.id == cl.label_id).map(|l| {
|
|
l.name
|
|
.clone()
|
|
.unwrap_or_else(|| cl.label_id.to_string())
|
|
})
|
|
})
|
|
.collect(),
|
|
None => vec![],
|
|
};
|
|
|
|
// Derive web URL from API base URL (strip `/api`).
|
|
let web_base = base_url.trim_end_matches("/api");
|
|
Ticket {
|
|
id: format!("p:{}", self.id.as_str().unwrap_or(&self.id.to_string())),
|
|
source: Source::Planka,
|
|
title: self.name,
|
|
description: self.description.unwrap_or_default(),
|
|
status,
|
|
assignees,
|
|
labels,
|
|
created_at: self.created_at.unwrap_or_default(),
|
|
updated_at: self.updated_at.unwrap_or_default(),
|
|
url: format!("{web_base}/cards/{}", self.id.as_str().unwrap_or(&self.id.to_string())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Map a Planka list name to a normalised status.
|
|
fn list_name_to_status(name: &str) -> Status {
|
|
let lower = name.to_lowercase();
|
|
if lower.contains("done") || lower.contains("complete") {
|
|
Status::Done
|
|
} else if lower.contains("progress") || lower.contains("doing") || lower.contains("active")
|
|
{
|
|
Status::InProgress
|
|
} else if lower.contains("closed") || lower.contains("archive") {
|
|
Status::Closed
|
|
} else {
|
|
Status::Open
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PlankaClient {
|
|
/// Create a new Planka client, exchanging the Hydra token for a Planka JWT
|
|
/// if the direct Bearer token is rejected.
|
|
async fn new(domain: &str) -> Result<Self> {
|
|
let base_url = format!("https://projects.{domain}/api");
|
|
let hydra_token = get_token().await?;
|
|
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}")))?;
|
|
|
|
// Exchange the Hydra access token for a Planka JWT via our custom endpoint.
|
|
let exchange_url = format!("{base_url}/access-tokens/exchange-using-token");
|
|
let exchange_resp = http
|
|
.post(&exchange_url)
|
|
.json(&serde_json::json!({ "token": hydra_token }))
|
|
.send()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka token exchange failed: {e}")))?;
|
|
|
|
if !exchange_resp.status().is_success() {
|
|
let status = exchange_resp.status();
|
|
let body = exchange_resp.text().await.unwrap_or_default();
|
|
return Err(SunbeamError::identity(format!(
|
|
"Planka token exchange returned {status}: {body}"
|
|
)));
|
|
}
|
|
|
|
let body: serde_json::Value = exchange_resp.json().await?;
|
|
let token = body
|
|
.get("item")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| SunbeamError::identity("Planka exchange response missing 'item' field"))?
|
|
.to_string();
|
|
|
|
Ok(Self {
|
|
base_url,
|
|
token,
|
|
http,
|
|
})
|
|
}
|
|
|
|
/// Discover all projects and boards, then fetch cards from each.
|
|
async fn list_all_cards(&self) -> Result<Vec<Ticket>> {
|
|
// GET /api/projects returns all projects the user has access to,
|
|
// with included boards.
|
|
let url = format!("{}/projects", self.base_url);
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.bearer_auth(&self.token)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka list projects: {e}")))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(SunbeamError::network(format!(
|
|
"Planka GET projects returned {}",
|
|
resp.status()
|
|
)));
|
|
}
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
// Extract board IDs — Planka uses string IDs (snowflake-style)
|
|
let board_ids: Vec<String> = body
|
|
.get("included")
|
|
.and_then(|inc| inc.get("boards"))
|
|
.and_then(|b| b.as_array())
|
|
.map(|boards| {
|
|
boards
|
|
.iter()
|
|
.filter_map(|b| {
|
|
b.get("id").and_then(|id| {
|
|
id.as_str()
|
|
.map(|s| s.to_string())
|
|
.or_else(|| id.as_u64().map(|n| n.to_string()))
|
|
})
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
if board_ids.is_empty() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
// Fetch cards from each board
|
|
let mut all_tickets = Vec::new();
|
|
for board_id in &board_ids {
|
|
match self.list_cards(board_id).await {
|
|
Ok(tickets) => all_tickets.extend(tickets),
|
|
Err(e) => {
|
|
crate::output::warn(&format!("Planka board {board_id}: {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(all_tickets)
|
|
}
|
|
|
|
/// GET /api/boards/{id} and extract all cards.
|
|
async fn list_cards(&self, board_id: &str) -> Result<Vec<Ticket>> {
|
|
let url = format!("{}/boards/{board_id}", self.base_url);
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.bearer_auth(&self.token)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka list_cards: {e}")))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(SunbeamError::network(format!(
|
|
"Planka GET board {board_id} returned {}",
|
|
resp.status()
|
|
)));
|
|
}
|
|
|
|
let body: planka_json::BoardResponse = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka board parse error: {e}")))?;
|
|
|
|
let included = body.included;
|
|
let tickets = included
|
|
.as_ref()
|
|
.map(|inc| {
|
|
inc.cards
|
|
.clone()
|
|
.into_iter()
|
|
.map(|c: planka_json::Card| c.to_ticket(&self.base_url, Some(inc)))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Ok(tickets)
|
|
}
|
|
|
|
/// GET /api/cards/{id}
|
|
async fn get_card(&self, id: &str) -> Result<Ticket> {
|
|
let url = format!("{}/cards/{id}", self.base_url);
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.bearer_auth(&self.token)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka get_card: {e}")))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(SunbeamError::network(format!(
|
|
"Planka GET card {id} returned {}",
|
|
resp.status()
|
|
)));
|
|
}
|
|
|
|
let body: planka_json::CardDetailResponse = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka card parse error: {e}")))?;
|
|
|
|
Ok(body
|
|
.item
|
|
.to_ticket(&self.base_url, body.included.as_ref()))
|
|
}
|
|
|
|
/// POST /api/lists/{list_id}/cards
|
|
async fn create_card(
|
|
&self,
|
|
_board_id: &str,
|
|
list_id: &str,
|
|
name: &str,
|
|
description: &str,
|
|
) -> Result<Ticket> {
|
|
let url = format!("{}/lists/{list_id}/cards", self.base_url);
|
|
let body = serde_json::json!({
|
|
"name": name,
|
|
"description": description,
|
|
"position": 65535,
|
|
});
|
|
|
|
let resp = self
|
|
.http
|
|
.post(&url)
|
|
.bearer_auth(&self.token)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka create_card: {e}")))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(SunbeamError::network(format!(
|
|
"Planka POST card returned {}",
|
|
resp.status()
|
|
)));
|
|
}
|
|
|
|
let card: planka_json::CardDetailResponse = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka card create parse error: {e}")))?;
|
|
|
|
Ok(card.item.to_ticket(&self.base_url, card.included.as_ref()))
|
|
}
|
|
|
|
/// PATCH /api/cards/{id}
|
|
async fn update_card(&self, id: &str, updates: &CardUpdate) -> Result<()> {
|
|
let url = format!("{}/cards/{id}", self.base_url);
|
|
let resp = self
|
|
.http
|
|
.patch(&url)
|
|
.bearer_auth(&self.token)
|
|
.json(updates)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka update_card: {e}")))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(SunbeamError::network(format!(
|
|
"Planka PATCH card {id} returned {}",
|
|
resp.status()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Move a card to a different list.
|
|
async fn move_card(&self, id: &str, list_id: &str) -> Result<()> {
|
|
self.update_card(
|
|
id,
|
|
&CardUpdate {
|
|
list_id: Some(serde_json::json!(list_id)),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// POST /api/cards/{id}/comment-actions
|
|
async fn comment_card(&self, id: &str, text: &str) -> Result<()> {
|
|
let url = format!("{}/cards/{id}/comment-actions", self.base_url);
|
|
let body = serde_json::json!({ "text": text });
|
|
|
|
let resp = self
|
|
.http
|
|
.post(&url)
|
|
.bearer_auth(&self.token)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka comment_card: {e}")))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(SunbeamError::network(format!(
|
|
"Planka POST comment on card {id} returned {}",
|
|
resp.status()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Search for a Planka user by name/username, return their ID.
|
|
async fn resolve_user_id(&self, query: &str) -> Result<String> {
|
|
// "me" or "self" assigns to the current user
|
|
if query == "me" || query == "self" {
|
|
// Get current user via the token (decode JWT or call /api/users/me equivalent)
|
|
// Planka doesn't have /api/users/me, but we can get user from any board membership
|
|
let projects_url = format!("{}/projects", self.base_url);
|
|
if let Ok(resp) = self.http.get(&projects_url).bearer_auth(&self.token).send().await {
|
|
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
|
if let Some(memberships) = body.get("included")
|
|
.and_then(|i| i.get("boardMemberships"))
|
|
.and_then(|b| b.as_array())
|
|
{
|
|
if let Some(user_id) = memberships.first()
|
|
.and_then(|m| m.get("userId"))
|
|
.and_then(|v| v.as_str())
|
|
{
|
|
return Ok(user_id.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Search other users (note: Planka excludes current user from search results)
|
|
let url = format!("{}/users/search", self.base_url);
|
|
let resp = self.http.get(&url)
|
|
.bearer_auth(&self.token)
|
|
.query(&[("query", query)])
|
|
.send().await
|
|
.map_err(|e| SunbeamError::network(format!("Planka user search: {e}")))?;
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let users = body.get("items").and_then(|i| i.as_array());
|
|
if let Some(users) = users {
|
|
if let Some(user) = users.first() {
|
|
if let Some(id) = user.get("id").and_then(|v| v.as_str()) {
|
|
return Ok(id.to_string());
|
|
}
|
|
}
|
|
}
|
|
Err(SunbeamError::identity(format!(
|
|
"Planka user not found: {query} (use 'me' to assign to yourself)"
|
|
)))
|
|
}
|
|
|
|
/// POST /api/cards/{id}/memberships
|
|
async fn assign_card(&self, id: &str, user: &str) -> Result<()> {
|
|
// Resolve username to user ID
|
|
let user_id = self.resolve_user_id(user).await?;
|
|
let url = format!("{}/cards/{id}/memberships", self.base_url);
|
|
let body = serde_json::json!({ "userId": user_id });
|
|
|
|
let resp = self
|
|
.http
|
|
.post(&url)
|
|
.bearer_auth(&self.token)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SunbeamError::network(format!("Planka assign_card: {e}")))?;
|
|
|
|
if !resp.status().is_success() {
|
|
return Err(SunbeamError::network(format!(
|
|
"Planka POST membership on card {id} returned {}",
|
|
resp.status()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gitea client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Update payload for a Gitea issue.
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct IssueUpdate {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub title: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub body: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub state: Option<String>,
|
|
}
|
|
|
|
struct GiteaClient {
|
|
base_url: String,
|
|
token: String,
|
|
http: reqwest::Client,
|
|
}
|
|
|
|
/// Serde helpers for Gitea JSON responses.
|
|
mod gitea_json {
|
|
use super::*;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Issue {
|
|
pub number: u64,
|
|
#[serde(default)]
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub body: Option<String>,
|
|
#[serde(default)]
|
|
pub state: String,
|
|
#[serde(default)]
|
|
pub assignees: Option<Vec<GiteaUser>>,
|
|
#[serde(default)]
|
|
pub labels: Option<Vec<GiteaLabel>>,
|
|
#[serde(default)]
|
|
pub created_at: Option<String>,
|
|
#[serde(default)]
|
|
pub updated_at: Option<String>,
|
|
#[serde(default)]
|
|
pub html_url: Option<String>,
|
|
#[serde(default)]
|
|
pub repository: Option<Repository>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
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.
|
|
async fn new(domain: &str) -> Result<Self> {
|
|
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 <pat>`).
|
|
fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
|
|
self.http
|
|
.get(url)
|
|
.header("Authorization", format!("token {}", self.token))
|
|
}
|
|
|
|
fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
|
|
self.http
|
|
.post(url)
|
|
.header("Authorization", format!("token {}", self.token))
|
|
}
|
|
|
|
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).
|
|
fn list_issues<'a>(
|
|
&'a self,
|
|
org: &'a str,
|
|
repo: Option<&'a str>,
|
|
state: &'a str,
|
|
) -> futures::future::BoxFuture<'a, Result<Vec<Ticket>>> {
|
|
Box::pin(self.list_issues_inner(org, repo, state))
|
|
}
|
|
|
|
async fn list_issues_inner(
|
|
&self,
|
|
org: &str,
|
|
repo: Option<&str>,
|
|
state: &str,
|
|
) -> Result<Vec<Ticket>> {
|
|
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<gitea_json::Issue> = 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<Repo> = 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}
|
|
async fn get_issue(&self, org: &str, repo: &str, index: u64) -> Result<Ticket> {
|
|
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
|
|
async fn create_issue(
|
|
&self,
|
|
org: &str,
|
|
repo: &str,
|
|
title: &str,
|
|
body: &str,
|
|
) -> Result<Ticket> {
|
|
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}
|
|
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.
|
|
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
|
|
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)]
|
|
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(())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<Vec<String>> = 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, &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_json::state_to_status("open"), Status::Open);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitea_state_closed() {
|
|
assert_eq!(gitea_json::state_to_status("closed"), Status::Closed);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitea_state_unknown_defaults_open() {
|
|
assert_eq!(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<Vec<String>> = 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<String>> = 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 = 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 = 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_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_json::Issue {
|
|
number: 42,
|
|
title: "Bug report".to_string(),
|
|
body: Some("Something broke".to_string()),
|
|
state: "open".to_string(),
|
|
assignees: Some(vec![gitea_json::GiteaUser {
|
|
login: "dev1".to_string(),
|
|
}]),
|
|
labels: Some(vec![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"
|
|
);
|
|
}
|
|
}
|