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/// Fields for a new Linear issue. Only what `push --create` sets.
87#[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/// Fields `push` may change on an existing Linear issue. `None` means "leave
119/// it alone" — the bridge never writes a field the user did not ask for.
120#[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
152/// Post a comment. Returns nothing useful — a comment either lands or errors.
153pub 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
166/// Split `ENG-412` into `("ENG", 412)`.
167///
168/// Team keys can contain digits and hyphens (`WEB-2`, `A-B-12`), so the split
169/// is on the *last* hyphen, and the tail has to be entirely numeric.
170pub 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
188// ---- envelope helpers ----
189
190fn 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
198/// Pull the issue out of a mutation payload, checking `success` first so a
199/// refusal is reported as a refusal rather than as a parse failure.
200fn 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    /// Records what was asked and replies with whatever was queued.
234    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        // Team keys can contain hyphens; splitting on the first would break.
285        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        // `model::Issue` has no `#[serde(default)]` on these, so a selection
418        // that dropped one would fail at runtime on every operation at once.
419        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}