feat: split auth into sso/git, Planka token exchange, board discovery

Auth:
- sunbeam auth login runs SSO (Hydra OIDC) then Git (Gitea PAT)
- SSO callback auto-redirects browser to Gitea token page
- sunbeam auth sso / sunbeam auth git for individual flows
- Gitea PAT verified against API before saving

Planka:
- Token exchange via /api/access-tokens/exchange-using-token endpoint
- Board discovery via GET /api/projects
- String IDs (snowflake) handled throughout

Config:
- kubectl-style contexts: --context flag > current-context > "local"
- Removed --env flag
- Per-domain auth token storage
This commit is contained in:
2026-03-20 19:25:10 +00:00
parent ded0ab442e
commit ffc0fe917b
3 changed files with 311 additions and 87 deletions

View File

@@ -20,6 +20,9 @@ pub struct AuthTokens {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>, pub id_token: Option<String>,
pub domain: String, pub domain: String,
/// Gitea personal access token (created during auth login).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitea_token: Option<String>,
} }
/// Default client ID when the K8s secret is unavailable. /// Default client ID when the K8s secret is unavailable.
@@ -265,6 +268,7 @@ async fn refresh_token(cached: &AuthTokens) -> Result<AuthTokens> {
expires_at, expires_at,
id_token: token_resp.id_token.or_else(|| cached.id_token.clone()), id_token: token_resp.id_token.or_else(|| cached.id_token.clone()),
domain: cached.domain.clone(), domain: cached.domain.clone(),
gitea_token: cached.gitea_token.clone(),
}; };
write_cache(&new_tokens)?; write_cache(&new_tokens)?;
@@ -342,6 +346,7 @@ async fn bind_callback_listener() -> Result<(tokio::net::TcpListener, u16)> {
async fn wait_for_callback( async fn wait_for_callback(
listener: tokio::net::TcpListener, listener: tokio::net::TcpListener,
expected_state: &str, expected_state: &str,
redirect_url: Option<&str>,
) -> Result<CallbackParams> { ) -> Result<CallbackParams> {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -400,23 +405,44 @@ async fn wait_for_callback(
)); ));
} }
// Send success response // Send success response — redirect to next step if provided, otherwise show done page
let html = concat!( let response = if let Some(next_url) = redirect_url {
"<!DOCTYPE html><html><head><style>", let html = format!(
"body{font-family:system-ui,sans-serif;display:flex;justify-content:center;", "<!DOCTYPE html><html><head>\
"align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}", <meta http-equiv='refresh' content='1;url={next_url}'>\
".card{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}", <style>\
"h2{margin:0 0 1rem}p{color:#9ca3af}", body{{font-family:system-ui,sans-serif;display:flex;justify-content:center;\
"</style></head><body><div class='card'>", align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}}\
"<h2>Authentication successful</h2>", .card{{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}}\
"<p>You can close this tab and return to the terminal.</p>", h2{{margin:0 0 1rem}}p{{color:#9ca3af}}a{{color:#d97706}}\
"</div></body></html>" </style></head><body><div class='card'>\
); <h2>SSO login successful</h2>\
let response = format!( <p>Redirecting to Gitea token setup...</p>\
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", <p><a href='{next_url}'>Click here if not redirected</a></p>\
html.len(), </div></body></html>"
html );
); format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
} else {
let html = "\
<!DOCTYPE html><html><head><style>\
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;\
align-items:center;min-height:100vh;margin:0;background:#1a1f2e;color:#e8e6e3}\
.card{text-align:center;padding:3rem;border:1px solid #334;border-radius:1rem}\
h2{margin:0 0 1rem}p{color:#9ca3af}\
</style></head><body><div class='card'>\
<h2>Authentication successful</h2>\
<p>You can close this tab and return to the terminal.</p>\
</div></body></html>";
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
)
};
let _ = stream.write_all(response.as_bytes()).await; let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await; let _ = stream.shutdown().await;
@@ -464,7 +490,12 @@ pub async fn get_token() -> Result<String> {
} }
/// Interactive browser-based OAuth2 login. /// Interactive browser-based OAuth2 login.
pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> { /// SSO login — Hydra OIDC authorization code flow with PKCE.
/// `gitea_redirect`: if Some, the browser callback page auto-redirects to Gitea token page.
pub async fn cmd_auth_sso_login_with_redirect(
domain_override: Option<&str>,
gitea_redirect: Option<&str>,
) -> Result<()> {
crate::output::step("Authenticating with Hydra"); crate::output::step("Authenticating with Hydra");
// Resolve domain: explicit flag > cached token domain > config > cluster discovery // Resolve domain: explicit flag > cached token domain > config > cluster discovery
@@ -507,7 +538,7 @@ pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> {
// Wait for callback // Wait for callback
crate::output::ok("Waiting for authentication callback..."); crate::output::ok("Waiting for authentication callback...");
let callback = wait_for_callback(listener, &state).await?; let callback = wait_for_callback(listener, &state, gitea_redirect).await?;
// Exchange code for tokens // Exchange code for tokens
crate::output::ok("Exchanging authorization code for tokens..."); crate::output::ok("Exchanging authorization code for tokens...");
@@ -529,24 +560,117 @@ pub async fn cmd_auth_login(domain_override: Option<&str>) -> Result<()> {
expires_at, expires_at,
id_token: token_resp.id_token.clone(), id_token: token_resp.id_token.clone(),
domain: domain.clone(), domain: domain.clone(),
gitea_token: None,
}; };
write_cache(&tokens)?;
// Print success with email if available // Print success with email if available
if let Some(ref id_token) = tokens.id_token { let email = tokens
if let Some(email) = extract_email(id_token) { .id_token
crate::output::ok(&format!("Logged in as {email}")); .as_ref()
} else { .and_then(|t| extract_email(t));
crate::output::ok("Logged in successfully"); if let Some(ref email) = email {
} crate::output::ok(&format!("Logged in as {email}"));
} else { } else {
crate::output::ok("Logged in successfully"); crate::output::ok("Logged in successfully");
} }
write_cache(&tokens)?;
Ok(()) Ok(())
} }
/// SSO login — standalone (no redirect after callback).
pub async fn cmd_auth_sso_login(domain_override: Option<&str>) -> Result<()> {
cmd_auth_sso_login_with_redirect(domain_override, None).await
}
/// Gitea token login — opens the PAT creation page and prompts for the token.
pub async fn cmd_auth_git_login(domain_override: Option<&str>) -> Result<()> {
crate::output::step("Setting up Gitea API access");
let domain = resolve_domain(domain_override).await?;
let url = format!("https://src.{domain}/user/settings/applications");
crate::output::ok("Opening Gitea token page in your browser...");
crate::output::ok("Create a token with all scopes selected, then paste it below.");
println!("\n {url}\n");
let _ = open_browser(&url);
// Prompt for the token
eprint!(" Gitea token: ");
let mut token = String::new();
std::io::stdin()
.read_line(&mut token)
.ctx("Failed to read token from stdin")?;
let token = token.trim().to_string();
if token.is_empty() {
return Err(SunbeamError::identity("No token provided."));
}
// Verify the token works
let client = reqwest::Client::new();
let resp = client
.get(format!("https://src.{domain}/api/v1/user"))
.header("Authorization", format!("token {token}"))
.send()
.await
.ctx("Failed to verify Gitea token")?;
if !resp.status().is_success() {
return Err(SunbeamError::identity(format!(
"Gitea token is invalid (HTTP {}). Check the token and try again.",
resp.status()
)));
}
let user: serde_json::Value = resp.json().await?;
let login = user
.get("login")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
// Save to cache
let mut tokens = read_cache().unwrap_or_else(|_| AuthTokens {
access_token: String::new(),
refresh_token: String::new(),
expires_at: Utc::now(),
id_token: None,
domain: domain.clone(),
gitea_token: None,
});
tokens.gitea_token = Some(token);
if tokens.domain.is_empty() {
tokens.domain = domain;
}
write_cache(&tokens)?;
crate::output::ok(&format!("Gitea authenticated as {login}"));
Ok(())
}
/// Combined login — SSO first, then Gitea.
pub async fn cmd_auth_login_all(domain_override: Option<&str>) -> Result<()> {
// Resolve domain early so we can build the Gitea redirect URL
let domain = resolve_domain(domain_override).await?;
let gitea_url = format!("https://src.{domain}/user/settings/applications");
cmd_auth_sso_login_with_redirect(Some(&domain), Some(&gitea_url)).await?;
cmd_auth_git_login(Some(&domain)).await?;
Ok(())
}
/// Get the Gitea API token (for use by pm.rs).
pub fn get_gitea_token() -> Result<String> {
let tokens = read_cache().map_err(|_| {
SunbeamError::identity("Not logged in. Run `sunbeam auth login` first.")
})?;
tokens.gitea_token.ok_or_else(|| {
SunbeamError::identity(
"No Gitea token. Run `sunbeam auth login` or `sunbeam auth set-gitea-token <token>`.",
)
})
}
/// Remove cached auth tokens. /// Remove cached auth tokens.
pub async fn cmd_auth_logout() -> Result<()> { pub async fn cmd_auth_logout() -> Result<()> {
let path = cache_path(); let path = cache_path();
@@ -674,6 +798,7 @@ mod tests {
expires_at: Utc::now() + Duration::hours(1), expires_at: Utc::now() + Duration::hours(1),
id_token: Some("eyJhbGciOiJSUzI1NiJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.sig".to_string()), id_token: Some("eyJhbGciOiJSUzI1NiJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.sig".to_string()),
domain: "sunbeam.pt".to_string(), domain: "sunbeam.pt".to_string(),
gitea_token: None,
}; };
let json = serde_json::to_string_pretty(&tokens).unwrap(); let json = serde_json::to_string_pretty(&tokens).unwrap();
@@ -699,6 +824,7 @@ mod tests {
expires_at: Utc::now() + Duration::hours(1), expires_at: Utc::now() + Duration::hours(1),
id_token: None, id_token: None,
domain: "example.com".to_string(), domain: "example.com".to_string(),
gitea_token: None,
}; };
let json = serde_json::to_string(&tokens).unwrap(); let json = serde_json::to_string(&tokens).unwrap();
@@ -717,6 +843,7 @@ mod tests {
expires_at: Utc::now() + Duration::hours(1), expires_at: Utc::now() + Duration::hours(1),
id_token: None, id_token: None,
domain: "example.com".to_string(), domain: "example.com".to_string(),
gitea_token: None,
}; };
let now = Utc::now(); let now = Utc::now();
@@ -732,6 +859,7 @@ mod tests {
expires_at: Utc::now() - Duration::hours(1), expires_at: Utc::now() - Duration::hours(1),
id_token: None, id_token: None,
domain: "example.com".to_string(), domain: "example.com".to_string(),
gitea_token: None,
}; };
let now = Utc::now(); let now = Utc::now();
@@ -747,6 +875,7 @@ mod tests {
expires_at: Utc::now() + Duration::seconds(30), expires_at: Utc::now() + Duration::seconds(30),
id_token: None, id_token: None,
domain: "example.com".to_string(), domain: "example.com".to_string(),
gitea_token: None,
}; };
let now = Utc::now(); let now = Utc::now();

