1use 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
15fn 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 _ => "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
57const 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#[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
118fn 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
132pub struct LinearSource {
134 client: reqwest::Client,
135 endpoint: reqwest::Url,
136 api_key: String,
137 mapping: Option<StateMapping>,
138}
139
140impl LinearSource {
141 pub fn new(api_key: impl Into<String>) -> Result<Self> {
144 Self::with_endpoint(ENDPOINT, api_key)
145 }
146
147 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 #[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 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 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 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}