Skip to main content

shodh_memory/integrations/
linear.rs

1//! Linear integration for syncing issues to Shodh memory
2//!
3//! Provides:
4//! - Webhook receiver for real-time issue updates
5//! - Bulk sync for importing existing issues
6//! - HMAC-SHA256 signature verification
7
8use anyhow::{Context, Result};
9use hmac::{Hmac, Mac};
10use serde::{Deserialize, Serialize};
11use sha2::Sha256;
12
13type HmacSha256 = Hmac<Sha256>;
14
15// =============================================================================
16// LINEAR WEBHOOK TYPES
17// =============================================================================
18
19/// Linear webhook payload structure
20#[derive(Debug, Clone, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct LinearWebhookPayload {
23    /// Action type: "create", "update", "remove"
24    pub action: String,
25    /// Actor who triggered the webhook (user info)
26    #[serde(default)]
27    pub actor: Option<LinearActor>,
28    /// Timestamp of the webhook
29    #[serde(default)]
30    pub created_at: Option<String>,
31    /// The data payload (varies by type)
32    pub data: LinearIssueData,
33    /// Type of entity: "Issue", "Comment", "Project", etc.
34    #[serde(rename = "type")]
35    pub entity_type: String,
36    /// URL to the entity in Linear
37    #[serde(default)]
38    pub url: Option<String>,
39    /// Organization ID
40    #[serde(default)]
41    pub organization_id: Option<String>,
42    /// Webhook ID
43    #[serde(default)]
44    pub webhook_id: Option<String>,
45    /// Webhook timestamp
46    #[serde(default)]
47    pub webhook_timestamp: Option<i64>,
48}
49
50/// Actor who triggered the webhook
51#[derive(Debug, Clone, Deserialize)]
52pub struct LinearActor {
53    pub id: String,
54    #[serde(default)]
55    pub name: Option<String>,
56    #[serde(rename = "type")]
57    #[serde(default)]
58    pub actor_type: Option<String>,
59}
60
61/// Linear issue data from webhook
62#[derive(Debug, Clone, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct LinearIssueData {
65    /// UUID of the issue
66    pub id: String,
67    /// Human-readable identifier (e.g., "SHO-39")
68    #[serde(default)]
69    pub identifier: Option<String>,
70    /// Issue title
71    #[serde(default)]
72    pub title: Option<String>,
73    /// Issue description (markdown)
74    #[serde(default)]
75    pub description: Option<String>,
76    /// Priority (0-4, where 1=Urgent, 4=Low, 0=None)
77    #[serde(default)]
78    pub priority: Option<i32>,
79    /// Priority label
80    #[serde(default)]
81    pub priority_label: Option<String>,
82    /// Issue state
83    #[serde(default)]
84    pub state: Option<LinearState>,
85    /// Assignee
86    #[serde(default)]
87    pub assignee: Option<LinearUser>,
88    /// Creator
89    #[serde(default)]
90    pub creator: Option<LinearUser>,
91    /// Labels
92    #[serde(default)]
93    pub labels: Vec<LinearLabel>,
94    /// Team
95    #[serde(default)]
96    pub team: Option<LinearTeam>,
97    /// Project
98    #[serde(default)]
99    pub project: Option<LinearProject>,
100    /// Cycle
101    #[serde(default)]
102    pub cycle: Option<LinearCycle>,
103    /// Parent issue (for sub-issues)
104    #[serde(default)]
105    pub parent: Option<Box<LinearIssueData>>,
106    /// Due date
107    #[serde(default)]
108    pub due_date: Option<String>,
109    /// Estimate (story points)
110    #[serde(default)]
111    pub estimate: Option<f32>,
112    /// Created timestamp
113    #[serde(default)]
114    pub created_at: Option<String>,
115    /// Updated timestamp
116    #[serde(default)]
117    pub updated_at: Option<String>,
118    /// Completed timestamp
119    #[serde(default)]
120    pub completed_at: Option<String>,
121    /// Canceled timestamp
122    #[serde(default)]
123    pub canceled_at: Option<String>,
124    /// URL to the issue
125    #[serde(default)]
126    pub url: Option<String>,
127}
128
129#[derive(Debug, Clone, Deserialize)]
130pub struct LinearState {
131    pub id: String,
132    pub name: String,
133    #[serde(default)]
134    pub color: Option<String>,
135    #[serde(rename = "type")]
136    #[serde(default)]
137    pub state_type: Option<String>,
138}
139
140#[derive(Debug, Clone, Deserialize)]
141pub struct LinearUser {
142    pub id: String,
143    #[serde(default)]
144    pub name: Option<String>,
145    #[serde(default)]
146    pub email: Option<String>,
147}
148
149#[derive(Debug, Clone, Deserialize)]
150pub struct LinearLabel {
151    pub id: String,
152    pub name: String,
153    #[serde(default)]
154    pub color: Option<String>,
155}
156
157#[derive(Debug, Clone, Deserialize)]
158pub struct LinearTeam {
159    pub id: String,
160    #[serde(default)]
161    pub name: Option<String>,
162    #[serde(default)]
163    pub key: Option<String>,
164}
165
166#[derive(Debug, Clone, Deserialize)]
167pub struct LinearProject {
168    pub id: String,
169    #[serde(default)]
170    pub name: Option<String>,
171}
172
173#[derive(Debug, Clone, Deserialize)]
174pub struct LinearCycle {
175    pub id: String,
176    #[serde(default)]
177    pub name: Option<String>,
178    #[serde(default)]
179    pub number: Option<i32>,
180}
181
182// =============================================================================
183// WEBHOOK HANDLER
184// =============================================================================
185
186/// Linear webhook handler
187pub struct LinearWebhook {
188    /// Webhook signing secret for HMAC verification
189    signing_secret: Option<String>,
190}
191
192impl LinearWebhook {
193    /// Create a new webhook handler
194    pub fn new(signing_secret: Option<String>) -> Self {
195        Self { signing_secret }
196    }
197
198    /// Verify webhook signature using HMAC-SHA256
199    ///
200    /// Linear sends the signature in the `Linear-Signature` header
201    pub fn verify_signature(&self, body: &[u8], signature: &str) -> Result<bool> {
202        let secret = match &self.signing_secret {
203            Some(s) => s,
204            None => {
205                tracing::warn!("No signing secret configured, rejecting webhook");
206                return Ok(false);
207            }
208        };
209
210        let mut mac =
211            HmacSha256::new_from_slice(secret.as_bytes()).context("Invalid signing secret")?;
212        mac.update(body);
213
214        // Linear signature format: "sha256=<hex>"
215        let expected_sig = signature.strip_prefix("sha256=").unwrap_or(signature);
216
217        let expected_bytes = hex::decode(expected_sig).context("Invalid signature format")?;
218
219        Ok(mac.verify_slice(&expected_bytes).is_ok())
220    }
221
222    /// Parse webhook payload
223    pub fn parse_payload(&self, body: &[u8]) -> Result<LinearWebhookPayload> {
224        serde_json::from_slice(body).context("Failed to parse Linear webhook payload")
225    }
226
227    /// Transform Linear issue to memory content
228    ///
229    /// Creates a structured text representation suitable for semantic search
230    pub fn issue_to_content(issue: &LinearIssueData) -> String {
231        let mut parts = Vec::new();
232
233        // Header: Identifier and title
234        if let Some(id) = &issue.identifier {
235            if let Some(title) = &issue.title {
236                parts.push(format!("{}: {}", id, title));
237            } else {
238                parts.push(id.clone());
239            }
240        } else if let Some(title) = &issue.title {
241            parts.push(title.clone());
242        }
243
244        // Metadata section
245        let mut metadata = Vec::new();
246
247        if let Some(state) = &issue.state {
248            metadata.push(format!("Status: {}", state.name));
249        }
250
251        if let Some(assignee) = &issue.assignee {
252            if let Some(name) = &assignee.name {
253                metadata.push(format!("Assignee: {}", name));
254            }
255        }
256
257        if let Some(priority) = &issue.priority_label {
258            metadata.push(format!("Priority: {}", priority));
259        }
260
261        if !issue.labels.is_empty() {
262            let label_names: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
263            metadata.push(format!("Labels: {}", label_names.join(", ")));
264        }
265
266        if let Some(project) = &issue.project {
267            if let Some(name) = &project.name {
268                metadata.push(format!("Project: {}", name));
269            }
270        }
271
272        if let Some(cycle) = &issue.cycle {
273            if let Some(name) = &cycle.name {
274                metadata.push(format!("Cycle: {}", name));
275            } else if let Some(num) = cycle.number {
276                metadata.push(format!("Cycle: #{}", num));
277            }
278        }
279
280        if let Some(due) = &issue.due_date {
281            metadata.push(format!("Due: {}", due));
282        }
283
284        if let Some(estimate) = issue.estimate {
285            metadata.push(format!("Estimate: {} points", estimate));
286        }
287
288        if !metadata.is_empty() {
289            parts.push(metadata.join(" | "));
290        }
291
292        // Description
293        if let Some(desc) = &issue.description {
294            if !desc.is_empty() {
295                parts.push(String::new()); // Empty line
296                parts.push(desc.clone());
297            }
298        }
299
300        parts.join("\n")
301    }
302
303    /// Extract tags from Linear issue
304    pub fn issue_to_tags(issue: &LinearIssueData) -> Vec<String> {
305        let mut tags = vec!["linear".to_string()];
306
307        // Add identifier as tag
308        if let Some(id) = &issue.identifier {
309            tags.push(id.clone());
310        }
311
312        // Add labels as tags
313        for label in &issue.labels {
314            tags.push(label.name.clone());
315        }
316
317        // Add state as tag
318        if let Some(state) = &issue.state {
319            tags.push(state.name.clone());
320        }
321
322        // Add team key as tag
323        if let Some(team) = &issue.team {
324            if let Some(key) = &team.key {
325                tags.push(key.clone());
326            }
327        }
328
329        // Add project name as tag
330        if let Some(project) = &issue.project {
331            if let Some(name) = &project.name {
332                tags.push(name.clone());
333            }
334        }
335
336        tags
337    }
338
339    /// Determine change type from webhook action and issue state
340    pub fn determine_change_type(action: &str, issue: &LinearIssueData) -> String {
341        match action {
342            "create" => "created".to_string(),
343            "remove" => "content_updated".to_string(), // Treat removal as update (soft delete)
344            "update" => {
345                // Try to determine what changed
346                if issue.completed_at.is_some() || issue.canceled_at.is_some() {
347                    "status_changed".to_string()
348                } else {
349                    "content_updated".to_string()
350                }
351            }
352            _ => "content_updated".to_string(),
353        }
354    }
355}
356
357// =============================================================================
358// BULK SYNC TYPES
359// =============================================================================
360
361/// Request for bulk syncing Linear issues
362#[derive(Debug, Deserialize)]
363pub struct LinearSyncRequest {
364    /// User ID to associate memories with
365    pub user_id: String,
366    /// Linear API key
367    pub api_key: String,
368    /// Optional team ID to filter issues
369    #[serde(default)]
370    pub team_id: Option<String>,
371    /// Optional: only sync issues updated after this date (ISO 8601)
372    #[serde(default)]
373    pub updated_after: Option<String>,
374    /// Optional: limit number of issues to sync
375    #[serde(default)]
376    pub limit: Option<usize>,
377}
378
379/// Response from bulk sync
380#[derive(Debug, Serialize)]
381pub struct LinearSyncResponse {
382    /// Number of issues synced
383    pub synced_count: usize,
384    /// Number of issues created (new)
385    pub created_count: usize,
386    /// Number of issues updated (existing)
387    pub updated_count: usize,
388    /// Number of issues that failed
389    pub error_count: usize,
390    /// Error messages if any
391    #[serde(skip_serializing_if = "Vec::is_empty")]
392    pub errors: Vec<String>,
393}
394
395// =============================================================================
396// LINEAR API CLIENT
397// =============================================================================
398
399/// Simple Linear GraphQL API client for bulk sync
400pub struct LinearClient {
401    api_key: String,
402    api_url: String,
403    client: reqwest::Client,
404}
405
406impl LinearClient {
407    const DEFAULT_API_URL: &'static str = "https://api.linear.app/graphql";
408
409    pub fn new(api_key: String) -> Self {
410        let api_url =
411            std::env::var("LINEAR_API_URL").unwrap_or_else(|_| Self::DEFAULT_API_URL.to_string());
412        Self {
413            api_key,
414            api_url,
415            client: reqwest::Client::new(),
416        }
417    }
418
419    /// Fetch issues from Linear using GraphQL
420    pub async fn fetch_issues(
421        &self,
422        team_id: Option<&str>,
423        updated_after: Option<&str>,
424        limit: Option<usize>,
425    ) -> Result<Vec<LinearIssueData>> {
426        let limit = limit.unwrap_or(250);
427
428        // Build filter
429        let mut filters = Vec::new();
430        if let Some(tid) = team_id {
431            filters.push(format!(r#"team: {{ id: {{ eq: "{}" }} }}"#, tid));
432        }
433        if let Some(after) = updated_after {
434            filters.push(format!(r#"updatedAt: {{ gte: "{}" }}"#, after));
435        }
436
437        let filter_str = if filters.is_empty() {
438            String::new()
439        } else {
440            format!("filter: {{ {} }}", filters.join(", "))
441        };
442
443        let query = format!(
444            r#"
445            query {{
446                issues(first: {}, {}) {{
447                    nodes {{
448                        id
449                        identifier
450                        title
451                        description
452                        priority
453                        priorityLabel
454                        url
455                        createdAt
456                        updatedAt
457                        completedAt
458                        canceledAt
459                        dueDate
460                        estimate
461                        state {{
462                            id
463                            name
464                            color
465                            type
466                        }}
467                        assignee {{
468                            id
469                            name
470                            email
471                        }}
472                        creator {{
473                            id
474                            name
475                            email
476                        }}
477                        labels {{
478                            nodes {{
479                                id
480                                name
481                                color
482                            }}
483                        }}
484                        team {{
485                            id
486                            name
487                            key
488                        }}
489                        project {{
490                            id
491                            name
492                        }}
493                        cycle {{
494                            id
495                            name
496                            number
497                        }}
498                        parent {{
499                            id
500                            identifier
501                            title
502                        }}
503                    }}
504                }}
505            }}
506        "#,
507            limit, filter_str
508        );
509
510        let response = self
511            .client
512            .post(&self.api_url)
513            .header("Authorization", &self.api_key)
514            .header("Content-Type", "application/json")
515            .json(&serde_json::json!({ "query": query }))
516            .send()
517            .await
518            .context("Failed to send request to Linear API")?;
519
520        if !response.status().is_success() {
521            let status = response.status();
522            let body = response.text().await.unwrap_or_default();
523            anyhow::bail!("Linear API error: {} - {}", status, body);
524        }
525
526        let body: serde_json::Value = response
527            .json()
528            .await
529            .context("Failed to parse Linear API response")?;
530
531        // Check for GraphQL errors
532        if let Some(errors) = body.get("errors") {
533            anyhow::bail!("Linear GraphQL errors: {:?}", errors);
534        }
535
536        // Parse issues from response
537        let issues_raw = body
538            .get("data")
539            .and_then(|d| d.get("issues"))
540            .and_then(|i| i.get("nodes"))
541            .context("Unexpected Linear API response structure")?;
542
543        // Transform to our structure (handling nested labels)
544        let issues: Vec<LinearIssueData> = issues_raw
545            .as_array()
546            .context("Expected issues array")?
547            .iter()
548            .filter_map(|issue| {
549                // Handle labels.nodes -> labels transformation
550                let mut issue_obj = issue.clone();
551                if let Some(labels) = issue_obj.get("labels").and_then(|l| l.get("nodes")) {
552                    issue_obj["labels"] = labels.clone();
553                }
554                serde_json::from_value(issue_obj).ok()
555            })
556            .collect();
557
558        Ok(issues)
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn test_issue_to_content() {
568        let issue = LinearIssueData {
569            id: "uuid".to_string(),
570            identifier: Some("SHO-39".to_string()),
571            title: Some("Test Issue".to_string()),
572            description: Some("This is a test".to_string()),
573            priority: Some(2),
574            priority_label: Some("High".to_string()),
575            state: Some(LinearState {
576                id: "state-id".to_string(),
577                name: "In Progress".to_string(),
578                color: None,
579                state_type: None,
580            }),
581            assignee: Some(LinearUser {
582                id: "user-id".to_string(),
583                name: Some("Varun".to_string()),
584                email: None,
585            }),
586            creator: None,
587            labels: vec![LinearLabel {
588                id: "label-id".to_string(),
589                name: "Feature".to_string(),
590                color: None,
591            }],
592            team: None,
593            project: None,
594            cycle: None,
595            parent: None,
596            due_date: None,
597            estimate: None,
598            created_at: None,
599            updated_at: None,
600            completed_at: None,
601            canceled_at: None,
602            url: None,
603        };
604
605        let content = LinearWebhook::issue_to_content(&issue);
606        assert!(content.contains("SHO-39: Test Issue"));
607        assert!(content.contains("Status: In Progress"));
608        assert!(content.contains("Assignee: Varun"));
609        assert!(content.contains("Labels: Feature"));
610        assert!(content.contains("This is a test"));
611    }
612
613    #[test]
614    fn test_issue_to_tags() {
615        let issue = LinearIssueData {
616            id: "uuid".to_string(),
617            identifier: Some("SHO-39".to_string()),
618            title: None,
619            description: None,
620            priority: None,
621            priority_label: None,
622            state: Some(LinearState {
623                id: "state-id".to_string(),
624                name: "In Progress".to_string(),
625                color: None,
626                state_type: None,
627            }),
628            assignee: None,
629            creator: None,
630            labels: vec![LinearLabel {
631                id: "label-id".to_string(),
632                name: "Feature".to_string(),
633                color: None,
634            }],
635            team: Some(LinearTeam {
636                id: "team-id".to_string(),
637                name: Some("Shodh".to_string()),
638                key: Some("SHO".to_string()),
639            }),
640            project: None,
641            cycle: None,
642            parent: None,
643            due_date: None,
644            estimate: None,
645            created_at: None,
646            updated_at: None,
647            completed_at: None,
648            canceled_at: None,
649            url: None,
650        };
651
652        let tags = LinearWebhook::issue_to_tags(&issue);
653        assert!(tags.contains(&"linear".to_string()));
654        assert!(tags.contains(&"SHO-39".to_string()));
655        assert!(tags.contains(&"Feature".to_string()));
656        assert!(tags.contains(&"In Progress".to_string()));
657        assert!(tags.contains(&"SHO".to_string()));
658    }
659}