View File

@@ -149,13 +149,25 @@ pub enum Verb {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum AuthAction { pub enum AuthAction {
/// Log in via browser (OAuth2 authorization code flow). /// Log in to both SSO and Gitea.
Login { Login {
/// Domain to authenticate against (e.g. sunbeam.pt). /// Domain to authenticate against (e.g. sunbeam.pt).
#[arg(long)] #[arg(long)]
domain: Option<String>, domain: Option<String>,
}, },
/// Log out (remove cached tokens). /// Log in to SSO only (Hydra OIDC — for Planka, identity management).
Sso {
/// Domain to authenticate against.
#[arg(long)]
domain: Option<String>,
},
/// Log in to Gitea only (personal access token).
Git {
/// Domain to authenticate against.
#[arg(long)]
domain: Option<String>,
},
/// Log out (remove all cached tokens).
Logout, Logout,
/// Show current authentication status. /// Show current authentication status.
Status, Status,
@@ -1022,11 +1034,15 @@ pub async fn dispatch() -> Result<()> {
}, },
Some(Verb::Auth { action }) => match action { Some(Verb::Auth { action }) => match action {
None => { None => crate::auth::cmd_auth_status().await,
crate::auth::cmd_auth_status().await
}
Some(AuthAction::Login { domain }) => { Some(AuthAction::Login { domain }) => {
crate::auth::cmd_auth_login(domain.as_deref()).await crate::auth::cmd_auth_login_all(domain.as_deref()).await
}
Some(AuthAction::Sso { domain }) => {
crate::auth::cmd_auth_sso_login(domain.as_deref()).await
}
Some(AuthAction::Git { domain }) => {
crate::auth::cmd_auth_git_login(domain.as_deref()).await
} }
Some(AuthAction::Logout) => crate::auth::cmd_auth_logout().await, Some(AuthAction::Logout) => crate::auth::cmd_auth_logout().await,
Some(AuthAction::Status) => crate::auth::cmd_auth_status().await, Some(AuthAction::Status) => crate::auth::cmd_auth_status().await,

187
src/pm.rs
View File

@@ -142,7 +142,7 @@ pub struct CardUpdate {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>, pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub list_id: Option<u64>, pub list_id: Option<serde_json::Value>,
} }
struct PlankaClient { struct PlankaClient {
@@ -168,13 +168,13 @@ mod planka_json {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Card { pub struct Card {
pub id: u64, pub id: serde_json::Value,
#[serde(default)] #[serde(default)]
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub description: Option<String>, pub description: Option<String>,
#[serde(default)] #[serde(default)]
pub list_id: Option<u64>, pub list_id: Option<serde_json::Value>,
#[serde(default)] #[serde(default)]
pub created_at: Option<String>, pub created_at: Option<String>,
#[serde(default)] #[serde(default)]
@@ -208,21 +208,21 @@ mod planka_json {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CardMembership { pub struct CardMembership {
pub card_id: u64, pub card_id: serde_json::Value,
pub user_id: u64, pub user_id: serde_json::Value,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CardLabel { pub struct CardLabel {
pub card_id: u64, pub card_id: serde_json::Value,
pub label_id: u64, pub label_id: serde_json::Value,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Label { pub struct Label {
pub id: u64, pub id: serde_json::Value,
#[serde(default)] #[serde(default)]
pub name: Option<String>, pub name: Option<String>,
} }
@@ -230,7 +230,7 @@ mod planka_json {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct List { pub struct List {
pub id: u64, pub id: serde_json::Value,
#[serde(default)] #[serde(default)]
pub name: String, pub name: String,
} }
@@ -238,7 +238,7 @@ mod planka_json {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct User { pub struct User {
pub id: u64, pub id: serde_json::Value,
#[serde(default)] #[serde(default)]
pub name: Option<String>, pub name: Option<String>,
#[serde(default)] #[serde(default)]
@@ -301,7 +301,7 @@ mod planka_json {
// Derive web URL from API base URL (strip `/api`). // Derive web URL from API base URL (strip `/api`).
let web_base = base_url.trim_end_matches("/api"); let web_base = base_url.trim_end_matches("/api");
Ticket { Ticket {
id: format!("planka:{}", self.id), id: format!("p:{}", self.id.as_str().unwrap_or(&self.id.to_string())),
source: Source::Planka, source: Source::Planka,
title: self.name, title: self.name,
description: self.description.unwrap_or_default(), description: self.description.unwrap_or_default(),
@@ -342,27 +342,29 @@ impl PlankaClient {
.build() .build()
.map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?; .map_err(|e| SunbeamError::network(format!("Failed to build HTTP client: {e}")))?;
// Try OIDC exchange first to get a Planka-native JWT. // Exchange the Hydra access token for a Planka JWT via our custom endpoint.
let exchange_url = format!("{base_url}/access-tokens/exchange-using-oidc"); let exchange_url = format!("{base_url}/access-tokens/exchange-using-token");
let exchange_resp = http let exchange_resp = http
.post(&exchange_url) .post(&exchange_url)
.bearer_auth(&hydra_token) .json(&serde_json::json!({ "token": hydra_token }))
.send() .send()
.await; .await
.map_err(|e| SunbeamError::network(format!("Planka token exchange failed: {e}")))?;
let token = match exchange_resp { if !exchange_resp.status().is_success() {
Ok(resp) if resp.status().is_success() => { let status = exchange_resp.status();
let body: planka_json::ExchangeResponse = resp let body = exchange_resp.text().await.unwrap_or_default();
.json() return Err(SunbeamError::identity(format!(
.await "Planka token exchange returned {status}: {body}"
.map_err(|e| SunbeamError::network(format!("Planka OIDC exchange parse error: {e}")))?; )));
body.token }
.or(body.item)
.unwrap_or_else(|| hydra_token.clone()) let body: serde_json::Value = exchange_resp.json().await?;
} let token = body
// Exchange not available or failed -- fall back to using the Hydra token directly. .get("item")
_ => hydra_token, .and_then(|v| v.as_str())
}; .ok_or_else(|| SunbeamError::identity("Planka exchange response missing 'item' field"))?
.to_string();
Ok(Self { Ok(Self {
base_url, base_url,
@@ -371,8 +373,66 @@ impl PlankaClient {
}) })
} }
/// 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. /// GET /api/boards/{id} and extract all cards.
async fn list_cards(&self, board_id: u64) -> Result<Vec<Ticket>> { async fn list_cards(&self, board_id: &str) -> Result<Vec<Ticket>> {
let url = format!("{}/boards/{board_id}", self.base_url); let url = format!("{}/boards/{board_id}", self.base_url);
let resp = self let resp = self
.http .http
@@ -501,7 +561,7 @@ impl PlankaClient {
self.update_card( self.update_card(
id, id,
&CardUpdate { &CardUpdate {
list_id: Some(list_id), list_id: Some(serde_json::Value::Number(list_id.into())),
..Default::default() ..Default::default()
}, },
) )
@@ -642,7 +702,7 @@ mod gitea_json {
.unwrap_or_else(|| format!("{web_base}/{org}/{repo}/issues/{}", self.number)); .unwrap_or_else(|| format!("{web_base}/{org}/{repo}/issues/{}", self.number));
Ticket { Ticket {
id: format!("gitea:{org}/{repo}#{}", self.number), id: format!("g:{org}/{repo}#{}", self.number),
source: Source::Gitea, source: Source::Gitea,
title: self.title, title: self.title,
description: self.body.unwrap_or_default(), description: self.body.unwrap_or_default(),
@@ -670,7 +730,8 @@ impl GiteaClient {
/// Create a new Gitea client using the Hydra OAuth2 token. /// Create a new Gitea client using the Hydra OAuth2 token.
async fn new(domain: &str) -> Result<Self> { async fn new(domain: &str) -> Result<Self> {
let base_url = format!("https://src.{domain}/api/v1"); let base_url = format!("https://src.{domain}/api/v1");
let token = get_token().await?; // Gitea needs its own PAT, not the Hydra access token
let token = crate::auth::get_gitea_token()?;
let http = reqwest::Client::builder() let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.build() .build()
@@ -683,6 +744,25 @@ impl GiteaClient {
}) })
} }
/// 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). /// List issues for an org/repo (or search across an org).
fn list_issues<'a>( fn list_issues<'a>(
&'a self, &'a self,
@@ -705,7 +785,7 @@ impl GiteaClient {
let resp = self let resp = self
.http .http
.get(&url) .get(&url)
.bearer_auth(&self.token) .header("Authorization", format!("token {}", self.token))
.query(&[("state", state), ("type", "issues"), ("limit", "50")]) .query(&[("state", state), ("type", "issues"), ("limit", "50")])
.send() .send()
.await .await
@@ -734,7 +814,7 @@ impl GiteaClient {
let repos_resp = self let repos_resp = self
.http .http
.get(&repos_url) .get(&repos_url)
.bearer_auth(&self.token) .header("Authorization", format!("token {}", self.token))
.query(&[("limit", "50")]) .query(&[("limit", "50")])
.send() .send()
.await .await
@@ -774,7 +854,7 @@ impl GiteaClient {
let resp = self let resp = self
.http .http
.get(&url) .get(&url)
.bearer_auth(&self.token) .header("Authorization", format!("token {}", self.token))
.send() .send()
.await .await
.map_err(|e| SunbeamError::network(format!("Gitea get_issue: {e}")))?; .map_err(|e| SunbeamError::network(format!("Gitea get_issue: {e}")))?;
@@ -811,7 +891,7 @@ impl GiteaClient {
let resp = self let resp = self
.http .http
.post(&url) .post(&url)
.bearer_auth(&self.token) .header("Authorization", format!("token {}", self.token))
.json(&payload) .json(&payload)
.send() .send()
.await .await
@@ -844,7 +924,7 @@ impl GiteaClient {
let resp = self let resp = self
.http .http
.patch(&url) .patch(&url)
.bearer_auth(&self.token) .header("Authorization", format!("token {}", self.token))
.json(updates) .json(updates)
.send() .send()
.await .await
@@ -890,7 +970,7 @@ impl GiteaClient {
let resp = self let resp = self
.http .http
.post(&url) .post(&url)
.bearer_auth(&self.token) .header("Authorization", format!("token {}", self.token))
.json(&payload) .json(&payload)
.send() .send()
.await .await
@@ -923,7 +1003,7 @@ impl GiteaClient {
let resp = self let resp = self
.http .http
.post(&url) .post(&url)
.bearer_auth(&self.token) .header("Authorization", format!("token {}", self.token))
.json(&payload) .json(&payload)
.send() .send()
.await .await
@@ -1009,8 +1089,7 @@ pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> {
let planka_fut = async { let planka_fut = async {
if fetch_planka { if fetch_planka {
let client = PlankaClient::new(&domain).await?; let client = PlankaClient::new(&domain).await?;
// Board ID 1 as default; in practice this would be configurable. client.list_all_cards().await
client.list_cards(1).await
} else { } else {
Ok(vec![]) Ok(vec![])
} }
@@ -1314,7 +1393,7 @@ mod tests {
fn test_display_ticket_list_table() { fn test_display_ticket_list_table() {
let tickets = vec![ let tickets = vec![
Ticket { Ticket {
id: "planka:1".to_string(), id: "p:1".to_string(),
source: Source::Planka, source: Source::Planka,
title: "Fix login".to_string(), title: "Fix login".to_string(),
description: String::new(), description: String::new(),
@@ -1326,7 +1405,7 @@ mod tests {
url: "https://projects.example.com/cards/1".to_string(), url: "https://projects.example.com/cards/1".to_string(),
}, },
Ticket { Ticket {
id: "gitea:studio/cli#7".to_string(), id: "g:studio/cli#7".to_string(),
source: Source::Gitea, source: Source::Gitea,
title: "Add tests".to_string(), title: "Add tests".to_string(),
description: "We need more tests.".to_string(), description: "We need more tests.".to_string(),
@@ -1353,8 +1432,8 @@ mod tests {
.collect(); .collect();
let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]); let tbl = output::table(&rows, &["ID", "STATUS", "TITLE", "ASSIGNEES", "SOURCE"]);
assert!(tbl.contains("planka:1")); assert!(tbl.contains("p:1"));
assert!(tbl.contains("gitea:studio/cli#7")); assert!(tbl.contains("g:studio/cli#7"));
assert!(tbl.contains("open")); assert!(tbl.contains("open"));
assert!(tbl.contains("in-progress")); assert!(tbl.contains("in-progress"));
assert!(tbl.contains("Fix login")); assert!(tbl.contains("Fix login"));
@@ -1379,7 +1458,7 @@ mod tests {
let update = CardUpdate { let update = CardUpdate {
name: Some("New name".to_string()), name: Some("New name".to_string()),
description: None, description: None,
list_id: Some(5), list_id: Some(serde_json::json!(5)),
}; };
let json = serde_json::to_value(&update).unwrap(); let json = serde_json::to_value(&update).unwrap();
assert_eq!(json["name"], "New name"); assert_eq!(json["name"], "New name");
@@ -1411,19 +1490,19 @@ mod tests {
card_labels: vec![], card_labels: vec![],
labels: vec![], labels: vec![],
lists: vec![ lists: vec![
List { id: 1, name: "To Do".to_string() }, List { id: serde_json::json!(1), name: "To Do".to_string() },
List { id: 2, name: "In Progress".to_string() }, List { id: serde_json::json!(2), name: "In Progress".to_string() },
List { id: 3, name: "Done".to_string() }, List { id: serde_json::json!(3), name: "Done".to_string() },
List { id: 4, name: "Archived / Closed".to_string() }, List { id: serde_json::json!(4), name: "Archived / Closed".to_string() },
], ],
users: vec![], users: vec![],
}; };
let make_card = |list_id| Card { let make_card = |list_id: u64| Card {
id: 1, id: serde_json::json!(1),
name: "test".to_string(), name: "test".to_string(),
description: None, description: None,
list_id: Some(list_id), list_id: Some(serde_json::json!(list_id)),
created_at: None, created_at: None,
updated_at: None, updated_at: None,
}; };
@@ -1466,7 +1545,7 @@ mod tests {
}; };
let ticket = issue.to_ticket("https://src.example.com/api/v1", "studio", "app"); let ticket = issue.to_ticket("https://src.example.com/api/v1", "studio", "app");
assert_eq!(ticket.id, "gitea:studio/app#42"); assert_eq!(ticket.id, "g:studio/app#42");
assert_eq!(ticket.source, Source::Gitea); assert_eq!(ticket.source, Source::Gitea);
assert_eq!(ticket.title, "Bug report"); assert_eq!(ticket.title, "Bug report");
assert_eq!(ticket.description, "Something broke"); assert_eq!(ticket.description, "Something broke");