1use 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
17fn 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 _ => "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
59const 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#[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
120fn 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
134pub struct LinearSource {
136 client: reqwest::Client,
137 endpoint: reqwest::Url,
138 api_key: String,
139 mapping: Option<StateMapping>,
140}
141
142impl LinearSource {
143 pub fn new(api_key: impl Into<String>) -> Result<Self> {
146 Self::with_endpoint(ENDPOINT, api_key)
147 }
148
149 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 #[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 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
285trait 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 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 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 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}