omnitrack 0.3.0

Universal issue-tracker provider contracts and clients (Linear, Jira, ...) for Rust, in one crate.
Documentation
use crate::{ErrorKind, IssueResult, StatusCategory, error};
use reqwest::Method;
use serde::Deserialize;

use super::client::JiraClient;
use super::model::StatusCategoryNode;

#[derive(Deserialize)]
struct TransitionNode {
    id: String,
    name: String,
    to: Option<TransitionTarget>,
}

#[derive(Deserialize)]
struct TransitionTarget {
    name: String,
    #[serde(rename = "statusCategory")]
    status_category: Option<StatusCategoryNode>,
}

pub(crate) fn category_status_key(category: StatusCategory) -> &'static str {
    match category {
        StatusCategory::Backlog | StatusCategory::Unstarted => "new",
        StatusCategory::Started => "indeterminate",
        StatusCategory::Completed | StatusCategory::Canceled => "done",
    }
}

impl JiraClient {
    async fn transitions(&self, key: &str) -> IssueResult<Vec<TransitionNode>> {
        #[derive(Deserialize)]
        struct Response {
            #[serde(default)]
            transitions: Vec<TransitionNode>,
        }

        let path = format!("/rest/api/3/issue/{key}/transitions");
        let response: Response = self.request(Method::GET, &path, None).await?;
        Ok(response.transitions)
    }

    async fn apply_transition(&self, key: &str, transition_id: &str) -> IssueResult<()> {
        let path = format!("/rest/api/3/issue/{key}/transitions");
        let body = serde_json::json!({ "transition": { "id": transition_id } });
        self.request_unit(Method::POST, &path, Some(&body)).await
    }

    pub(crate) async fn transition_status(&self, key: &str, status: &str) -> IssueResult<()> {
        let wanted = status.to_lowercase();
        let target = self.transitions(key).await?.into_iter().find(|transition| {
            transition
                .to
                .as_ref()
                .map(|to| to.name.to_lowercase() == wanted)
                .unwrap_or(false)
                || transition.name.to_lowercase() == wanted
        });

        match target {
            Some(transition) => self.apply_transition(key, &transition.id).await,
            None => Err(error().of(
                ErrorKind::Provider,
                format!("jira issue has no transition to status {status}"),
            )),
        }
    }

    pub(crate) async fn transition_category(
        &self,
        key: &str,
        category: StatusCategory,
    ) -> IssueResult<()> {
        let wanted = category_status_key(category);
        let target = self.transitions(key).await?.into_iter().find(|transition| {
            transition
                .to
                .as_ref()
                .and_then(|to| to.status_category.as_ref())
                .map(|status_category| status_category.key == wanted)
                .unwrap_or(false)
        });

        match target {
            Some(transition) => self.apply_transition(key, &transition.id).await,
            None => Err(error().of(
                ErrorKind::Provider,
                format!("jira issue has no transition to category {wanted}"),
            )),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn category_status_keys_map_to_jira() {
        assert_eq!(category_status_key(StatusCategory::Backlog), "new");
        assert_eq!(
            category_status_key(StatusCategory::Started),
            "indeterminate"
        );
        assert_eq!(category_status_key(StatusCategory::Canceled), "done");
    }
}