gonzalo_ticket_asana/
source.rs1use crate::mapping::{AsanaTask, task_to_ticket};
11use async_trait::async_trait;
12use gonzalo_domain::{StateCategory, Ticket};
13use gonzalo_ticket::{
14 Capabilities, Cursor, Page, Result, SourceError, StateMapping, StateSignal, TicketSource,
15 provider_error,
16};
17use serde::Deserialize;
18
19const DEFAULT_BASE: &str = "https://app.asana.com/api/1.0";
20
21const OPT_FIELDS: &str = "name,notes,html_notes,completed,assignee.name,created_by.name,\
23 memberships.project.name,memberships.project.gid,memberships.section.name,\
24 memberships.section.gid,custom_fields.gid,custom_fields.enum_value.name,\
25 custom_fields.display_value,tags.name,permalink_url";
26
27#[derive(Debug, Deserialize)]
28struct ListResponse {
29 #[serde(default)]
30 data: Vec<AsanaTask>,
31 #[serde(default)]
32 next_page: Option<NextPage>,
33}
34
35#[derive(Debug, Deserialize)]
36struct NextPage {
37 #[serde(default)]
38 offset: Option<String>,
39}
40
41#[derive(Debug, Deserialize)]
42struct OneResponse {
43 data: AsanaTask,
44}
45
46pub struct AsanaSource {
48 client: reqwest::Client,
49 base: reqwest::Url,
50 project: String,
51 token: String,
52 mapping: Option<StateMapping>,
53}
54
55impl AsanaSource {
56 pub fn new(project: impl Into<String>, token: impl Into<String>) -> Result<Self> {
58 Self::with_base(DEFAULT_BASE, project, token)
59 }
60
61 pub fn with_base(
64 base: &str,
65 project: impl Into<String>,
66 token: impl Into<String>,
67 ) -> Result<Self> {
68 let client = reqwest::Client::builder()
69 .user_agent("gonzalo-ticket-asana")
70 .build()
71 .map_err(be)?;
72 Ok(Self {
73 client,
74 base: reqwest::Url::parse(base).map_err(be)?,
75 project: project.into(),
76 token: token.into(),
77 mapping: None,
78 })
79 }
80
81 #[must_use]
84 pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
85 self.mapping = Some(mapping);
86 self
87 }
88
89 fn url(&self, segments: &[&str]) -> Result<reqwest::Url> {
90 let mut url = self.base.clone();
91 url.path_segments_mut()
92 .map_err(|_| SourceError::Backend("base URL cannot be a base".into()))?
93 .extend(segments);
94 Ok(url)
95 }
96
97 fn auth(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
98 rb.bearer_auth(&self.token)
99 }
100}
101
102#[async_trait]
103impl TicketSource for AsanaSource {
104 fn capabilities(&self) -> Capabilities {
105 Capabilities {
106 single_assignee: true,
107 custom_fields: true,
108 push: true,
109 comments: true,
110 ..Capabilities::default()
111 }
112 }
113
114 async fn fetch_changed(&self, cursor: &Cursor) -> Result<Page> {
115 let mut url = self.url(&["tasks"])?;
116 {
117 let mut q = url.query_pairs_mut();
118 q.append_pair("project", &self.project);
119 q.append_pair("opt_fields", OPT_FIELDS);
120 q.append_pair("limit", "100");
121 if let Some(offset) = &cursor.0 {
122 q.append_pair("offset", offset);
123 }
124 }
125 let resp = self
126 .auth(self.client.get(url))
127 .send()
128 .await
129 .map_err(be)?
130 .check_status()
131 .await?;
132 let list: ListResponse = resp.json().await.map_err(be)?;
133 let tickets = list
134 .data
135 .iter()
136 .map(|t| task_to_ticket(t, self.mapping.as_ref()))
137 .collect();
138 Ok(Page {
139 tickets,
140 next: Cursor(list.next_page.and_then(|p| p.offset)),
141 })
142 }
143
144 async fn get(&self, uid: &str) -> Result<Ticket> {
145 let mut url = self.url(&["tasks", uid])?;
146 url.query_pairs_mut().append_pair("opt_fields", OPT_FIELDS);
147 let resp = self
148 .auth(self.client.get(url))
149 .send()
150 .await
151 .map_err(be)?
152 .check_status()
153 .await?;
154 let one: OneResponse = resp.json().await.map_err(be)?;
155 Ok(task_to_ticket(&one.data, self.mapping.as_ref()))
156 }
157
158 async fn set_state(&self, uid: &str, target: StateCategory) -> Result<()> {
159 let terminal = matches!(target, StateCategory::Done | StateCategory::Canceled);
162 if !terminal
170 && matches!(
171 self.mapping.as_ref().map(|m| &m.signal),
172 Some(StateSignal::Section | StateSignal::CustomField { .. })
173 )
174 {
175 return Err(SourceError::Unsupported(
176 "non-terminal set_state under a section/custom-field mapping (section/custom-field write-back not implemented)",
177 ));
178 }
179 let completed = terminal;
180 let url = self.url(&["tasks", uid])?;
181 self.auth(
182 self.client
183 .put(url)
184 .json(&serde_json::json!({ "data": { "completed": completed } })),
185 )
186 .send()
187 .await
188 .map_err(be)?
189 .check_status()
190 .await?;
191 Ok(())
192 }
193
194 async fn comment(&self, uid: &str, body: &str) -> Result<()> {
195 let url = self.url(&["tasks", uid, "stories"])?;
196 self.auth(
197 self.client
198 .post(url)
199 .json(&serde_json::json!({ "data": { "text": body } })),
200 )
201 .send()
202 .await
203 .map_err(be)?
204 .check_status()
205 .await?;
206 Ok(())
207 }
208}
209
210fn be<E: std::fmt::Display>(e: E) -> SourceError {
211 SourceError::Backend(e.to_string())
212}
213
214trait CheckStatus: Sized {
223 async fn check_status(self) -> Result<Self>;
224}
225
226impl CheckStatus for reqwest::Response {
227 async fn check_status(self) -> Result<Self> {
228 let status = self.status();
229 if status.is_success() {
230 return Ok(self);
231 }
232 let body = self.text().await.unwrap_or_default();
234 Err(provider_error(status.as_u16(), &body))
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn builds_task_urls() {
244 let src = AsanaSource::new("1201", "tok").unwrap();
245 assert_eq!(
246 src.url(&["tasks"]).unwrap().as_str(),
247 "https://app.asana.com/api/1.0/tasks"
248 );
249 assert_eq!(
250 src.url(&["tasks", "1201"]).unwrap().as_str(),
251 "https://app.asana.com/api/1.0/tasks/1201"
252 );
253 }
254
255 #[test]
256 fn capabilities_reflect_asana_shape() {
257 let caps = AsanaSource::new("1201", "tok").unwrap().capabilities();
258 assert!(caps.single_assignee);
259 assert!(caps.custom_fields);
260 assert!(caps.push);
261 assert!(caps.comments);
262 }
263}