Skip to main content

gonzalo_ticket_asana/
source.rs

1//! A read-only [`TicketSource`] backed by the Asana REST API.
2//!
3//! Authenticates with a personal access token (bearer). Phase 1 imports a
4//! single project's tasks via `fetch_changed` (offset-paginated) and `get`. An
5//! optional [`StateMapping`] selects the status signal (completed / section /
6//! custom field; see [`crate::mapping`]). `capabilities` reports
7//! `single_assignee` and `custom_fields`. HTTP-level behavior is exercised by
8//! the conformance suite's fixtures (#20).
9
10use 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
20/// Fields requested explicitly — Asana returns a minimal object otherwise.
21const 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
45/// Imports tasks from a single Asana project.
46pub 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    /// Import tasks from the Asana `project` gid, authenticating with a PAT.
56    pub fn new(project: impl Into<String>, token: impl Into<String>) -> Result<Self> {
57        Self::with_base(DEFAULT_BASE, project, token)
58    }
59
60    /// As [`AsanaSource::new`] but against a custom API base (e.g. a test
61    /// server).
62    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    /// Apply a per-connection [`StateMapping`] selecting the status signal
81    /// (completed / section / custom field).
82    #[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        // The portable Asana write is the `completed` flag: terminal categories
159        // complete the task, others reopen it.
160        let terminal = matches!(target, StateCategory::Done | StateCategory::Canceled);
161        // When a section / custom-field mapping drives the read path, the
162        // category comes from that field, not the `completed` bool. Only a
163        // terminal move is expressible via the completed flag; a non-terminal
164        // move would toggle `completed` without touching the section / custom
165        // field, so the read path would still report the field's category.
166        // Reject it rather than reporting a false success (#143). (Section /
167        // custom-field write-back is out of scope here.)
168        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}