use anyhow::{Context, Result};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinearWebhookPayload {
pub action: String,
#[serde(default)]
pub actor: Option<LinearActor>,
#[serde(default)]
pub created_at: Option<String>,
pub data: LinearIssueData,
#[serde(rename = "type")]
pub entity_type: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub organization_id: Option<String>,
#[serde(default)]
pub webhook_id: Option<String>,
#[serde(default)]
pub webhook_timestamp: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LinearActor {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(rename = "type")]
#[serde(default)]
pub actor_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinearIssueData {
pub id: String,
#[serde(default)]
pub identifier: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub priority: Option<i32>,
#[serde(default)]
pub priority_label: Option<String>,
#[serde(default)]
pub state: Option<LinearState>,
#[serde(default)]
pub assignee: Option<LinearUser>,
#[serde(default)]
pub creator: Option<LinearUser>,
#[serde(default)]
pub labels: Vec<LinearLabel>,
#[serde(default)]
pub team: Option<LinearTeam>,
#[serde(default)]
pub project: Option<LinearProject>,
#[serde(default)]
pub cycle: Option<LinearCycle>,
#[serde(default)]
pub parent: Option<Box<LinearIssueData>>,
#[serde(default)]
pub due_date: Option<String>,
#[serde(default)]
pub estimate: Option<f32>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub completed_at: Option<String>,
#[serde(default)]
pub canceled_at: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LinearState {
pub id: String,
pub name: String,
#[serde(default)]
pub color: Option<String>,
#[serde(rename = "type")]
#[serde(default)]
pub state_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LinearUser {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LinearLabel {
pub id: String,
pub name: String,
#[serde(default)]
pub color: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LinearTeam {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub key: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LinearProject {
pub id: String,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LinearCycle {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub number: Option<i32>,
}
pub struct LinearWebhook {
signing_secret: Option<String>,
}
impl LinearWebhook {
pub fn new(signing_secret: Option<String>) -> Self {
Self { signing_secret }
}
pub fn verify_signature(&self, body: &[u8], signature: &str) -> Result<bool> {
let secret = match &self.signing_secret {
Some(s) => s,
None => {
tracing::warn!("No signing secret configured, rejecting webhook");
return Ok(false);
}
};
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).context("Invalid signing secret")?;
mac.update(body);
let expected_sig = signature.strip_prefix("sha256=").unwrap_or(signature);
let expected_bytes = hex::decode(expected_sig).context("Invalid signature format")?;
Ok(mac.verify_slice(&expected_bytes).is_ok())
}
pub fn parse_payload(&self, body: &[u8]) -> Result<LinearWebhookPayload> {
serde_json::from_slice(body).context("Failed to parse Linear webhook payload")
}
pub fn issue_to_content(issue: &LinearIssueData) -> String {
let mut parts = Vec::new();
if let Some(id) = &issue.identifier {
if let Some(title) = &issue.title {
parts.push(format!("{}: {}", id, title));
} else {
parts.push(id.clone());
}
} else if let Some(title) = &issue.title {
parts.push(title.clone());
}
let mut metadata = Vec::new();
if let Some(state) = &issue.state {
metadata.push(format!("Status: {}", state.name));
}
if let Some(assignee) = &issue.assignee {
if let Some(name) = &assignee.name {
metadata.push(format!("Assignee: {}", name));
}
}
if let Some(priority) = &issue.priority_label {
metadata.push(format!("Priority: {}", priority));
}
if !issue.labels.is_empty() {
let label_names: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect();
metadata.push(format!("Labels: {}", label_names.join(", ")));
}
if let Some(project) = &issue.project {
if let Some(name) = &project.name {
metadata.push(format!("Project: {}", name));
}
}
if let Some(cycle) = &issue.cycle {
if let Some(name) = &cycle.name {
metadata.push(format!("Cycle: {}", name));
} else if let Some(num) = cycle.number {
metadata.push(format!("Cycle: #{}", num));
}
}
if let Some(due) = &issue.due_date {
metadata.push(format!("Due: {}", due));
}
if let Some(estimate) = issue.estimate {
metadata.push(format!("Estimate: {} points", estimate));
}
if !metadata.is_empty() {
parts.push(metadata.join(" | "));
}
if let Some(desc) = &issue.description {
if !desc.is_empty() {
parts.push(String::new()); parts.push(desc.clone());
}
}
parts.join("\n")
}
pub fn issue_to_tags(issue: &LinearIssueData) -> Vec<String> {
let mut tags = vec!["linear".to_string()];
if let Some(id) = &issue.identifier {
tags.push(id.clone());
}
for label in &issue.labels {
tags.push(label.name.clone());
}
if let Some(state) = &issue.state {
tags.push(state.name.clone());
}
if let Some(team) = &issue.team {
if let Some(key) = &team.key {
tags.push(key.clone());
}
}
if let Some(project) = &issue.project {
if let Some(name) = &project.name {
tags.push(name.clone());
}
}
tags
}
pub fn determine_change_type(action: &str, issue: &LinearIssueData) -> String {
match action {
"create" => "created".to_string(),
"remove" => "content_updated".to_string(), "update" => {
if issue.completed_at.is_some() || issue.canceled_at.is_some() {
"status_changed".to_string()
} else {
"content_updated".to_string()
}
}
_ => "content_updated".to_string(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct LinearSyncRequest {
pub user_id: String,
pub api_key: String,
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub updated_after: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct LinearSyncResponse {
pub synced_count: usize,
pub created_count: usize,
pub updated_count: usize,
pub error_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
}
pub struct LinearClient {
api_key: String,
api_url: String,
client: reqwest::Client,
}
impl LinearClient {
const DEFAULT_API_URL: &'static str = "https://api.linear.app/graphql";
pub fn new(api_key: String) -> Self {
let api_url =
std::env::var("LINEAR_API_URL").unwrap_or_else(|_| Self::DEFAULT_API_URL.to_string());
Self {
api_key,
api_url,
client: reqwest::Client::new(),
}
}
pub async fn fetch_issues(
&self,
team_id: Option<&str>,
updated_after: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<LinearIssueData>> {
let limit = limit.unwrap_or(250);
let mut filters = Vec::new();
if let Some(tid) = team_id {
filters.push(format!(r#"team: {{ id: {{ eq: "{}" }} }}"#, tid));
}
if let Some(after) = updated_after {
filters.push(format!(r#"updatedAt: {{ gte: "{}" }}"#, after));
}
let filter_str = if filters.is_empty() {
String::new()
} else {
format!("filter: {{ {} }}", filters.join(", "))
};
let query = format!(
r#"
query {{
issues(first: {}, {}) {{
nodes {{
id
identifier
title
description
priority
priorityLabel
url
createdAt
updatedAt
completedAt
canceledAt
dueDate
estimate
state {{
id
name
color
type
}}
assignee {{
id
name
email
}}
creator {{
id
name
email
}}
labels {{
nodes {{
id
name
color
}}
}}
team {{
id
name
key
}}
project {{
id
name
}}
cycle {{
id
name
number
}}
parent {{
id
identifier
title
}}
}}
}}
}}
"#,
limit, filter_str
);
let response = self
.client
.post(&self.api_url)
.header("Authorization", &self.api_key)
.header("Content-Type", "application/json")
.json(&serde_json::json!({ "query": query }))
.send()
.await
.context("Failed to send request to Linear API")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Linear API error: {} - {}", status, body);
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse Linear API response")?;
if let Some(errors) = body.get("errors") {
anyhow::bail!("Linear GraphQL errors: {:?}", errors);
}
let issues_raw = body
.get("data")
.and_then(|d| d.get("issues"))
.and_then(|i| i.get("nodes"))
.context("Unexpected Linear API response structure")?;
let issues: Vec<LinearIssueData> = issues_raw
.as_array()
.context("Expected issues array")?
.iter()
.filter_map(|issue| {
let mut issue_obj = issue.clone();
if let Some(labels) = issue_obj.get("labels").and_then(|l| l.get("nodes")) {
issue_obj["labels"] = labels.clone();
}
serde_json::from_value(issue_obj).ok()
})
.collect();
Ok(issues)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_issue_to_content() {
let issue = LinearIssueData {
id: "uuid".to_string(),
identifier: Some("SHO-39".to_string()),
title: Some("Test Issue".to_string()),
description: Some("This is a test".to_string()),
priority: Some(2),
priority_label: Some("High".to_string()),
state: Some(LinearState {
id: "state-id".to_string(),
name: "In Progress".to_string(),
color: None,
state_type: None,
}),
assignee: Some(LinearUser {
id: "user-id".to_string(),
name: Some("Varun".to_string()),
email: None,
}),
creator: None,
labels: vec![LinearLabel {
id: "label-id".to_string(),
name: "Feature".to_string(),
color: None,
}],
team: None,
project: None,
cycle: None,
parent: None,
due_date: None,
estimate: None,
created_at: None,
updated_at: None,
completed_at: None,
canceled_at: None,
url: None,
};
let content = LinearWebhook::issue_to_content(&issue);
assert!(content.contains("SHO-39: Test Issue"));
assert!(content.contains("Status: In Progress"));
assert!(content.contains("Assignee: Varun"));
assert!(content.contains("Labels: Feature"));
assert!(content.contains("This is a test"));
}
#[test]
fn test_issue_to_tags() {
let issue = LinearIssueData {
id: "uuid".to_string(),
identifier: Some("SHO-39".to_string()),
title: None,
description: None,
priority: None,
priority_label: None,
state: Some(LinearState {
id: "state-id".to_string(),
name: "In Progress".to_string(),
color: None,
state_type: None,
}),
assignee: None,
creator: None,
labels: vec![LinearLabel {
id: "label-id".to_string(),
name: "Feature".to_string(),
color: None,
}],
team: Some(LinearTeam {
id: "team-id".to_string(),
name: Some("Shodh".to_string()),
key: Some("SHO".to_string()),
}),
project: None,
cycle: None,
parent: None,
due_date: None,
estimate: None,
created_at: None,
updated_at: None,
completed_at: None,
canceled_at: None,
url: None,
};
let tags = LinearWebhook::issue_to_tags(&issue);
assert!(tags.contains(&"linear".to_string()));
assert!(tags.contains(&"SHO-39".to_string()));
assert!(tags.contains(&"Feature".to_string()));
assert!(tags.contains(&"In Progress".to_string()));
assert!(tags.contains(&"SHO".to_string()));
}
}