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).
This commit is contained in:
420
sunbeam-sdk/src/pm/gitea_issues.rs
Normal file
420
sunbeam-sdk/src/pm/gitea_issues.rs
Normal file
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<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.
|
||||
pub(super) 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>`).
|
||||
#[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<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}
|
||||
pub(super) 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
|
||||
pub(super) 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}
|
||||
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(())
|
||||
}
|
||||
}
|
||||
729
sunbeam-sdk/src/pm/mod.rs
Normal file
729
sunbeam-sdk/src/pm/mod.rs
Normal file
@@ -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<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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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, &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<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 = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
546
sunbeam-sdk/src/pm/planka.rs
Normal file
546
sunbeam-sdk/src/pm/planka.rs
Normal file
@@ -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<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>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<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.
|
||||
pub(super) 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.
|
||||
pub(super) 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}
|
||||
pub(super) 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
|
||||
pub(super) 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}
|
||||
pub(super) 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.
|
||||
#[allow(dead_code)]
|
||||
pub(super) 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
|
||||
pub(super) 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
|
||||
pub(super) 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user