feat: complete pm subcommands with board discovery and user resolution
Planka: - Board discovery via GET /api/projects (no hardcoded IDs) - String IDs (snowflake) throughout — TicketRef::Planka holds String - Create auto-discovers first board/list, or matches --target by name - Close finds "Done"/"Closed" list and moves card automatically - Assign resolves users via search, supports "me" for self-assign - Ticket IDs use p:/g: short prefixes Gitea: - Assign uses PATCH on issue (not POST /assignees which needs collaborator) - Create requires --target org/repo All pm subcommands tested against live Planka + Gitea instances.
This commit is contained in:
206
src/pm.rs
206
src/pm.rs
@@ -70,8 +70,8 @@ impl std::fmt::Display for Status {
|
||||
/// A parsed ticket reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TicketRef {
|
||||
/// Planka card by numeric ID.
|
||||
Planka(u64),
|
||||
/// Planka card by ID (snowflake string).
|
||||
Planka(String),
|
||||
/// Gitea issue: (org, repo, issue number).
|
||||
Gitea {
|
||||
org: String,
|
||||
@@ -92,10 +92,10 @@ pub fn parse_ticket_id(id: &str) -> Result<TicketRef> {
|
||||
|
||||
match prefix {
|
||||
"p" | "planka" => {
|
||||
let card_id: u64 = rest
|
||||
.parse()
|
||||
.map_err(|_| SunbeamError::config(format!("Invalid Planka card ID: {rest}")))?;
|
||||
Ok(TicketRef::Planka(card_id))
|
||||
if rest.is_empty() {
|
||||
return Err(SunbeamError::config("Empty Planka card ID"));
|
||||
}
|
||||
Ok(TicketRef::Planka(rest.to_string()))
|
||||
}
|
||||
"g" | "gitea" => {
|
||||
// Expected: org/repo#number
|
||||
@@ -310,7 +310,7 @@ mod planka_json {
|
||||
labels,
|
||||
created_at: self.created_at.unwrap_or_default(),
|
||||
updated_at: self.updated_at.unwrap_or_default(),
|
||||
url: format!("{web_base}/cards/{}", self.id),
|
||||
url: format!("{web_base}/cards/{}", self.id.as_str().unwrap_or(&self.id.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,7 +470,7 @@ impl PlankaClient {
|
||||
}
|
||||
|
||||
/// GET /api/cards/{id}
|
||||
async fn get_card(&self, id: u64) -> Result<Ticket> {
|
||||
async fn get_card(&self, id: &str) -> Result<Ticket> {
|
||||
let url = format!("{}/cards/{id}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
@@ -500,8 +500,8 @@ impl PlankaClient {
|
||||
/// POST /api/lists/{list_id}/cards
|
||||
async fn create_card(
|
||||
&self,
|
||||
_board_id: u64,
|
||||
list_id: u64,
|
||||
_board_id: &str,
|
||||
list_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
) -> Result<Ticket> {
|
||||
@@ -509,6 +509,7 @@ impl PlankaClient {
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"position": 65535,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
@@ -536,7 +537,7 @@ impl PlankaClient {
|
||||
}
|
||||
|
||||
/// PATCH /api/cards/{id}
|
||||
async fn update_card(&self, id: u64, updates: &CardUpdate) -> Result<()> {
|
||||
async fn update_card(&self, id: &str, updates: &CardUpdate) -> Result<()> {
|
||||
let url = format!("{}/cards/{id}", self.base_url);
|
||||
let resp = self
|
||||
.http
|
||||
@@ -557,11 +558,11 @@ impl PlankaClient {
|
||||
}
|
||||
|
||||
/// Move a card to a different list.
|
||||
async fn move_card(&self, id: u64, list_id: u64) -> Result<()> {
|
||||
async fn move_card(&self, id: &str, list_id: &str) -> Result<()> {
|
||||
self.update_card(
|
||||
id,
|
||||
&CardUpdate {
|
||||
list_id: Some(serde_json::Value::Number(list_id.into())),
|
||||
list_id: Some(serde_json::json!(list_id)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -569,7 +570,7 @@ impl PlankaClient {
|
||||
}
|
||||
|
||||
/// POST /api/cards/{id}/comment-actions
|
||||
async fn comment_card(&self, id: u64, text: &str) -> Result<()> {
|
||||
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 });
|
||||
|
||||
@@ -591,8 +592,55 @@ impl PlankaClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search for a Planka user by name/username, return their ID.
|
||||
async fn resolve_user_id(&self, query: &str) -> Result<String> {
|
||||
// "me" or "self" assigns to the current user
|
||||
if query == "me" || query == "self" {
|
||||
// Get current user via the token (decode JWT or call /api/users/me equivalent)
|
||||
// Planka doesn't have /api/users/me, but we can get user from any board membership
|
||||
let projects_url = format!("{}/projects", self.base_url);
|
||||
if let Ok(resp) = self.http.get(&projects_url).bearer_auth(&self.token).send().await {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(memberships) = body.get("included")
|
||||
.and_then(|i| i.get("boardMemberships"))
|
||||
.and_then(|b| b.as_array())
|
||||
{
|
||||
if let Some(user_id) = memberships.first()
|
||||
.and_then(|m| m.get("userId"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return Ok(user_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search other users (note: Planka excludes current user from search results)
|
||||
let url = format!("{}/users/search", self.base_url);
|
||||
let resp = self.http.get(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.query(&[("query", query)])
|
||||
.send().await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka user search: {e}")))?;
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let users = body.get("items").and_then(|i| i.as_array());
|
||||
if let Some(users) = users {
|
||||
if let Some(user) = users.first() {
|
||||
if let Some(id) = user.get("id").and_then(|v| v.as_str()) {
|
||||
return Ok(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(SunbeamError::identity(format!(
|
||||
"Planka user not found: {query} (use 'me' to assign to yourself)"
|
||||
)))
|
||||
}
|
||||
|
||||
/// POST /api/cards/{id}/memberships
|
||||
async fn assign_card(&self, id: u64, user_id: &str) -> Result<()> {
|
||||
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 });
|
||||
|
||||
@@ -994,15 +1042,18 @@ impl GiteaClient {
|
||||
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}/assignees",
|
||||
"{}/repos/{org}/{repo}/issues/{index}",
|
||||
self.base_url
|
||||
);
|
||||
let payload = serde_json::json!({ "assignees": [assignee] });
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.patch(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.json(&payload)
|
||||
.send()
|
||||
@@ -1011,7 +1062,7 @@ impl GiteaClient {
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SunbeamError::network(format!(
|
||||
"Gitea POST assignee on {org}/{repo}#{index} returned {}",
|
||||
"Gitea assign on {org}/{repo}#{index} returned {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
@@ -1139,7 +1190,7 @@ pub async fn cmd_pm_show(id: &str) -> Result<()> {
|
||||
let ticket = match ticket_ref {
|
||||
TicketRef::Planka(card_id) => {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
client.get_card(card_id).await?
|
||||
client.get_card(&card_id).await?
|
||||
}
|
||||
TicketRef::Gitea { org, repo, number } => {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
@@ -1163,23 +1214,48 @@ pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str)
|
||||
|
||||
let ticket = match source {
|
||||
"planka" | "p" => {
|
||||
let parts: Vec<&str> = target.splitn(2, '/').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(SunbeamError::config(
|
||||
"Planka target must be 'board_id/list_id'",
|
||||
));
|
||||
}
|
||||
let board_id: u64 = parts[0]
|
||||
.parse()
|
||||
.map_err(|_| SunbeamError::config("Invalid board_id"))?;
|
||||
let list_id: u64 = parts[1]
|
||||
.parse()
|
||||
.map_err(|_| SunbeamError::config("Invalid list_id"))?;
|
||||
|
||||
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'"));
|
||||
@@ -1209,7 +1285,7 @@ pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> {
|
||||
match ticket_ref {
|
||||
TicketRef::Planka(card_id) => {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
client.comment_card(card_id, text).await?;
|
||||
client.comment_card(&card_id, text).await?;
|
||||
}
|
||||
TicketRef::Gitea { org, repo, number } => {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
@@ -1230,21 +1306,48 @@ pub async fn cmd_pm_close(id: &str) -> Result<()> {
|
||||
|
||||
match ticket_ref {
|
||||
TicketRef::Planka(card_id) => {
|
||||
// Move to a "Done" list -- caller should specify the list, but as a
|
||||
// sensible default we just update the card name convention. A real
|
||||
// implementation would look up the board's "Done" list.
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
// Attempt to find the board's Done list via get_card metadata.
|
||||
// For now, just mark description.
|
||||
let _ = client
|
||||
.update_card(
|
||||
card_id,
|
||||
&CardUpdate {
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
output::ok(&format!("Planka card {card_id} closed (move to Done list manually if needed)."));
|
||||
// Get the card to find its board, then find a "Done"/"Closed" list
|
||||
let ticket = client.get_card(&card_id).await?;
|
||||
// Try to find the board and its lists
|
||||
let url = format!("{}/cards/{card_id}", client.base_url);
|
||||
let resp = client.http.get(&url).bearer_auth(&client.token).send().await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka get card: {e}")))?;
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let board_id = body.get("item").and_then(|i| i.get("boardId"))
|
||||
.and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if !board_id.is_empty() {
|
||||
// Fetch the board to get its lists
|
||||
let board_url = format!("{}/boards/{board_id}", client.base_url);
|
||||
let board_resp = client.http.get(&board_url).bearer_auth(&client.token).send().await
|
||||
.map_err(|e| SunbeamError::network(format!("Planka get board: {e}")))?;
|
||||
let board_body: serde_json::Value = board_resp.json().await?;
|
||||
let lists = board_body.get("included")
|
||||
.and_then(|i| i.get("lists"))
|
||||
.and_then(|l| l.as_array());
|
||||
|
||||
if let Some(lists) = lists {
|
||||
// Find a list named "Done", "Closed", "Complete", or similar
|
||||
let done_list = lists.iter().find(|l| {
|
||||
let name = l.get("name").and_then(|n| n.as_str()).unwrap_or("").to_lowercase();
|
||||
name.contains("done") || name.contains("closed") || name.contains("complete")
|
||||
});
|
||||
|
||||
if let Some(done_list) = done_list {
|
||||
let list_id = done_list.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !list_id.is_empty() {
|
||||
client.update_card(&card_id, &CardUpdate {
|
||||
list_id: Some(serde_json::json!(list_id)),
|
||||
..Default::default()
|
||||
}).await?;
|
||||
output::ok(&format!("Moved p:{card_id} to Done."));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
output::warn(&format!("Could not find a Done list for p:{card_id}. Move it manually."));
|
||||
}
|
||||
TicketRef::Gitea { org, repo, number } => {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
@@ -1266,7 +1369,7 @@ pub async fn cmd_pm_assign(id: &str, user: &str) -> Result<()> {
|
||||
match ticket_ref {
|
||||
TicketRef::Planka(card_id) => {
|
||||
let client = PlankaClient::new(&domain).await?;
|
||||
client.assign_card(card_id, user).await?;
|
||||
client.assign_card(&card_id, user).await?;
|
||||
}
|
||||
TicketRef::Gitea { org, repo, number } => {
|
||||
let client = GiteaClient::new(&domain).await?;
|
||||
@@ -1291,13 +1394,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_parse_planka_short() {
|
||||
let r = parse_ticket_id("p:42").unwrap();
|
||||
assert_eq!(r, TicketRef::Planka(42));
|
||||
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));
|
||||
assert_eq!(r, TicketRef::Planka("100".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1338,7 +1441,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_planka_id() {
|
||||
assert!(parse_ticket_id("p:abc").is_err());
|
||||
// Empty ID should fail
|
||||
assert!(parse_ticket_id("p:").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user