1use serde_json::{json, Value};
9
10use super::client::GraphQl;
11use super::model::{Issue, Nodes, Team};
12use crate::error::{Error, Result};
13
14pub const ISSUE_FIELDS: &str = r#"
16 id
17 identifier
18 title
19 description
20 url
21 updatedAt
22 priority
23 dueDate
24 state { id name type position }
25 team { id key name }
26 labels { nodes { name } }
27"#;
28
29pub async fn issue_by_key<G: GraphQl>(api: &G, team_key: &str, number: i64) -> Result<Issue> {
35 let doc = format!(
36 r#"query IssueByKey($team: String!, $number: Float!) {{
37 issues(filter: {{ team: {{ key: {{ eq: $team }} }}, number: {{ eq: $number }} }}, first: 1) {{
38 nodes {{ {ISSUE_FIELDS} }}
39 }}
40 }}"#
41 );
42 let data = api
43 .query(&doc, json!({ "team": team_key, "number": number as f64 }))
44 .await?;
45 let nodes: Nodes<Issue> = take(data, "issues")?;
46 nodes
47 .first()
48 .ok_or_else(|| Error::NotFound(format!("Linear issue {team_key}-{number}")))
49}
50
51pub async fn issue_by_id<G: GraphQl>(api: &G, id: &str) -> Result<Issue> {
54 let doc = format!(
55 r#"query IssueById($id: String!) {{
56 issue(id: $id) {{ {ISSUE_FIELDS} }}
57 }}"#
58 );
59 let data = api.query(&doc, json!({ "id": id })).await?;
60 let issue = data.get("issue").cloned().unwrap_or(Value::Null);
61 if issue.is_null() {
62 return Err(Error::NotFound(format!("Linear issue {id}")));
63 }
64 Ok(serde_json::from_value(issue)?)
65}
66
67pub async fn team_by_key<G: GraphQl>(api: &G, key: &str) -> Result<Team> {
69 let doc = r#"query TeamByKey($key: String!) {
70 teams(filter: { key: { eq: $key } }, first: 1) {
71 nodes {
72 id
73 key
74 name
75 states { nodes { id name type position } }
76 }
77 }
78 }"#;
79 let data = api.query(doc, json!({ "key": key })).await?;
80 let nodes: Nodes<Team> = take(data, "teams")?;
81 nodes
82 .first()
83 .ok_or_else(|| Error::NotFound(format!("Linear team {key}")))
84}
85
86#[derive(Debug, Clone)]
88pub struct NewIssue {
89 pub team_id: String,
90 pub title: String,
91 pub description: String,
92 pub state_id: Option<String>,
93 pub priority: f64,
94}
95
96pub async fn create_issue<G: GraphQl>(api: &G, new: NewIssue) -> Result<Issue> {
97 let doc = format!(
98 r#"mutation IssueCreate($input: IssueCreateInput!) {{
99 issueCreate(input: $input) {{
100 success
101 issue {{ {ISSUE_FIELDS} }}
102 }}
103 }}"#
104 );
105 let mut input = json!({
106 "teamId": new.team_id,
107 "title": new.title,
108 "description": new.description,
109 "priority": new.priority,
110 });
111 if let Some(state_id) = new.state_id {
112 input["stateId"] = json!(state_id);
113 }
114 let data = api.query(&doc, json!({ "input": input })).await?;
115 payload_issue(data, "issueCreate")
116}
117
118#[derive(Debug, Clone, Default)]
121pub struct IssuePatch {
122 pub state_id: Option<String>,
123 pub description: Option<String>,
124}
125
126impl IssuePatch {
127 pub fn is_empty(&self) -> bool {
128 self.state_id.is_none() && self.description.is_none()
129 }
130}
131
132pub async fn update_issue<G: GraphQl>(api: &G, id: &str, patch: IssuePatch) -> Result<Issue> {
133 let doc = format!(
134 r#"mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {{
135 issueUpdate(id: $id, input: $input) {{
136 success
137 issue {{ {ISSUE_FIELDS} }}
138 }}
139 }}"#
140 );
141 let mut input = json!({});
142 if let Some(state_id) = patch.state_id {
143 input["stateId"] = json!(state_id);
144 }
145 if let Some(description) = patch.description {
146 input["description"] = json!(description);
147 }
148 let data = api.query(&doc, json!({ "id": id, "input": input })).await?;
149 payload_issue(data, "issueUpdate")
150}
151
152pub async fn create_comment<G: GraphQl>(api: &G, issue_id: &str, body: &str) -> Result<()> {
154 let doc = r#"mutation CommentCreate($input: CommentCreateInput!) {
155 commentCreate(input: $input) { success }
156 }"#;
157 let data = api
158 .query(
159 doc,
160 json!({ "input": { "issueId": issue_id, "body": body } }),
161 )
162 .await?;
163 require_success(&data, "commentCreate")
164}
165
166pub fn parse_issue_key(key: &str) -> Result<(String, i64)> {
171 let trimmed = key.trim();
172 let (team, number) = trimmed
173 .rsplit_once('-')
174 .ok_or_else(|| Error::NotFound(format!("{trimmed:?} is not a Linear issue key")))?;
175 let number: i64 = number.parse().map_err(|_| {
176 Error::NotFound(format!(
177 "{trimmed:?} is not a Linear issue key (expected TEAM-123)"
178 ))
179 })?;
180 if team.is_empty() {
181 return Err(Error::NotFound(format!(
182 "{trimmed:?} is not a Linear issue key (no team)"
183 )));
184 }
185 Ok((team.to_uppercase(), number))
186}
187
188fn take<T: serde::de::DeserializeOwned>(data: Value, field: &str) -> Result<T> {
191 let raw = data
192 .get(field)
193 .cloned()
194 .ok_or_else(|| Error::Unexpected(format!("response had no `{field}` field")))?;
195 Ok(serde_json::from_value(raw)?)
196}
197
198fn payload_issue(data: Value, field: &str) -> Result<Issue> {
201 require_success(&data, field)?;
202 let raw = data
203 .get(field)
204 .and_then(|p| p.get("issue"))
205 .cloned()
206 .unwrap_or(Value::Null);
207 if raw.is_null() {
208 return Err(Error::Unexpected(format!(
209 "{field} reported success but returned no issue"
210 )));
211 }
212 Ok(serde_json::from_value(raw)?)
213}
214
215fn require_success(data: &Value, field: &str) -> Result<()> {
216 let payload = data
217 .get(field)
218 .ok_or_else(|| Error::Unexpected(format!("response had no `{field}` field")))?;
219 match payload.get("success").and_then(Value::as_bool) {
220 Some(true) => Ok(()),
221 Some(false) => Err(Error::Api(vec![format!("{field} reported success: false")])),
222 None => Err(Error::Unexpected(format!(
223 "{field} payload had no `success` field"
224 ))),
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use std::cell::RefCell;
232
233 struct Fake {
235 replies: RefCell<Vec<Value>>,
236 seen: RefCell<Vec<(String, Value)>>,
237 }
238
239 impl Fake {
240 fn new(replies: Vec<Value>) -> Self {
241 Self {
242 replies: RefCell::new(replies),
243 seen: RefCell::new(Vec::new()),
244 }
245 }
246 fn last_vars(&self) -> Value {
247 self.seen.borrow().last().unwrap().1.clone()
248 }
249 fn last_doc(&self) -> String {
250 self.seen.borrow().last().unwrap().0.clone()
251 }
252 }
253
254 impl GraphQl for Fake {
255 async fn query(&self, doc: &str, vars: Value) -> Result<Value> {
256 self.seen.borrow_mut().push((doc.to_string(), vars));
257 let mut replies = self.replies.borrow_mut();
258 if replies.is_empty() {
259 return Err(Error::Unexpected("fake ran out of replies".into()));
260 }
261 Ok(replies.remove(0))
262 }
263 }
264
265 fn issue_json() -> Value {
266 json!({
267 "id": "uuid-1",
268 "identifier": "ENG-412",
269 "title": "Fix the thing",
270 "description": "spec",
271 "url": "https://linear.app/acme/issue/ENG-412",
272 "updatedAt": "2026-07-29T12:00:00.000Z",
273 "priority": 2,
274 "dueDate": null,
275 "state": {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
276 "team": {"id": "t1", "key": "ENG", "name": "Engineering"},
277 "labels": {"nodes": [{"name": "bug"}]}
278 })
279 }
280
281 #[test]
282 fn parse_issue_key_splits_on_the_last_hyphen() {
283 assert_eq!(parse_issue_key("ENG-412").unwrap(), ("ENG".into(), 412));
284 assert_eq!(parse_issue_key("A-B-12").unwrap(), ("A-B".into(), 12));
286 assert_eq!(parse_issue_key(" eng-7 ").unwrap(), ("ENG".into(), 7));
287 }
288
289 #[test]
290 fn parse_issue_key_rejects_things_that_are_not_keys() {
291 for bad in ["ENG", "ENG-", "-12", "ENG-abc", ""] {
292 assert!(parse_issue_key(bad).is_err(), "{bad:?} should not parse");
293 }
294 }
295
296 #[tokio::test]
297 async fn issue_by_key_sends_team_and_number_and_parses_the_node() {
298 let fake = Fake::new(vec![json!({"issues": {"nodes": [issue_json()]}})]);
299 let issue = issue_by_key(&fake, "ENG", 412).await.unwrap();
300 assert_eq!(issue.identifier, "ENG-412");
301 assert_eq!(issue.label_names(), vec!["bug"]);
302 let vars = fake.last_vars();
303 assert_eq!(vars["team"], "ENG");
304 assert_eq!(vars["number"], 412.0);
305 }
306
307 #[tokio::test]
308 async fn issue_by_key_reports_not_found_for_an_empty_node_list() {
309 let fake = Fake::new(vec![json!({"issues": {"nodes": []}})]);
310 let err = issue_by_key(&fake, "ENG", 999).await.unwrap_err();
311 assert!(err.to_string().contains("ENG-999"), "{err}");
312 }
313
314 #[tokio::test]
315 async fn issue_by_id_reports_not_found_for_a_null_issue() {
316 let fake = Fake::new(vec![json!({"issue": null})]);
317 let err = issue_by_id(&fake, "uuid-nope").await.unwrap_err();
318 assert!(err.to_string().contains("uuid-nope"), "{err}");
319 }
320
321 #[tokio::test]
322 async fn team_by_key_parses_the_state_list() {
323 let fake = Fake::new(vec![json!({"teams": {"nodes": [{
324 "id": "t1", "key": "ENG", "name": "Engineering",
325 "states": {"nodes": [
326 {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
327 {"id": "s2", "name": "Done", "type": "completed", "position": 2.0}
328 ]}
329 }]}})]);
330 let team = team_by_key(&fake, "ENG").await.unwrap();
331 assert_eq!(team.states.nodes.len(), 2);
332 assert_eq!(team.states.nodes[1].name, "Done");
333 }
334
335 #[tokio::test]
336 async fn update_issue_omits_fields_the_caller_left_unset() {
337 let fake = Fake::new(vec![
338 json!({"issueUpdate": {"success": true, "issue": issue_json()}}),
339 ]);
340 let patch = IssuePatch {
341 state_id: Some("s2".into()),
342 description: None,
343 };
344 update_issue(&fake, "uuid-1", patch).await.unwrap();
345 let input = &fake.last_vars()["input"];
346 assert_eq!(input["stateId"], "s2");
347 assert!(
348 input.get("description").is_none(),
349 "an unset field must not be sent: {input}"
350 );
351 }
352
353 #[tokio::test]
354 async fn create_issue_sends_the_state_only_when_given() {
355 let fake = Fake::new(vec![
356 json!({"issueCreate": {"success": true, "issue": issue_json()}}),
357 ]);
358 create_issue(
359 &fake,
360 NewIssue {
361 team_id: "t1".into(),
362 title: "T".into(),
363 description: "D".into(),
364 state_id: None,
365 priority: 2.0,
366 },
367 )
368 .await
369 .unwrap();
370 let input = &fake.last_vars()["input"];
371 assert_eq!(input["teamId"], "t1");
372 assert!(input.get("stateId").is_none());
373 }
374
375 #[tokio::test]
376 async fn a_mutation_reporting_success_false_is_an_error() {
377 let fake = Fake::new(vec![
378 json!({"issueUpdate": {"success": false, "issue": null}}),
379 ]);
380 let err = update_issue(&fake, "uuid-1", IssuePatch::default())
381 .await
382 .unwrap_err();
383 assert!(err.to_string().contains("success: false"), "{err}");
384 }
385
386 #[tokio::test]
387 async fn success_true_with_no_issue_is_reported_rather_than_parsed_as_garbage() {
388 let fake = Fake::new(vec![
389 json!({"issueCreate": {"success": true, "issue": null}}),
390 ]);
391 let err = create_issue(
392 &fake,
393 NewIssue {
394 team_id: "t1".into(),
395 title: "T".into(),
396 description: "D".into(),
397 state_id: None,
398 priority: 0.0,
399 },
400 )
401 .await
402 .unwrap_err();
403 assert!(err.to_string().contains("returned no issue"), "{err}");
404 }
405
406 #[tokio::test]
407 async fn create_comment_checks_success() {
408 let fake = Fake::new(vec![json!({"commentCreate": {"success": true}})]);
409 create_comment(&fake, "uuid-1", "hello").await.unwrap();
410 assert_eq!(fake.last_vars()["input"]["issueId"], "uuid-1");
411 assert_eq!(fake.last_vars()["input"]["body"], "hello");
412 assert!(fake.last_doc().contains("commentCreate"));
413 }
414
415 #[test]
416 fn issue_fields_selects_everything_the_model_requires() {
417 for field in [
420 "id",
421 "identifier",
422 "title",
423 "description",
424 "url",
425 "updatedAt",
426 "priority",
427 "dueDate",
428 "state {",
429 "team {",
430 "labels {",
431 ] {
432 assert!(
433 ISSUE_FIELDS.contains(field),
434 "{field} missing from ISSUE_FIELDS"
435 );
436 }
437 }
438}