Skip to main content

symphony_tracker/
graphql_tool.rs

1//! Optional `linear_graphql` client-side tool extension (Spec Section 10.5).
2//!
3//! Allows the coding agent to execute GraphQL queries/mutations against Linear
4//! using Symphony's configured tracker auth.
5
6use serde_json::Value;
7
8/// Result of a `linear_graphql` tool call.
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct GraphqlToolResult {
11    pub success: bool,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub data: Option<Value>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub errors: Option<Value>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub error: Option<String>,
18}
19
20/// Validate the input for a `linear_graphql` tool call (S10.5).
21///
22/// Returns `Ok((query, variables))` or `Err(error_message)`.
23pub fn validate_input(input: &Value) -> Result<(String, Value), String> {
24    // Accept either an object with "query" field or a raw string
25    let (query_str, variables) = if let Some(s) = input.as_str() {
26        // Raw query string shorthand
27        (s.to_string(), Value::Null)
28    } else if let Some(obj) = input.as_object() {
29        let query = obj
30            .get("query")
31            .and_then(|q| q.as_str())
32            .ok_or("'query' must be a non-empty string")?;
33
34        if query.trim().is_empty() {
35            return Err("'query' must be a non-empty string".into());
36        }
37
38        let vars = obj.get("variables").cloned().unwrap_or(Value::Null);
39        // Variables must be an object when present (S10.5)
40        if !vars.is_null() && !vars.is_object() {
41            return Err("'variables' must be a JSON object when present".into());
42        }
43
44        (query.to_string(), vars)
45    } else {
46        return Err("input must be an object with 'query' field or a raw query string".into());
47    };
48
49    if query_str.trim().is_empty() {
50        return Err("'query' must be a non-empty string".into());
51    }
52
53    // Check for multiple operations (S10.5: single operation only)
54    if has_multiple_operations(&query_str) {
55        return Err("query must contain exactly one GraphQL operation".into());
56    }
57
58    Ok((query_str, variables))
59}
60
61/// Check if a GraphQL document contains multiple operations.
62///
63/// Simple heuristic: count top-level `query`, `mutation`, `subscription` keywords.
64fn has_multiple_operations(query: &str) -> bool {
65    let mut count = 0;
66    let mut chars = query.chars().peekable();
67    let mut in_string = false;
68    let mut in_comment = false;
69
70    while let Some(ch) = chars.next() {
71        if in_comment {
72            if ch == '\n' {
73                in_comment = false;
74            }
75            continue;
76        }
77        if ch == '#' {
78            in_comment = true;
79            continue;
80        }
81        if ch == '"' {
82            in_string = !in_string;
83            continue;
84        }
85        if in_string {
86            continue;
87        }
88
89        // Look for operation keywords at word boundaries
90        if ch.is_alphabetic() {
91            let mut word = String::new();
92            word.push(ch);
93            while let Some(&next) = chars.peek() {
94                if next.is_alphanumeric() || next == '_' {
95                    word.push(next);
96                    chars.next();
97                } else {
98                    break;
99                }
100            }
101            match word.as_str() {
102                "query" | "mutation" | "subscription" => {
103                    count += 1;
104                    if count > 1 {
105                        return true;
106                    }
107                }
108                _ => {}
109            }
110        }
111    }
112
113    false
114}
115
116/// Execute a `linear_graphql` tool call against the Linear API.
117///
118/// Uses the provided endpoint and API key (from Symphony's tracker config).
119pub async fn execute_graphql_tool(
120    endpoint: &str,
121    api_key: &str,
122    query: &str,
123    variables: Value,
124) -> GraphqlToolResult {
125    let http = reqwest::Client::new();
126    let body = serde_json::json!({
127        "query": query,
128        "variables": if variables.is_null() { Value::Object(serde_json::Map::new()) } else { variables },
129    });
130
131    let response = match http
132        .post(endpoint)
133        .header("Authorization", api_key)
134        .header("Content-Type", "application/json")
135        .json(&body)
136        .send()
137        .await
138    {
139        Ok(r) => r,
140        Err(e) => {
141            return GraphqlToolResult {
142                success: false,
143                data: None,
144                errors: None,
145                error: Some(format!("transport_failure: {e}")),
146            };
147        }
148    };
149
150    let status = response.status().as_u16();
151    if !(200..300).contains(&status) {
152        let body_text = response.text().await.unwrap_or_else(|_| "<unreadable>".into());
153        return GraphqlToolResult {
154            success: false,
155            data: None,
156            errors: None,
157            error: Some(format!("api_status_{status}: {body_text}")),
158        };
159    }
160
161    let json: Value = match response.json().await {
162        Ok(j) => j,
163        Err(e) => {
164            return GraphqlToolResult {
165                success: false,
166                data: None,
167                errors: None,
168                error: Some(format!("parse_failure: {e}")),
169            };
170        }
171    };
172
173    // Check for top-level GraphQL errors (S10.5)
174    let has_errors = json
175        .get("errors")
176        .and_then(|e| e.as_array())
177        .is_some_and(|arr| !arr.is_empty());
178
179    if has_errors {
180        // success=false but preserve full body for debugging
181        GraphqlToolResult {
182            success: false,
183            data: json.get("data").cloned(),
184            errors: json.get("errors").cloned(),
185            error: None,
186        }
187    } else {
188        GraphqlToolResult {
189            success: true,
190            data: json.get("data").cloned(),
191            errors: None,
192            error: None,
193        }
194    }
195}
196
197/// Tool spec for advertising `linear_graphql` during handshake (S10.5).
198pub fn tool_spec() -> Value {
199    serde_json::json!({
200        "name": "linear_graphql",
201        "description": "Execute a GraphQL query or mutation against the Linear API.",
202        "inputSchema": {
203            "type": "object",
204            "properties": {
205                "query": {
206                    "type": "string",
207                    "description": "A single GraphQL query or mutation document"
208                },
209                "variables": {
210                    "type": "object",
211                    "description": "Optional GraphQL variables"
212                }
213            },
214            "required": ["query"]
215        }
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn validate_valid_query() {
225        let input = serde_json::json!({
226            "query": "query { viewer { id } }",
227        });
228        let (query, vars) = validate_input(&input).unwrap();
229        assert_eq!(query, "query { viewer { id } }");
230        assert!(vars.is_null());
231    }
232
233    #[test]
234    fn validate_query_with_variables() {
235        let input = serde_json::json!({
236            "query": "query($id: ID!) { issue(id: $id) { title } }",
237            "variables": { "id": "abc-123" }
238        });
239        let (query, vars) = validate_input(&input).unwrap();
240        assert!(query.contains("$id: ID!"));
241        assert!(vars.is_object());
242        assert_eq!(vars["id"], "abc-123");
243    }
244
245    #[test]
246    fn validate_raw_string_input() {
247        let input = Value::String("query { viewer { id } }".into());
248        let (query, _vars) = validate_input(&input).unwrap();
249        assert_eq!(query, "query { viewer { id } }");
250    }
251
252    #[test]
253    fn validate_empty_query_fails() {
254        let input = serde_json::json!({ "query": "" });
255        let err = validate_input(&input).unwrap_err();
256        assert!(err.contains("non-empty"));
257    }
258
259    #[test]
260    fn validate_missing_query_fails() {
261        let input = serde_json::json!({ "variables": {} });
262        let err = validate_input(&input).unwrap_err();
263        assert!(err.contains("non-empty"));
264    }
265
266    #[test]
267    fn validate_variables_must_be_object() {
268        let input = serde_json::json!({
269            "query": "query { viewer { id } }",
270            "variables": [1, 2, 3]
271        });
272        let err = validate_input(&input).unwrap_err();
273        assert!(err.contains("JSON object"));
274    }
275
276    #[test]
277    fn validate_multiple_operations_rejected() {
278        let input = serde_json::json!({
279            "query": "query A { viewer { id } } mutation B { updateIssue { id } }"
280        });
281        let err = validate_input(&input).unwrap_err();
282        assert!(err.contains("exactly one"));
283    }
284
285    #[test]
286    fn validate_single_mutation_accepted() {
287        let input = serde_json::json!({
288            "query": "mutation { updateIssue(id: \"123\", input: { title: \"New\" }) { success } }"
289        });
290        assert!(validate_input(&input).is_ok());
291    }
292
293    #[test]
294    fn has_multiple_operations_detects_two() {
295        assert!(has_multiple_operations(
296            "query A { a } mutation B { b }"
297        ));
298    }
299
300    #[test]
301    fn has_multiple_operations_single_ok() {
302        assert!(!has_multiple_operations("query { viewer { id } }"));
303    }
304
305    #[test]
306    fn has_multiple_operations_ignores_comments() {
307        // "mutation" in a comment should not count
308        assert!(!has_multiple_operations(
309            "query { viewer { id } }\n# mutation { x }"
310        ));
311    }
312
313    #[test]
314    fn tool_spec_has_required_fields() {
315        let spec = tool_spec();
316        assert_eq!(spec["name"], "linear_graphql");
317        assert!(spec.get("inputSchema").is_some());
318    }
319
320    #[test]
321    fn graphql_tool_result_serialization() {
322        let result = GraphqlToolResult {
323            success: true,
324            data: Some(serde_json::json!({"viewer": {"id": "user-1"}})),
325            errors: None,
326            error: None,
327        };
328        let json = serde_json::to_value(&result).unwrap();
329        assert_eq!(json["success"], true);
330        assert!(json.get("data").is_some());
331        // None fields should be skipped
332        assert!(json.get("errors").is_none());
333        assert!(json.get("error").is_none());
334    }
335
336    #[test]
337    fn graphql_tool_result_failure() {
338        let result = GraphqlToolResult {
339            success: false,
340            data: Some(serde_json::json!(null)),
341            errors: Some(serde_json::json!([{"message": "Not found"}])),
342            error: None,
343        };
344        let json = serde_json::to_value(&result).unwrap();
345        assert_eq!(json["success"], false);
346        assert!(json.get("errors").is_some());
347    }
348
349    // ─── Real Integration Tests (S17.8) ───
350
351    #[tokio::test]
352    #[ignore] // S17.8: skipped when credentials absent
353    async fn real_graphql_tool_valid_query() {
354        let api_key = std::env::var("LINEAR_API_KEY")
355            .expect("LINEAR_API_KEY must be set for real integration tests");
356
357        let result = execute_graphql_tool(
358            "https://api.linear.app/graphql",
359            &api_key,
360            "query { viewer { id name } }",
361            Value::Null,
362        )
363        .await;
364
365        assert!(result.success, "valid query should succeed: {:?}", result);
366        assert!(result.data.is_some(), "data should be present");
367        assert!(result.error.is_none(), "error should be absent on success");
368    }
369
370    #[tokio::test]
371    #[ignore] // S17.8: skipped when credentials absent
372    async fn real_graphql_tool_invalid_auth() {
373        let result = execute_graphql_tool(
374            "https://api.linear.app/graphql",
375            "lin_api_invalid_key_12345",
376            "query { viewer { id } }",
377            Value::Null,
378        )
379        .await;
380
381        assert!(!result.success, "invalid auth should fail");
382    }
383}