Skip to main content

nomoreide_remote_protocol/
linear.rs

1//! Linear operations shared by local and remote clients. Credentials never enter this protocol.
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5#[serde(tag = "operation", rename_all = "camelCase", deny_unknown_fields)]
6pub enum LinearRequest {
7    Metadata {},
8    Binding {
9        team: String,
10        project: Option<String>,
11    },
12    /// Forget the selected repository's default team — what unticking the
13    /// team menu's "use for this repository" does. Additive to [`Binding`]
14    /// rather than a nullable team on it, because that field is `String` on
15    /// the wire and the platform speaks this format from another repository.
16    Unbind {},
17    Issues {
18        team: String,
19        project: Option<String>,
20        after: Option<String>,
21    },
22    Issue {
23        id: String,
24    },
25    /// A Linear *project*, not an issue. `teamIds` is plural in Linear's own
26    /// input because a project may span teams; this creates it against the one
27    /// the panel is scoped to, which is the only team it can name.
28    CreateProject {
29        team: String,
30        name: String,
31        description: String,
32    },
33    Create {
34        team: String,
35        project: Option<String>,
36        title: String,
37        description: String,
38    },
39    Update {
40        id: String,
41        state: String,
42    },
43    /// Where a board drop leaves a card: its column **and** its position in
44    /// that column, in one mutation. Separate from [`Update`] because a
45    /// cross-column drag changes both, and two round trips would show the card
46    /// in the right lane at the wrong height.
47    Place {
48        id: String,
49        state: String,
50        sort_order: f64,
51    },
52    Comment {
53        id: String,
54        body: String,
55    },
56}
57impl LinearRequest {
58    pub fn is_mutating(&self) -> bool {
59        matches!(
60            self,
61            Self::Binding { .. }
62                | Self::Unbind {}
63                | Self::CreateProject { .. }
64                | Self::Create { .. }
65                | Self::Update { .. }
66                | Self::Place { .. }
67                | Self::Comment { .. }
68        )
69    }
70    pub fn validate(&self) -> Result<(), &'static str> {
71        fn id(value: &str) -> bool {
72            !value.is_empty()
73                && value.len() <= 128
74                && value
75                    .bytes()
76                    .all(|b| b.is_ascii_alphanumeric() || b == b'-')
77        }
78        let valid = match self {
79            Self::Metadata {} => true,
80            Self::Binding { team, project } => id(team) && project.as_deref().map_or(true, id),
81            Self::Unbind {} => true,
82            Self::Issues {
83                team,
84                project,
85                after,
86            } => {
87                id(team)
88                    && project.as_deref().map_or(true, id)
89                    && after.as_ref().map_or(true, |s| s.len() <= 512)
90            }
91            Self::Issue { id: value } => id(value),
92            Self::Create {
93                team,
94                project,
95                title,
96                description,
97            } => {
98                id(team)
99                    && project.as_deref().map_or(true, id)
100                    && !title.trim().is_empty()
101                    && title.len() <= 512
102                    && description.len() <= 16000
103            }
104            Self::CreateProject {
105                team,
106                name,
107                description,
108            } => {
109                id(team)
110                    && !name.trim().is_empty()
111                    && name.len() <= 512
112                    && description.len() <= 16000
113            }
114            Self::Update { id: value, state } => id(value) && id(state),
115            // A non-finite sort order would serialise as `null` and silently
116            // clear the field rather than set it.
117            Self::Place {
118                id: value,
119                state,
120                sort_order,
121            } => id(value) && id(state) && sort_order.is_finite(),
122            Self::Comment { id: value, body } => {
123                id(value) && !body.trim().is_empty() && body.len() <= 16000
124            }
125        };
126        if valid {
127            Ok(())
128        } else {
129            Err("Invalid Linear request or field length")
130        }
131    }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
135pub struct LinearResponse {
136    pub data: LinearData,
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    #[test]
143    fn writes_and_input_boundaries() {
144        assert!(!(LinearRequest::Metadata {}).is_mutating());
145        let write = LinearRequest::Comment {
146            id: "ENG-1".into(),
147            body: "Hello".into(),
148        };
149        assert!(write.is_mutating());
150        assert!(write.validate().is_ok());
151        assert!(LinearRequest::Issue {
152            id: "../token".into()
153        }
154        .validate()
155        .is_err());
156        assert!(serde_json::from_value::<LinearRequest>(
157            serde_json::json!({"operation":"metadata", "token":"secret"})
158        )
159        .is_err());
160    }
161}
162
163/// Only these provider fields may leave the host. Unknown response fields are dropped.
164#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
165#[serde(rename_all = "camelCase")]
166pub struct LinearData {
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub teams: Option<Connection<Team>>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub viewer: Option<Choice>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub binding: Option<Binding>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub issues: Option<Connection<Issue>>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub issue: Option<Issue>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub issue_create: Option<IssueMutation>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub issue_update: Option<IssueMutation>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub comment_create: Option<CommentMutation>,
183}
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185pub struct Choice {
186    pub id: String,
187    pub name: String,
188}
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct Binding {
191    pub team: String,
192    pub project: Option<String>,
193}
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195#[serde(rename_all = "camelCase")]
196pub struct Connection<T> {
197    pub nodes: Vec<T>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub page_info: Option<PageInfo>,
200}
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
202#[serde(rename_all = "camelCase")]
203pub struct PageInfo {
204    pub has_next_page: bool,
205    pub end_cursor: Option<String>,
206}
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
208pub struct Team {
209    pub id: String,
210    pub name: String,
211    pub states: Connection<Choice>,
212    pub projects: Connection<Choice>,
213}
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
215#[serde(rename_all = "camelCase")]
216pub struct Issue {
217    pub id: String,
218    pub identifier: String,
219    pub title: String,
220    pub description: Option<String>,
221    pub url: String,
222    pub branch_name: String,
223    pub priority: u8,
224    pub state: Choice,
225    pub team: Choice,
226    pub assignee: Option<Choice>,
227    pub project: Option<Choice>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub comments: Option<Connection<Comment>>,
230}
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232pub struct Comment {
233    pub id: String,
234    pub body: String,
235    pub user: Option<CommentUser>,
236}
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238pub struct CommentUser {
239    pub name: String,
240}
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242pub struct IssueMutation {
243    pub success: bool,
244    pub issue: Option<Issue>,
245}
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
247pub struct CommentMutation {
248    pub success: bool,
249    pub comment: Option<Comment>,
250}
251
252#[cfg(test)]
253mod wire_tests {
254    use super::*;
255    use crate::{version::capabilities, DeviceBound};
256    #[test]
257    fn wire_policy_separates_reads_from_writes() {
258        let read = DeviceBound::Linear(LinearRequest::Metadata {});
259        assert!(!read.mutating());
260        assert_eq!(read.required_capability(), Some(capabilities::LINEAR));
261        for request in [
262            LinearRequest::Binding {
263                team: "team".into(),
264                project: None,
265            },
266            LinearRequest::Create {
267                team: "team".into(),
268                project: None,
269                title: "Task".into(),
270                description: String::new(),
271            },
272            LinearRequest::Update {
273                id: "ENG-1".into(),
274                state: "done".into(),
275            },
276            LinearRequest::Comment {
277                id: "ENG-1".into(),
278                body: "Comment".into(),
279            },
280        ] {
281            assert!(DeviceBound::Linear(request).mutating());
282        }
283    }
284    #[test]
285    fn remote_response_drops_credentials_and_unexpected_fields() {
286        let response: LinearData = serde_json::from_value(serde_json::json!({
287            "token": "secret", "hostPath": "/private/repository",
288            "viewer": {"id":"user", "name":"Name", "email":"private@example.com", "token":"secret"},
289            "teams": {"nodes": []}
290        }))
291        .unwrap();
292        let encoded = serde_json::to_string(&response).unwrap();
293        assert!(!encoded.contains("secret"));
294        assert!(!encoded.contains("private"));
295        assert!(encoded.contains("Name"));
296    }
297}