Skip to main content

gonzalo_ticket_linear/
source.rs

1//! A read-only [`TicketSource`] backed by the Linear GraphQL API.
2//!
3//! Authenticates with a Linear API key in the `Authorization` header. Phase 1
4//! imports via `fetch_changed` (cursor-paginated `issues` query) and `get`.
5//! HTTP-level behavior is exercised by the conformance suite's fixtures (#20);
6//! the mapping is unit-tested in [`crate::mapping`].
7
8use crate::mapping::{LinearIssue, issue_to_ticket};
9use async_trait::async_trait;
10use gonzalo_domain::{StateCategory, Ticket};
11use gonzalo_ticket::{Capabilities, Cursor, Page, Result, SourceError, StateMapping, TicketSource};
12use serde::Deserialize;
13use serde::de::DeserializeOwned;
14
15/// The Linear workflow-state `type` a target [`StateCategory`] maps onto.
16fn target_state_type(target: StateCategory) -> &'static str {
17    match target {
18        StateCategory::Triage => "triage",
19        StateCategory::Backlog => "backlog",
20        StateCategory::InProgress | StateCategory::Pending => "started",
21        StateCategory::Done => "completed",
22        StateCategory::Canceled => "canceled",
23        // Open
24        _ => "unstarted",
25    }
26}
27
28#[derive(Debug, Deserialize)]
29struct IssueTeamData {
30    issue: Option<IssueTeam>,
31}
32
33#[derive(Debug, Deserialize)]
34struct IssueTeam {
35    team: Team,
36}
37
38#[derive(Debug, Deserialize)]
39struct Team {
40    states: StateNodes,
41}
42
43#[derive(Debug, Deserialize)]
44struct StateNodes {
45    nodes: Vec<WorkflowState>,
46}
47
48#[derive(Debug, Deserialize)]
49struct WorkflowState {
50    id: String,
51    #[serde(rename = "type")]
52    type_: String,
53}
54
55const ENDPOINT: &str = "https://api.linear.app/graphql";
56
57/// The issue fields selected by both queries.
58const ISSUE_FIELDS: &str = "id identifier title description priority \
59    state { name type } assignee { displayName } creator { displayName } \
60    labels { nodes { name } } team { key name } project { name }";
61
62#[derive(Debug, Deserialize)]
63struct GqlResponse<T> {
64    #[serde(default = "Option::default")]
65    data: Option<T>,
66    #[serde(default)]
67    errors: Vec<GqlError>,
68}
69
70#[derive(Debug, Deserialize)]
71struct GqlError {
72    message: String,
73}
74
75#[derive(Debug, Deserialize)]
76struct IssuesData {
77    issues: IssueConnection,
78}
79
80#[derive(Debug, Deserialize)]
81struct IssueConnection {
82    #[serde(rename = "pageInfo")]
83    page_info: PageInfo,
84    nodes: Vec<LinearIssue>,
85}
86
87#[derive(Debug, Deserialize)]
88struct PageInfo {
89    #[serde(rename = "hasNextPage")]
90    has_next_page: bool,
91    #[serde(rename = "endCursor")]
92    end_cursor: Option<String>,
93}
94
95#[derive(Debug, Deserialize)]
96struct IssueData {
97    issue: Option<LinearIssue>,
98}
99
100/// The `{ success }` payload every Linear mutation returns.
101#[derive(Debug, Deserialize)]
102struct SuccessFlag {
103    success: bool,
104}
105
106#[derive(Debug, Deserialize)]
107struct IssueUpdateResp {
108    #[serde(rename = "issueUpdate")]
109    issue_update: SuccessFlag,
110}
111
112#[derive(Debug, Deserialize)]
113struct CommentCreateResp {
114    #[serde(rename = "commentCreate")]
115    comment_create: SuccessFlag,
116}
117
118/// Linear returns HTTP 200 with `success: false` (and no `errors` array) for
119/// writes it silently declines — an invalid `stateId`, insufficient
120/// permissions, etc. Turn that into an error so a failed write never looks like
121/// a successful one.
122fn check_success(success: bool, op: &str) -> Result<()> {
123    if success {
124        Ok(())
125    } else {
126        Err(SourceError::Backend(format!(
127            "linear {op} reported success: false"
128        )))
129    }
130}
131
132/// Imports issues from a Linear workspace.
133pub struct LinearSource {
134    client: reqwest::Client,
135    endpoint: reqwest::Url,
136    api_key: String,
137    mapping: Option<StateMapping>,
138}
139
140impl LinearSource {
141    /// Connect with a Linear API key (sent verbatim in the `Authorization`
142    /// header, per Linear's personal-API-key scheme).
143    pub fn new(api_key: impl Into<String>) -> Result<Self> {
144        Self::with_endpoint(ENDPOINT, api_key)
145    }
146
147    /// Connect against a custom GraphQL endpoint (e.g. a test server).
148    pub fn with_endpoint(endpoint: &str, api_key: impl Into<String>) -> Result<Self> {
149        let client = reqwest::Client::builder()
150            .user_agent("gonzalo-ticket-linear")
151            .build()
152            .map_err(be)?;
153        Ok(Self {
154            client,
155            endpoint: reqwest::Url::parse(endpoint).map_err(be)?,
156            api_key: api_key.into(),
157            mapping: None,
158        })
159    }
160
161    /// Apply a per-connection [`StateMapping`] for state-name → category
162    /// overrides (falls back to Linear's state `type`).
163    #[must_use]
164    pub fn with_mapping(mut self, mapping: StateMapping) -> Self {
165        self.mapping = Some(mapping);
166        self
167    }
168
169    async fn query<T: DeserializeOwned>(
170        &self,
171        query: &str,
172        variables: serde_json::Value,
173    ) -> Result<T> {
174        let body = serde_json::json!({ "query": query, "variables": variables });
175        let resp = self
176            .client
177            .post(self.endpoint.clone())
178            .header(reqwest::header::AUTHORIZATION, &self.api_key)
179            .json(&body)
180            .send()
181            .await
182            .map_err(be)?
183            .error_for_status()
184            .map_err(be)?;
185        let gql: GqlResponse<T> = resp.json().await.map_err(be)?;
186        if !gql.errors.is_empty() {
187            let msg = gql
188                .errors
189                .iter()
190                .map(|e| e.message.as_str())
191                .collect::<Vec<_>>()
192                .join("; ");
193            return Err(SourceError::Backend(format!("linear graphql: {msg}")));
194        }
195        gql.data
196            .ok_or_else(|| SourceError::Backend("linear graphql: empty data".into()))
197    }
198}
199
200#[async_trait]
201impl TicketSource for LinearSource {
202    fn capabilities(&self) -> Capabilities {
203        Capabilities {
204            push: true,
205            comments: true,
206            ..Capabilities::default()
207        }
208    }
209
210    async fn fetch_changed(&self, cursor: &Cursor) -> Result<Page> {
211        let query = format!(
212            "query($after: String) {{ issues(first: 100, after: $after) {{ \
213             pageInfo {{ hasNextPage endCursor }} nodes {{ {ISSUE_FIELDS} }} }} }}"
214        );
215        let data: IssuesData = self
216            .query(&query, serde_json::json!({ "after": cursor.0 }))
217            .await?;
218        let tickets = data
219            .issues
220            .nodes
221            .iter()
222            .map(|i| issue_to_ticket(i, self.mapping.as_ref()))
223            .collect();
224        let next = if data.issues.page_info.has_next_page {
225            Cursor(data.issues.page_info.end_cursor)
226        } else {
227            Cursor::default()
228        };
229        Ok(Page { tickets, next })
230    }
231
232    async fn get(&self, uid: &str) -> Result<Ticket> {
233        let query = format!("query($id: String!) {{ issue(id: $id) {{ {ISSUE_FIELDS} }} }}");
234        let data: IssueData = self.query(&query, serde_json::json!({ "id": uid })).await?;
235        let issue = data
236            .issue
237            .ok_or_else(|| SourceError::Backend(format!("no linear issue {uid}")))?;
238        Ok(issue_to_ticket(&issue, self.mapping.as_ref()))
239    }
240
241    async fn set_state(&self, uid: &str, target: StateCategory) -> Result<()> {
242        // Linear states are workspace-defined; resolve a state of the issue's
243        // team whose `type` matches the target, then update the issue to it.
244        let states_query =
245            "query($id: String!) { issue(id: $id) { team { states { nodes { id type } } } } }";
246        let data: IssueTeamData = self
247            .query(states_query, serde_json::json!({ "id": uid }))
248            .await?;
249        let team = data
250            .issue
251            .ok_or_else(|| SourceError::Backend(format!("no linear issue {uid}")))?
252            .team;
253        let want = target_state_type(target);
254        let state = team
255            .states
256            .nodes
257            .iter()
258            .find(|s| s.type_ == want)
259            .ok_or_else(|| {
260                SourceError::Backend(format!("team has no workflow state of type '{want}'"))
261            })?;
262
263        let mutation = "mutation($id: String!, $sid: String!) { issueUpdate(id: $id, input: { stateId: $sid }) { success } }";
264        let resp: IssueUpdateResp = self
265            .query(mutation, serde_json::json!({ "id": uid, "sid": state.id }))
266            .await?;
267        check_success(resp.issue_update.success, "issueUpdate")
268    }
269
270    async fn comment(&self, uid: &str, body: &str) -> Result<()> {
271        let mutation = "mutation($id: String!, $body: String!) { commentCreate(input: { issueId: $id, body: $body }) { success } }";
272        let resp: CommentCreateResp = self
273            .query(mutation, serde_json::json!({ "id": uid, "body": body }))
274            .await?;
275        check_success(resp.comment_create.success, "commentCreate")
276    }
277}
278
279fn be<E: std::fmt::Display>(e: E) -> SourceError {
280    SourceError::Backend(e.to_string())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn endpoint_and_write_capabilities() {
289        let src = LinearSource::new("lin_api_xxx").unwrap();
290        assert!(src.capabilities().push);
291        assert!(src.capabilities().comments);
292        assert_eq!(src.endpoint.as_str(), "https://api.linear.app/graphql");
293    }
294
295    #[test]
296    fn parses_graphql_errors_envelope() {
297        // Round-trips the error envelope shape the connector surfaces.
298        let resp: GqlResponse<IssuesData> =
299            serde_json::from_str(r#"{"errors":[{"message":"bad"}]}"#).unwrap();
300        assert!(resp.data.is_none());
301        assert_eq!(resp.errors[0].message, "bad");
302    }
303
304    #[test]
305    fn issue_update_success_false_is_deserialized() {
306        // Linear returns HTTP 200 with success:false and no `errors` array.
307        let resp: GqlResponse<IssueUpdateResp> =
308            serde_json::from_str(r#"{"data":{"issueUpdate":{"success":false}}}"#).unwrap();
309        assert!(!resp.data.unwrap().issue_update.success);
310    }
311
312    #[test]
313    fn comment_create_success_is_deserialized() {
314        let resp: GqlResponse<CommentCreateResp> =
315            serde_json::from_str(r#"{"data":{"commentCreate":{"success":true}}}"#).unwrap();
316        assert!(resp.data.unwrap().comment_create.success);
317    }
318
319    #[test]
320    fn check_success_maps_false_to_backend_error() {
321        let err = check_success(false, "issueUpdate").unwrap_err();
322        match err {
323            SourceError::Backend(msg) => {
324                assert!(msg.contains("issueUpdate"), "message was: {msg}");
325                assert!(msg.contains("success: false"), "message was: {msg}");
326            }
327            other => panic!("expected Backend error, got {other:?}"),
328        }
329    }
330
331    #[test]
332    fn check_success_passes_true() {
333        assert!(check_success(true, "commentCreate").is_ok());
334    }
335}