diff --git a/src/access.rs b/src/access.rs index 359e6ca..dca745a 100644 --- a/src/access.rs +++ b/src/access.rs @@ -18,8 +18,11 @@ limitations under the License. // * https://docs.atlassian.com/software/jira/docs/api/REST/latest/ // * https://docs.atlassian.com/jira-software/REST/latest/ +use serde::Serialize; + use crate::errors::JiraQueryError; use crate::issue_model::{Issue, JqlResults}; +use crate::{Comment, User}; // The prefix of every subsequent REST request. // This string comes directly after the host in the URL. @@ -74,6 +77,8 @@ enum Method<'a> { Key(&'a str), Keys(&'a [&'a str]), Search(&'a str), + User(&'a str), + Myself, } impl<'a> Method<'a> { @@ -82,6 +87,8 @@ impl<'a> Method<'a> { Self::Key(id) => format!("issue/{id}"), Self::Keys(ids) => format!("search?jql=id%20in%20({})", ids.join(",")), Self::Search(query) => format!("search?jql={query}"), + Self::User(id) => format!("user?accountId={id}"), + Self::Myself => "myself".to_string(), } } } @@ -137,7 +144,7 @@ impl JiraInstance { // The `startAt` option is only valid with JQL. With a URL by key, it breaks the REST query. let start_at = match method { - Method::Key(_) => String::new(), + Method::Key(_) | Method::User(_) | Method::Myself => String::new(), Method::Keys(_) | Method::Search(_) => format!("&startAt={start_at}"), }; @@ -162,6 +169,25 @@ impl JiraInstance { authenticated.send().await } + /// Post the given body to the specified URL using the configured authentication. + async fn authenticated_post( + &self, + url: &str, + body: &T, + ) -> Result { + let request_builder = self.client.post(url); + let authenticated = match &self.auth { + Auth::Anonymous => request_builder, + Auth::ApiKey(key) => request_builder.header("Authorization", &format!("Bearer {key}")), + Auth::Basic { user, password } => request_builder.basic_auth(user, Some(password)), + }; + authenticated + .header("Content-Type", "application/json") + .json(body) + .send() + .await + } + // This method uses a separate implementation from `issues` because Jira provides a way // to request a single ticket specifically. That conveniently handles error cases // where no tickets might match, or more than one might. @@ -279,6 +305,80 @@ impl JiraInstance { Ok(issues) } } + + /// Access a Jira user by their user ID. + pub async fn user( + &self, + user_id: &str, + ) -> Result> { + let user_url = self.path(&Method::User(user_id), 0); + + let user = self + .authenticated_get(&user_url) + .await? + .json::() + .await?; + + Ok(user) + } + + /// Access information about my own user account. + pub async fn myself( + &self, + ) -> Result> { + let user_url = self.path(&Method::Myself, 0); + + let user = self + .authenticated_get(&user_url) + .await? + .json::() + .await?; + + Ok(user) + } + + /// Post a comment to a Jira issue. + /// + /// Specify `user_id` to post the comment as a specific user. Otherwise, + /// if `user_id` is None, you're posting the comment as yourself. + pub async fn post_comment( + &self, + issue_id: &str, + user_id: Option<&str>, + content: &str, + ) -> Result> { + let url = self.path(&Method::Key(issue_id), 0) + "/comment"; + + log::debug!("Comment URL: {}", url); + + let user = if let Some(user_id) = user_id { + let u = self.user(user_id).await?; + log::debug!("Commenting user: {:#?}", u); + Some(u) + } else { + log::debug!("Commenting as myself."); + None + }; + + let comment = Comment { + // If the author is None, we're commenting as "myself" + author: user, + body: content.to_owned(), + ..Default::default() + }; + + log::debug!("Prepared comment: {:#?}", comment); + + let response = self.authenticated_post(&url, &comment).await?; + + log::debug!("Response to comment: {:#?}", response); + + let comment = response.json::().await?; + + log::debug!("Parsed comment: {:#?}", comment); + + Ok(comment) + } } #[cfg(test)] diff --git a/src/issue_model.rs b/src/issue_model.rs index ea46746..92ccb7d 100644 --- a/src/issue_model.rs +++ b/src/issue_model.rs @@ -72,8 +72,8 @@ pub struct Fields { pub timespent: Option, pub aggregatetimespent: Option, pub aggregatetimeoriginalestimate: Option, - pub progress: Progress, - pub aggregateprogress: Progress, + pub progress: Option, + pub aggregateprogress: Option, pub workratio: i64, pub summary: String, pub creator: User, @@ -272,18 +272,18 @@ pub struct Progress { } /// A comment below a Jira issue. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct Comment { - pub author: User, + pub author: Option, pub body: String, - pub created: DateTime, - pub id: String, + pub created: Option>, + pub id: Option, #[serde(rename = "updateAuthor")] - pub update_author: User, - pub updated: DateTime, + pub update_author: Option, + pub updated: Option>, pub visibility: Option, #[serde(rename = "self")] - pub self_link: String, + pub self_link: Option, #[serde(flatten)] pub extra: Value, } diff --git a/tests/integration.rs b/tests/integration.rs index 3e1f377..5217d79 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -20,6 +20,12 @@ fn apache_jira() -> JiraInstance { JiraInstance::at("https://issues.apache.org/jira/".to_string()).unwrap() } +/// A common convenience function to get anonymous access +/// to the Whamcloud Jira instance. +fn whamcloud_jira() -> JiraInstance { + JiraInstance::at("https://jira.whamcloud.com".to_string()).unwrap() +} + /// Try accessing several public issues separately /// to test the client and the deserialization. #[tokio::test] @@ -148,3 +154,12 @@ async fn access_apache_issues() { .await .unwrap(); } + +#[tokio::test] +async fn access_whamcloud_issues() { + let instance = whamcloud_jira(); + let _issues = instance + .issues(&["LU-10647", "LU-13009", "LU-8002", "LU-8874"]) + .await + .unwrap(); +}