1use anyhow::{Context, Result};
9use hmac::{Hmac, Mac};
10use serde::{Deserialize, Serialize};
11use sha2::Sha256;
12
13type HmacSha256 = Hmac<Sha256>;
14
15#[derive(Debug, Clone, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct LinearWebhookPayload {
23 pub action: String,
25 #[serde(default)]
27 pub actor: Option<LinearActor>,
28 #[serde(default)]
30 pub created_at: Option<String>,
31 pub data: LinearIssueData,
33 #[serde(rename = "type")]
35 pub entity_type: String,
36 #[serde(default)]
38 pub url: Option<String>,
39 #[serde(default)]
41 pub organization_id: Option<String>,
42 #[serde(default)]
44 pub webhook_id: Option<String>,
45 #[serde(default)]
47 pub webhook_timestamp: Option<i64>,
48}
49
50#[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#[derive(Debug, Clone, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct LinearIssueData {
65 pub id: String,
67 #[serde(default)]
69 pub identifier: Option<String>,
70 #[serde(default)]
72 pub title: Option<String>,
73 #[serde(default)]
75 pub description: Option<String>,
76 #[serde(default)]
78 pub priority: Option<i32>,
79 #[serde(default)]
81 pub priority_label: Option<String>,
82 #[serde(default)]
84 pub state: Option<LinearState>,
85 #[serde(default)]
87 pub assignee: Option<LinearUser>,
88 #[serde(default)]
90 pub creator: Option<LinearUser>,
91 #[serde(default)]
93 pub labels: Vec<LinearLabel>,
94 #[serde(default)]
96 pub team: Option<LinearTeam>,
97 #[serde(default)]
99 pub project: Option<LinearProject>,
100 #[serde(default)]
102 pub cycle: Option<LinearCycle>,
103 #[serde(default)]
105 pub parent: Option<Box<LinearIssueData>>,
106 #[serde(default)]
108 pub due_date: Option<String>,
109 #[serde(default)]
111 pub estimate: Option<f32>,
112 #[serde(default)]
114 pub created_at: Option<String>,
115 #[serde(default)]
117 pub updated_at: Option<String>,
118 #[serde(default)]
120 pub completed_at: Option<String>,
121 #[serde(default)]
123 pub canceled_at: Option<String>,
124 #[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
182pub struct LinearWebhook {
188 signing_secret: Option<String>,
190}
191
192impl LinearWebhook {
193 pub fn new(signing_secret: Option<String>) -> Self {
195 Self { signing_secret }
196 }
197
198 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 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 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 pub fn issue_to_content(issue: &LinearIssueData) -> String {
231 let mut parts = Vec::new();
232
233 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 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 if let Some(desc) = &issue.description {
294 if !desc.is_empty() {
295 parts.push(String::new()); parts.push(desc.clone());
297 }
298 }
299
300 parts.join("\n")
301 }
302
303 pub fn issue_to_tags(issue: &LinearIssueData) -> Vec<String> {
305 let mut tags = vec!["linear".to_string()];
306
307 if let Some(id) = &issue.identifier {
309 tags.push(id.clone());
310 }
311
312 for label in &issue.labels {
314 tags.push(label.name.clone());
315 }
316
317 if let Some(state) = &issue.state {
319 tags.push(state.name.clone());
320 }
321
322 if let Some(team) = &issue.team {
324 if let Some(key) = &team.key {
325 tags.push(key.clone());
326 }
327 }
328
329 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 pub fn determine_change_type(action: &str, issue: &LinearIssueData) -> String {
341 match action {
342 "create" => "created".to_string(),
343 "remove" => "content_updated".to_string(), "update" => {
345 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#[derive(Debug, Deserialize)]
363pub struct LinearSyncRequest {
364 pub user_id: String,
366 pub api_key: String,
368 #[serde(default)]
370 pub team_id: Option<String>,
371 #[serde(default)]
373 pub updated_after: Option<String>,
374 #[serde(default)]
376 pub limit: Option<usize>,
377}
378
379#[derive(Debug, Serialize)]
381pub struct LinearSyncResponse {
382 pub synced_count: usize,
384 pub created_count: usize,
386 pub updated_count: usize,
388 pub error_count: usize,
390 #[serde(skip_serializing_if = "Vec::is_empty")]
392 pub errors: Vec<String>,
393}
394
395pub 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 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 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 if let Some(errors) = body.get("errors") {
533 anyhow::bail!("Linear GraphQL errors: {:?}", errors);
534 }
535
536 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 let issues: Vec<LinearIssueData> = issues_raw
545 .as_array()
546 .context("Expected issues array")?
547 .iter()
548 .filter_map(|issue| {
549 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}