1use 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 Unbind {},
17 Issues {
18 team: String,
19 project: Option<String>,
20 after: Option<String>,
21 },
22 Issue {
23 id: String,
24 },
25 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 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 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#[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}