task_picker/sources/
openproject.rs

1use anyhow::{anyhow, Context, Result};
2use base64::prelude::*;
3use chrono::{DateTime, Local, NaiveDate, Utc};
4use json::{array, JsonValue};
5use serde::{Deserialize, Serialize};
6use ureq::Agent;
7
8use crate::tasks::Task;
9
10use super::OPENPROJECT_ICON;
11
12#[derive(Serialize, Deserialize, Clone)]
13#[serde(default)]
14pub struct OpenProjectSource {
15    #[serde(skip)]
16    agent: Agent,
17    pub name: String,
18    pub server_url: String,
19}
20
21impl Default for OpenProjectSource {
22    fn default() -> Self {
23        Self {
24            agent: Agent::new(),
25            name: "OpenProject".to_string(),
26            server_url: "https://community.openproject.org".to_string(),
27        }
28    }
29}
30
31impl OpenProjectSource {
32    fn create_task(&self, work_package: &JsonValue) -> Result<Option<Task>> {
33        if let JsonValue::Object(work_package) = work_package {
34            let title = work_package["subject"].as_str().unwrap_or("<unknown>");
35            let id = work_package["id"]
36                .as_i64()
37                .context("'id' field in response is not an integer")?;
38            let url = format!("{}/work_packages/{id}/activity", self.server_url);
39
40            let project = work_package["_links"]["project"]["title"].to_string();
41
42            let created = if let Some(c) = work_package["createdAt"].as_str() {
43                let created_utc: DateTime<Utc> = DateTime::parse_from_rfc3339(c)?.into();
44                Some(created_utc)
45            } else {
46                None
47            };
48
49            let start: Option<DateTime<Utc>> = work_package["startDate"]
50                .as_str()
51                .map(|due_date| NaiveDate::parse_from_str(due_date, "%Y-%m-%d"))
52                .transpose()?
53                .and_then(|due_date| due_date.and_hms_opt(0, 0, 0))
54                .map(|due_date| DateTime::from_naive_utc_and_offset(due_date, Utc));
55
56            let can_start = if let Some(start) = start {
57                let start: DateTime<Local> = DateTime::from(start);
58                Local::now().cmp(&start).is_ge()
59            } else {
60                true
61            };
62
63            if can_start {
64                let due: Option<DateTime<Utc>> = work_package["dueDate"]
65                    .as_str()
66                    .map(|due_date| NaiveDate::parse_from_str(due_date, "%Y-%m-%d"))
67                    .transpose()?
68                    .and_then(|due_date| due_date.and_hms_opt(0, 0, 0))
69                    .map(|due_date| DateTime::from_naive_utc_and_offset(due_date, Utc));
70
71                let t = Task {
72                    project: format!("{} {}", OPENPROJECT_ICON, project),
73                    title: title.to_string(),
74                    description: url,
75                    due,
76                    created,
77                    id: Some(id.to_string()),
78                };
79                Ok(Some(t))
80            } else {
81                Ok(None)
82            }
83        } else {
84            Err(anyhow!("Response is not a JSON object"))
85        }
86    }
87
88    pub fn query_tasks<S>(&self, secret: Option<S>) -> Result<Vec<Task>>
89    where
90        S: AsRef<str>,
91    {
92        let mut result = Vec::default();
93
94        let basic_auth = secret.map(|secret| format!("apikey:{}", secret.as_ref()));
95
96        // Query all statuses that count as "closed".
97        let mut request = self
98            .agent
99            .get(&format!("{}/api/v3/statuses", self.server_url));
100        if let Some(basic_auth) = &basic_auth {
101            request = request.set(
102                "Authorization",
103                &format!("Basic {}", &BASE64_STANDARD.encode(basic_auth.clone())),
104            )
105        }
106        let response = request.call()?;
107        let body = response.into_string()?;
108        let closed_statuses =
109            if let JsonValue::Array(elements) = &json::parse(&body)?["_embedded"]["elements"] {
110                elements
111                    .iter()
112                    .filter(|e| e["isClosed"].as_bool().unwrap_or(false))
113                    .filter_map(|e| e["id"].as_usize())
114                    .collect()
115            } else {
116                Vec::default()
117            };
118
119        // Get the user ID for the provided acccess token
120        let mut request = self
121            .agent
122            .get(&format!("{}/api/v3/users/me", self.server_url));
123        if let Some(basic_auth) = &basic_auth {
124            request = request.set(
125                "Authorization",
126                &format!("Basic {}", &BASE64_STANDARD.encode(basic_auth.clone())),
127            )
128        }
129        let response = request.call()?;
130        let body = response.into_string()?;
131
132        let user_id = json::parse(&body)?["id"].as_usize().unwrap_or(0);
133        // Filter by work packages that are assigned to the use and are not closed
134        let filter_param = array! [
135            {"assignee": {"operator": "=", "values": [user_id]}},
136            {"type": {"operator": "=", "values": [1]}},
137            {"status": {"operator": "!", "values": closed_statuses.clone()}}
138        ];
139
140        let mut request = self
141            .agent
142            .get(&format!("{}/api/v3/work_packages", self.server_url))
143            .query("filters", &filter_param.to_string());
144        if let Some(basic_auth) = &basic_auth {
145            request = request.set(
146                "Authorization",
147                &format!("Basic {}", &BASE64_STANDARD.encode(basic_auth.clone())),
148            )
149        }
150        let response = request.call()?;
151        let body = response.into_string()?;
152        let work_package_collection = json::parse(&body)?;
153
154        if let JsonValue::Array(elements) = &work_package_collection["_embedded"]["elements"] {
155            for e in elements {
156                if let Some(task) = self.create_task(e)? {
157                    result.push(task);
158                }
159            }
160        }
161
162        Ok(result)
163    }
164}
165
166#[cfg(test)]
167mod tests;