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.
|
/// A parsed ticket reference.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum TicketRef {
|
pub enum TicketRef {
|
||||||
/// Planka card by numeric ID.
|
/// Planka card by ID (snowflake string).
|
||||||
Planka(u64),
|
Planka(String),
|
||||||
/// Gitea issue: (org, repo, issue number).
|
/// Gitea issue: (org, repo, issue number).
|
||||||
Gitea {
|
Gitea {
|
||||||
org: String,
|
org: String,
|
||||||
@@ -92,10 +92,10 @@ pub fn parse_ticket_id(id: &str) -> Result<TicketRef> {
|
|||||||
|
|
||||||
match prefix {
|
match prefix {
|
||||||
"p" | "planka" => {
|
"p" | "planka" => {
|
||||||
let card_id: u64 = rest
|
if rest.is_empty() {
|
||||||
.parse()
|
return Err(SunbeamError::config("Empty Planka card ID"));
|
||||||
.map_err(|_| SunbeamError::config(format!("Invalid Planka card ID: {rest}")))?;
|
}
|
||||||
Ok(TicketRef::Planka(card_id))
|
Ok(TicketRef::Planka(rest.to_string()))
|
||||||
}
|
}
|
||||||
"g" | "gitea" => {
|
"g" | "gitea" => {
|
||||||
// Expected: org/repo#number
|
// Expected: org/repo#number
|
||||||
@@ -310,7 +310,7 @@ mod planka_json {
|
|||||||
labels,
|
labels,
|
||||||
created_at: self.created_at.unwrap_or_default(),
|
created_at: self.created_at.unwrap_or_default(),
|
||||||
updated_at: self.updated_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}
|
/// 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 url = format!("{}/cards/{id}", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
@@ -500,8 +500,8 @@ impl PlankaClient {
|
|||||||
/// POST /api/lists/{list_id}/cards
|
/// POST /api/lists/{list_id}/cards
|
||||||
async fn create_card(
|
async fn create_card(
|
||||||
&self,
|
&self,
|
||||||
_board_id: u64,
|
_board_id: &str,
|
||||||
list_id: u64,
|
list_id: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
description: &str,
|
description: &str,
|
||||||
) -> Result<Ticket> {
|
) -> Result<Ticket> {
|
||||||
@@ -509,6 +509,7 @@ impl PlankaClient {
|
|||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": description,
|
"description": description,
|
||||||
|
"position": 65535,
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
@@ -536,7 +537,7 @@ impl PlankaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// PATCH /api/cards/{id}
|
/// 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 url = format!("{}/cards/{id}", self.base_url);
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
@@ -557,11 +558,11 @@ impl PlankaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Move a card to a different list.
|
/// 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(
|
self.update_card(
|
||||||
id,
|
id,
|
||||||
&CardUpdate {
|
&CardUpdate {
|
||||||
list_id: Some(serde_json::Value::Number(list_id.into())),
|
list_id: Some(serde_json::json!(list_id)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -569,7 +570,7 @@ impl PlankaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cards/{id}/comment-actions
|
/// 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 url = format!("{}/cards/{id}/comment-actions", self.base_url);
|
||||||
let body = serde_json::json!({ "text": text });
|
let body = serde_json::json!({ "text": text });
|
||||||
|
|
||||||
@@ -591,8 +592,55 @@ impl PlankaClient {
|
|||||||
Ok(())
|
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
|
/// 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 url = format!("{}/cards/{id}/memberships", self.base_url);
|
||||||
let body = serde_json::json!({ "userId": user_id });
|
let body = serde_json::json!({ "userId": user_id });
|
||||||
|
|
||||||
@@ -994,15 +1042,18 @@ impl GiteaClient {
|
|||||||
index: u64,
|
index: u64,
|
||||||
assignee: &str,
|
assignee: &str,
|
||||||
) -> Result<()> {
|
) -> 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!(
|
let url = format!(
|
||||||
"{}/repos/{org}/{repo}/issues/{index}/assignees",
|
"{}/repos/{org}/{repo}/issues/{index}",
|
||||||
self.base_url
|
self.base_url
|
||||||
);
|
);
|
||||||
let payload = serde_json::json!({ "assignees": [assignee] });
|
let payload = serde_json::json!({ "assignees": [assignee] });
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.post(&url)
|
.patch(&url)
|
||||||
.header("Authorization", format!("token {}", self.token))
|
.header("Authorization", format!("token {}", self.token))
|
||||||
.json(&payload)
|
.json(&payload)
|
||||||
.send()
|
.send()
|
||||||
@@ -1011,7 +1062,7 @@ impl GiteaClient {
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(SunbeamError::network(format!(
|
return Err(SunbeamError::network(format!(
|
||||||
"Gitea POST assignee on {org}/{repo}#{index} returned {}",
|
"Gitea assign on {org}/{repo}#{index} returned {}",
|
||||||
resp.status()
|
resp.status()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1190,7 @@ pub async fn cmd_pm_show(id: &str) -> Result<()> {
|
|||||||
let ticket = match ticket_ref {
|
let ticket = match ticket_ref {
|
||||||
TicketRef::Planka(card_id) => {
|
TicketRef::Planka(card_id) => {
|
||||||
let client = PlankaClient::new(&domain).await?;
|
let client = PlankaClient::new(&domain).await?;
|
||||||
client.get_card(card_id).await?
|
client.get_card(&card_id).await?
|
||||||
}
|
}
|
||||||
TicketRef::Gitea { org, repo, number } => {
|
TicketRef::Gitea { org, repo, number } => {
|
||||||
let client = GiteaClient::new(&domain).await?;
|
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 {
|
let ticket = match source {
|
||||||
"planka" | "p" => {
|
"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?;
|
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?
|
client.create_card(board_id, list_id, title, body).await?
|
||||||
}
|
}
|
||||||
"gitea" | "g" => {
|
"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();
|
let parts: Vec<&str> = target.splitn(2, '/').collect();
|
||||||
if parts.len() != 2 {
|
if parts.len() != 2 {
|
||||||
return Err(SunbeamError::config("Gitea target must be 'org/repo'"));
|
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 {
|
match ticket_ref {
|
||||||
TicketRef::Planka(card_id) => {
|
TicketRef::Planka(card_id) => {
|
||||||
let client = PlankaClient::new(&domain).await?;
|
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 } => {
|
TicketRef::Gitea { org, repo, number } => {
|
||||||
let client = GiteaClient::new(&domain).await?;
|
let client = GiteaClient::new(&domain).await?;
|
||||||
@@ -1230,21 +1306,48 @@ pub async fn cmd_pm_close(id: &str) -> Result<()> {
|
|||||||
|
|
||||||
match ticket_ref {
|
match ticket_ref {
|
||||||
TicketRef::Planka(card_id) => {
|
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?;
|
let client = PlankaClient::new(&domain).await?;
|
||||||
// Attempt to find the board's Done list via get_card metadata.
|
// Get the card to find its board, then find a "Done"/"Closed" list
|
||||||
// For now, just mark description.
|
let ticket = client.get_card(&card_id).await?;
|
||||||
let _ = client
|
// Try to find the board and its lists
|
||||||
.update_card(
|
let url = format!("{}/cards/{card_id}", client.base_url);
|
||||||
card_id,
|
let resp = client.http.get(&url).bearer_auth(&client.token).send().await
|
||||||
&CardUpdate {
|
.map_err(|e| SunbeamError::network(format!("Planka get card: {e}")))?;
|
||||||
..Default::default()
|
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("");
|
||||||
.await;
|
|
||||||
output::ok(&format!("Planka card {card_id} closed (move to Done list manually if needed)."));
|
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 } => {
|
TicketRef::Gitea { org, repo, number } => {
|
||||||
let client = GiteaClient::new(&domain).await?;
|
let client = GiteaClient::new(&domain).await?;
|
||||||
@@ -1266,7 +1369,7 @@ pub async fn cmd_pm_assign(id: &str, user: &str) -> Result<()> {
|
|||||||
match ticket_ref {
|
match ticket_ref {
|
||||||
TicketRef::Planka(card_id) => {
|
TicketRef::Planka(card_id) => {
|
||||||
let client = PlankaClient::new(&domain).await?;
|
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 } => {
|
TicketRef::Gitea { org, repo, number } => {
|
||||||
let client = GiteaClient::new(&domain).await?;
|
let client = GiteaClient::new(&domain).await?;
|
||||||
@@ -1291,13 +1394,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_parse_planka_short() {
|
fn test_parse_planka_short() {
|
||||||
let r = parse_ticket_id("p:42").unwrap();
|
let r = parse_ticket_id("p:42").unwrap();
|
||||||
assert_eq!(r, TicketRef::Planka(42));
|
assert_eq!(r, TicketRef::Planka("42".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_planka_long() {
|
fn test_parse_planka_long() {
|
||||||
let r = parse_ticket_id("planka:100").unwrap();
|
let r = parse_ticket_id("planka:100").unwrap();
|
||||||
assert_eq!(r, TicketRef::Planka(100));
|
assert_eq!(r, TicketRef::Planka("100".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1338,7 +1441,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_invalid_planka_id() {
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user