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:
183
src/auth.rs
183
src/auth.rs
@@ -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();
|
||||||
|
|||||||
28
src/cli.rs
28
src/cli.rs
@@ -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
187
src/pm.rs
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user