task_picker/sources/
openproject.rs1use 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 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 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 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;