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