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