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)]
89pub struct AssignedIssue {
90 pub issue: Issue,
91 pub cycle_id: Option<String>,
93 pub team_active_cycle_id: Option<String>,
96}
97
98impl AssignedIssue {
99 pub fn in_active_scope(&self) -> bool {
103 match &self.team_active_cycle_id {
104 None => true,
105 Some(active) => self.cycle_id.as_deref() == Some(active.as_str()),
106 }
107 }
108}
109
110pub async fn assigned_to_viewer<G: GraphQl>(api: &G) -> Result<Vec<AssignedIssue>> {
118 let doc = format!(
119 r#"query ViewerAssignedIssues {{
120 viewer {{
121 assignedIssues(
122 filter: {{ state: {{ type: {{ nin: ["completed", "canceled"] }} }} }},
123 first: 100
124 ) {{
125 nodes {{ {ISSUE_FIELDS} cycle {{ id }} team {{ activeCycle {{ id }} }} }}
126 }}
127 }}
128 }}"#
129 );
130 let data = api.query(&doc, json!({})).await?;
131 let nodes = data
132 .pointer("/viewer/assignedIssues/nodes")
133 .and_then(Value::as_array)
134 .cloned()
135 .ok_or_else(|| {
136 Error::Unexpected("response had no `viewer.assignedIssues.nodes` field".into())
137 })?;
138 nodes.into_iter().map(parse_assigned).collect()
139}
140
141fn parse_assigned(node: Value) -> Result<AssignedIssue> {
144 let cycle_id = node
145 .pointer("/cycle/id")
146 .and_then(Value::as_str)
147 .map(str::to_string);
148 let team_active_cycle_id = node
149 .pointer("/team/activeCycle/id")
150 .and_then(Value::as_str)
151 .map(str::to_string);
152 let issue: Issue = serde_json::from_value(node)?;
153 Ok(AssignedIssue {
154 issue,
155 cycle_id,
156 team_active_cycle_id,
157 })
158}
159
160#[derive(Debug, Clone)]
162pub struct NewIssue {
163 pub team_id: String,
164 pub title: String,
165 pub description: String,
166 pub state_id: Option<String>,
167 pub priority: f64,
168}
169
170pub async fn create_issue<G: GraphQl>(api: &G, new: NewIssue) -> Result<Issue> {
171 let doc = format!(
172 r#"mutation IssueCreate($input: IssueCreateInput!) {{
173 issueCreate(input: $input) {{
174 success
175 issue {{ {ISSUE_FIELDS} }}
176 }}
177 }}"#
178 );
179 let mut input = json!({
180 "teamId": new.team_id,
181 "title": new.title,
182 "description": new.description,
183 "priority": new.priority,
184 });
185 if let Some(state_id) = new.state_id {
186 input["stateId"] = json!(state_id);
187 }
188 let data = api.query(&doc, json!({ "input": input })).await?;
189 payload_issue(data, "issueCreate")
190}
191
192#[derive(Debug, Clone, Default)]
195pub struct IssuePatch {
196 pub state_id: Option<String>,
197 pub description: Option<String>,
198}
199
200impl IssuePatch {
201 pub fn is_empty(&self) -> bool {
202 self.state_id.is_none() && self.description.is_none()
203 }
204}
205
206pub async fn update_issue<G: GraphQl>(api: &G, id: &str, patch: IssuePatch) -> Result<Issue> {
207 let doc = format!(
208 r#"mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {{
209 issueUpdate(id: $id, input: $input) {{
210 success
211 issue {{ {ISSUE_FIELDS} }}
212 }}
213 }}"#
214 );
215 let mut input = json!({});
216 if let Some(state_id) = patch.state_id {
217 input["stateId"] = json!(state_id);
218 }
219 if let Some(description) = patch.description {
220 input["description"] = json!(description);
221 }
222 let data = api.query(&doc, json!({ "id": id, "input": input })).await?;
223 payload_issue(data, "issueUpdate")
224}
225
226pub async fn create_comment<G: GraphQl>(api: &G, issue_id: &str, body: &str) -> Result<String> {
229 let doc = r#"mutation CommentCreate($input: CommentCreateInput!) {
230 commentCreate(input: $input) { success comment { id } }
231 }"#;
232 let data = api
233 .query(
234 doc,
235 json!({ "input": { "issueId": issue_id, "body": body } }),
236 )
237 .await?;
238 require_success(&data, "commentCreate")?;
239 data.get("commentCreate")
240 .and_then(|p| p.get("comment"))
241 .and_then(|c| c.get("id"))
242 .and_then(Value::as_str)
243 .map(str::to_string)
244 .ok_or_else(|| {
245 Error::Unexpected("commentCreate reported success but returned no comment id".into())
246 })
247}
248
249pub async fn update_comment<G: GraphQl>(api: &G, comment_id: &str, body: &str) -> Result<()> {
255 let doc = r#"mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) {
256 commentUpdate(id: $id, input: $input) { success }
257 }"#;
258 let data = api
259 .query(doc, json!({ "id": comment_id, "input": { "body": body } }))
260 .await?;
261 require_success(&data, "commentUpdate")
262}
263
264pub fn comment_not_found(err: &Error) -> bool {
268 match err {
269 Error::NotFound(_) => true,
270 Error::Api(messages) => messages
271 .iter()
272 .any(|m| m.to_ascii_lowercase().contains("not found")),
273 _ => false,
274 }
275}
276
277pub fn parse_issue_key(key: &str) -> Result<(String, i64)> {
282 let trimmed = key.trim();
283 let (team, number) = trimmed
284 .rsplit_once('-')
285 .ok_or_else(|| Error::NotFound(format!("{trimmed:?} is not a Linear issue key")))?;
286 let number: i64 = number.parse().map_err(|_| {
287 Error::NotFound(format!(
288 "{trimmed:?} is not a Linear issue key (expected TEAM-123)"
289 ))
290 })?;
291 if team.is_empty() {
292 return Err(Error::NotFound(format!(
293 "{trimmed:?} is not a Linear issue key (no team)"
294 )));
295 }
296 Ok((team.to_uppercase(), number))
297}
298
299fn take<T: serde::de::DeserializeOwned>(data: Value, field: &str) -> Result<T> {
302 let raw = data
303 .get(field)
304 .cloned()
305 .ok_or_else(|| Error::Unexpected(format!("response had no `{field}` field")))?;
306 Ok(serde_json::from_value(raw)?)
307}
308
309fn payload_issue(data: Value, field: &str) -> Result<Issue> {
312 require_success(&data, field)?;
313 let raw = data
314 .get(field)
315 .and_then(|p| p.get("issue"))
316 .cloned()
317 .unwrap_or(Value::Null);
318 if raw.is_null() {
319 return Err(Error::Unexpected(format!(
320 "{field} reported success but returned no issue"
321 )));
322 }
323 Ok(serde_json::from_value(raw)?)
324}
325
326fn require_success(data: &Value, field: &str) -> Result<()> {
327 let payload = data
328 .get(field)
329 .ok_or_else(|| Error::Unexpected(format!("response had no `{field}` field")))?;
330 match payload.get("success").and_then(Value::as_bool) {
331 Some(true) => Ok(()),
332 Some(false) => Err(Error::Api(vec![format!("{field} reported success: false")])),
333 None => Err(Error::Unexpected(format!(
334 "{field} payload had no `success` field"
335 ))),
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use std::cell::RefCell;
343
344 struct Fake {
346 replies: RefCell<Vec<Value>>,
347 seen: RefCell<Vec<(String, Value)>>,
348 }
349
350 impl Fake {
351 fn new(replies: Vec<Value>) -> Self {
352 Self {
353 replies: RefCell::new(replies),
354 seen: RefCell::new(Vec::new()),
355 }
356 }
357 fn last_vars(&self) -> Value {
358 self.seen.borrow().last().unwrap().1.clone()
359 }
360 fn last_doc(&self) -> String {
361 self.seen.borrow().last().unwrap().0.clone()
362 }
363 }
364
365 impl GraphQl for Fake {
366 async fn query(&self, doc: &str, vars: Value) -> Result<Value> {
367 self.seen.borrow_mut().push((doc.to_string(), vars));
368 let mut replies = self.replies.borrow_mut();
369 if replies.is_empty() {
370 return Err(Error::Unexpected("fake ran out of replies".into()));
371 }
372 Ok(replies.remove(0))
373 }
374 }
375
376 fn issue_json() -> Value {
377 json!({
378 "id": "uuid-1",
379 "identifier": "ENG-412",
380 "title": "Fix the thing",
381 "description": "spec",
382 "url": "https://linear.app/acme/issue/ENG-412",
383 "updatedAt": "2026-07-29T12:00:00.000Z",
384 "priority": 2,
385 "dueDate": null,
386 "state": {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
387 "team": {"id": "t1", "key": "ENG", "name": "Engineering"},
388 "labels": {"nodes": [{"name": "bug"}]}
389 })
390 }
391
392 #[test]
393 fn parse_issue_key_splits_on_the_last_hyphen() {
394 assert_eq!(parse_issue_key("ENG-412").unwrap(), ("ENG".into(), 412));
395 assert_eq!(parse_issue_key("A-B-12").unwrap(), ("A-B".into(), 12));
397 assert_eq!(parse_issue_key(" eng-7 ").unwrap(), ("ENG".into(), 7));
398 }
399
400 #[test]
401 fn parse_issue_key_rejects_things_that_are_not_keys() {
402 for bad in ["ENG", "ENG-", "-12", "ENG-abc", ""] {
403 assert!(parse_issue_key(bad).is_err(), "{bad:?} should not parse");
404 }
405 }
406
407 #[tokio::test]
408 async fn issue_by_key_sends_team_and_number_and_parses_the_node() {
409 let fake = Fake::new(vec![json!({"issues": {"nodes": [issue_json()]}})]);
410 let issue = issue_by_key(&fake, "ENG", 412).await.unwrap();
411 assert_eq!(issue.identifier, "ENG-412");
412 assert_eq!(issue.label_names(), vec!["bug"]);
413 let vars = fake.last_vars();
414 assert_eq!(vars["team"], "ENG");
415 assert_eq!(vars["number"], 412.0);
416 }
417
418 #[tokio::test]
419 async fn issue_by_key_reports_not_found_for_an_empty_node_list() {
420 let fake = Fake::new(vec![json!({"issues": {"nodes": []}})]);
421 let err = issue_by_key(&fake, "ENG", 999).await.unwrap_err();
422 assert!(err.to_string().contains("ENG-999"), "{err}");
423 }
424
425 #[tokio::test]
426 async fn issue_by_id_reports_not_found_for_a_null_issue() {
427 let fake = Fake::new(vec![json!({"issue": null})]);
428 let err = issue_by_id(&fake, "uuid-nope").await.unwrap_err();
429 assert!(err.to_string().contains("uuid-nope"), "{err}");
430 }
431
432 #[tokio::test]
433 async fn team_by_key_parses_the_state_list() {
434 let fake = Fake::new(vec![json!({"teams": {"nodes": [{
435 "id": "t1", "key": "ENG", "name": "Engineering",
436 "states": {"nodes": [
437 {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
438 {"id": "s2", "name": "Done", "type": "completed", "position": 2.0}
439 ]}
440 }]}})]);
441 let team = team_by_key(&fake, "ENG").await.unwrap();
442 assert_eq!(team.states.nodes.len(), 2);
443 assert_eq!(team.states.nodes[1].name, "Done");
444 }
445
446 #[tokio::test]
447 async fn update_issue_omits_fields_the_caller_left_unset() {
448 let fake = Fake::new(vec![
449 json!({"issueUpdate": {"success": true, "issue": issue_json()}}),
450 ]);
451 let patch = IssuePatch {
452 state_id: Some("s2".into()),
453 description: None,
454 };
455 update_issue(&fake, "uuid-1", patch).await.unwrap();
456 let input = &fake.last_vars()["input"];
457 assert_eq!(input["stateId"], "s2");
458 assert!(
459 input.get("description").is_none(),
460 "an unset field must not be sent: {input}"
461 );
462 }
463
464 #[tokio::test]
465 async fn create_issue_sends_the_state_only_when_given() {
466 let fake = Fake::new(vec![
467 json!({"issueCreate": {"success": true, "issue": issue_json()}}),
468 ]);
469 create_issue(
470 &fake,
471 NewIssue {
472 team_id: "t1".into(),
473 title: "T".into(),
474 description: "D".into(),
475 state_id: None,
476 priority: 2.0,
477 },
478 )
479 .await
480 .unwrap();
481 let input = &fake.last_vars()["input"];
482 assert_eq!(input["teamId"], "t1");
483 assert!(input.get("stateId").is_none());
484 }
485
486 #[tokio::test]
487 async fn a_mutation_reporting_success_false_is_an_error() {
488 let fake = Fake::new(vec![
489 json!({"issueUpdate": {"success": false, "issue": null}}),
490 ]);
491 let err = update_issue(&fake, "uuid-1", IssuePatch::default())
492 .await
493 .unwrap_err();
494 assert!(err.to_string().contains("success: false"), "{err}");
495 }
496
497 #[tokio::test]
498 async fn success_true_with_no_issue_is_reported_rather_than_parsed_as_garbage() {
499 let fake = Fake::new(vec![
500 json!({"issueCreate": {"success": true, "issue": null}}),
501 ]);
502 let err = create_issue(
503 &fake,
504 NewIssue {
505 team_id: "t1".into(),
506 title: "T".into(),
507 description: "D".into(),
508 state_id: None,
509 priority: 0.0,
510 },
511 )
512 .await
513 .unwrap_err();
514 assert!(err.to_string().contains("returned no issue"), "{err}");
515 }
516
517 #[tokio::test]
518 async fn create_comment_checks_success_and_returns_the_comment_id() {
519 let fake = Fake::new(vec![json!({"commentCreate": {
520 "success": true, "comment": {"id": "comment-uuid-1"}
521 }})]);
522 let id = create_comment(&fake, "uuid-1", "hello").await.unwrap();
523 assert_eq!(id, "comment-uuid-1");
524 assert_eq!(fake.last_vars()["input"]["issueId"], "uuid-1");
525 assert_eq!(fake.last_vars()["input"]["body"], "hello");
526 assert!(fake.last_doc().contains("commentCreate"));
527 }
528
529 #[tokio::test]
530 async fn create_comment_success_without_an_id_is_reported() {
531 let fake = Fake::new(vec![
532 json!({"commentCreate": {"success": true, "comment": null}}),
533 ]);
534 let err = create_comment(&fake, "uuid-1", "hello").await.unwrap_err();
535 assert!(err.to_string().contains("no comment"), "{err}");
536 }
537
538 #[tokio::test]
539 async fn update_comment_sends_the_id_and_body_and_checks_success() {
540 let fake = Fake::new(vec![json!({"commentUpdate": {"success": true}})]);
541 update_comment(&fake, "comment-uuid-1", "new body")
542 .await
543 .unwrap();
544 assert_eq!(fake.last_vars()["id"], "comment-uuid-1");
545 assert_eq!(fake.last_vars()["input"]["body"], "new body");
546 assert!(fake.last_doc().contains("commentUpdate"));
547 }
548
549 #[tokio::test]
550 async fn a_deleted_comment_is_detectable_by_the_caller() {
551 let err = Error::Api(vec!["Entity not found: Comment".into()]);
555 assert!(comment_not_found(&err));
556 assert!(!comment_not_found(&Error::Api(vec!["rate limited".into()])));
557 assert!(comment_not_found(&Error::NotFound("comment x".into())));
558 }
559
560 fn assigned_node(identifier: &str, cycle: Option<&str>, active: Option<&str>) -> Value {
561 let mut node = issue_json();
562 node["identifier"] = json!(identifier);
563 node["cycle"] = match cycle {
564 Some(id) => json!({ "id": id }),
565 None => Value::Null,
566 };
567 node["team"]["activeCycle"] = match active {
568 Some(id) => json!({ "id": id }),
569 None => Value::Null,
570 };
571 node
572 }
573
574 #[tokio::test]
575 async fn assigned_to_viewer_parses_issues_with_their_cycle_context() {
576 let fake = Fake::new(vec![json!({"viewer": {"assignedIssues": {"nodes": [
577 assigned_node("ENG-1", Some("cyc-9"), Some("cyc-9")),
578 assigned_node("ENG-2", None, None),
579 ]}}})]);
580 let assigned = assigned_to_viewer(&fake).await.unwrap();
581 assert_eq!(assigned.len(), 2);
582 assert_eq!(assigned[0].issue.identifier, "ENG-1");
583 assert_eq!(assigned[0].cycle_id.as_deref(), Some("cyc-9"));
584 assert_eq!(assigned[0].team_active_cycle_id.as_deref(), Some("cyc-9"));
585 assert_eq!(assigned[1].cycle_id, None);
586 assert_eq!(assigned[1].team_active_cycle_id, None);
587 }
588
589 #[tokio::test]
590 async fn assigned_to_viewer_asks_only_for_open_states() {
591 let fake = Fake::new(vec![json!({"viewer": {"assignedIssues": {"nodes": []}}})]);
594 assigned_to_viewer(&fake).await.unwrap();
595 let doc = fake.last_doc();
596 assert!(doc.contains("assignedIssues"), "{doc}");
597 assert!(doc.contains("completed"), "{doc}");
598 assert!(doc.contains("canceled"), "{doc}");
599 assert!(doc.contains("nin"), "{doc}");
600 }
601
602 #[tokio::test]
603 async fn assigned_to_viewer_reports_a_missing_viewer_rather_than_panicking() {
604 let fake = Fake::new(vec![json!({"nonsense": 1})]);
605 let err = assigned_to_viewer(&fake).await.unwrap_err();
606 assert!(err.to_string().contains("viewer"), "{err}");
607 }
608
609 #[test]
610 fn in_active_scope_is_everything_when_the_team_has_no_active_cycle() {
611 for cycle in [None, Some("cyc-1".to_string())] {
614 let a = AssignedIssue {
615 issue: serde_json::from_value(issue_json()).unwrap(),
616 cycle_id: cycle,
617 team_active_cycle_id: None,
618 };
619 assert!(a.in_active_scope());
620 }
621 }
622
623 #[test]
624 fn in_active_scope_requires_the_active_cycle_when_the_team_uses_cycles() {
625 let make = |cycle: Option<&str>| AssignedIssue {
626 issue: serde_json::from_value(issue_json()).unwrap(),
627 cycle_id: cycle.map(str::to_string),
628 team_active_cycle_id: Some("cyc-active".into()),
629 };
630 assert!(make(Some("cyc-active")).in_active_scope());
631 assert!(!make(Some("cyc-old")).in_active_scope(), "a past cycle is backlog, not now");
632 assert!(!make(None).in_active_scope(), "no cycle at all means backlog on a cycle team");
633 }
634
635 #[test]
636 fn issue_fields_selects_everything_the_model_requires() {
637 for field in [
640 "id",
641 "identifier",
642 "title",
643 "description",
644 "url",
645 "updatedAt",
646 "priority",
647 "dueDate",
648 "state {",
649 "team {",
650 "labels {",
651 ] {
652 assert!(
653 ISSUE_FIELDS.contains(field),
654 "{field} missing from ISSUE_FIELDS"
655 );
656 }
657 }
658}