Skip to main content

cliban_sync/linear/
ops.rs

1//! Typed GraphQL operations, generic over [`GraphQl`] so tests can drive them
2//! with canned JSON.
3//!
4//! The field selections here and the structs in [`super::model`] have to agree.
5//! [`ISSUE_FIELDS`] is shared by every query and mutation that returns an issue
6//! so there is exactly one place where that agreement is maintained.
7
8use serde_json::{json, Value};
9
10use super::client::GraphQl;
11use super::model::{Issue, Nodes, Team};
12use crate::error::{Error, Result};
13
14/// The issue selection, shared by every operation returning an issue.
15pub 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
29/// Look an issue up by its human key, e.g. `ENG-412`.
30///
31/// Filtering on team key + number rather than passing the identifier to
32/// `issue(id:)`: the identifier form is not documented to work for every
33/// workspace, whereas the filter form is plain schema.
34pub 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
51/// Fetch an issue by its Linear UUID. Used on push, where the link already
52/// records the stable id.
53pub 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
67/// A team and its workflow states, for status mapping and issue creation.
68pub 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/// An issue assigned to the viewer, with just enough cycle context to decide
87/// whether `import --mine` should bring it in.
88#[derive(Debug, Clone)]
89pub struct AssignedIssue {
90    pub issue: Issue,
91    /// The cycle the issue sits in, if any.
92    pub cycle_id: Option<String>,
93    /// The team's currently active cycle. `None` means the team does not use
94    /// cycles (or has none running), which changes what "mine, now" means.
95    pub team_active_cycle_id: Option<String>,
96}
97
98impl AssignedIssue {
99    /// Whether `--mine` should import this issue. A team with no active cycle
100    /// puts every open assigned issue in scope; a team running a cycle scopes
101    /// "mine, now" to that cycle — anything outside it is backlog.
102    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
110/// Every open issue assigned to the token's viewer, with cycle context.
111///
112/// The completed/canceled exclusion is server-side: finished work must never
113/// flood in through `--mine`, however long the assignee list has grown. The
114/// cycle scoping is deliberately client-side instead — whether a team uses
115/// cycles is per-issue data, and the caller wants to *count* the out-of-cycle
116/// skips rather than have them silently absent.
117pub 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
141/// The cycle fields ride alongside [`ISSUE_FIELDS`] in the same node; peel
142/// them off before handing the rest to the shared [`Issue`] shape.
143fn 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/// Fields for a new Linear issue. Only what `push --create` sets.
161#[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/// Fields `push` may change on an existing Linear issue. `None` means "leave
193/// it alone" — the bridge never writes a field the user did not ask for.
194#[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
226/// Post a comment and return its Linear id — the id is what lets a later push
227/// edit the same comment in place rather than append a new one.
228pub 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
249/// Rewrite an existing comment's body in place.
250///
251/// A comment someone deleted surfaces as a GraphQL error, not `success: false`
252/// — use [`comment_not_found`] on the returned error to tell "recreate it"
253/// apart from "the API refused".
254pub 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
264/// Whether an error from [`update_comment`] means the comment no longer exists
265/// — i.e. someone deleted it in Linear, and the right move is to create a
266/// fresh one rather than fail the push.
267pub 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
277/// Split `ENG-412` into `("ENG", 412)`.
278///
279/// Team keys can contain digits and hyphens (`WEB-2`, `A-B-12`), so the split
280/// is on the *last* hyphen, and the tail has to be entirely numeric.
281pub 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
299// ---- envelope helpers ----
300
301fn 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
309/// Pull the issue out of a mutation payload, checking `success` first so a
310/// refusal is reported as a refusal rather than as a parse failure.
311fn 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    /// Records what was asked and replies with whatever was queued.
345    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        // Team keys can contain hyphens; splitting on the first would break.
396        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        // Linear reports a vanished entity as a GraphQL error, which the
552        // transport surfaces as Error::Api. The push path needs to tell that
553        // apart from "the API refused" to recreate rather than fail.
554        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        // The server-side filter is the contract: completed and canceled work
592        // must never flood in through --mine.
593        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        // A team that does not use cycles reports no active cycle; every open
612        // assigned issue is then in scope.
613        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        // `model::Issue` has no `#[serde(default)]` on these, so a selection
638        // that dropped one would fail at runtime on every operation at once.
639        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}