#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use cratefield_core::{
Clock, Credential, Destination, Filed, HttpClient, HttpError, InboundStatusError,
ProviderScheme, SignatureEncoding, StatusUpdate, StatusWebhook, TicketComment, TicketDraft,
TicketState, TicketStatus, Tracker, TrackerError, WebhookVerifier, retry_after,
};
use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderValue};
use http::{Request, StatusCode};
use serde::Deserialize;
use sha2::{Digest, Sha256};
const GRAPHQL_URL: &str = "https://api.linear.app/graphql";
const PERSONAL_KEY_PREFIX: &str = "lin_api_";
const MAX_TITLE_CHARS: usize = 255;
const IDEM_PREFIX: &str = "cratefield-idem-";
const ISSUE_CREATE: &str = "mutation IssueCreate($input: IssueCreateInput!) { \
issueCreate(input: $input) { success issue { id url } } }";
const ISSUE_QUERY: &str = "query Issue($id: String!) { issue(id: $id) { id url state { type } } }";
const COMMENT_CREATE: &str = "mutation CommentCreate($input: CommentCreateInput!) { commentCreate(input: $input) { success } }";
pub struct LinearTracker {
http: Arc<dyn HttpClient>,
clock: Arc<dyn Clock>,
base: Option<String>,
}
impl std::fmt::Debug for LinearTracker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LinearTracker")
.field("base", &self.base)
.finish_non_exhaustive()
}
}
impl LinearTracker {
#[must_use]
pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
Self {
http,
clock,
base: None,
}
}
#[must_use]
pub fn with_base(mut self, base: impl Into<String>) -> Self {
self.base = Some(base.into());
self
}
fn endpoint(&self) -> &str {
self.base.as_deref().unwrap_or(GRAPHQL_URL)
}
fn request(cred: &Credential, uri: &str, body: Bytes) -> Result<Request<Bytes>, TrackerError> {
let secret = cred.expose();
if secret.is_empty() {
return Err(TrackerError::NotConfigured);
}
let malformed =
|| TrackerError::Rejected("linear credential is not a legal header value".to_owned());
if !secret.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
return Err(malformed());
}
let value = HeaderValue::from_str(&auth_header(secret)).map_err(|_| malformed())?;
Request::builder()
.method(http::Method::POST)
.uri(uri)
.header(AUTHORIZATION, value)
.header(ACCEPT, "application/json")
.header(CONTENT_TYPE, "application/json")
.body(body)
.map_err(|_| TrackerError::Rejected("linear request could not be built".into()))
}
async fn send_checked(&self, request: Request<Bytes>) -> Result<String, TrackerError> {
let response = self.http.send(request).await.map_err(|err: HttpError| {
match err {
HttpError::BlockedDestination(detail) => {
TrackerError::Rejected(format!("linear destination refused: {detail}"))
}
other => {
tracing::warn!(provider = "linear", outcome = "failed", error = %other, "tracker outcome");
TrackerError::Transient { retry_after: None }
}
}
})?;
let status = response.status();
let text = String::from_utf8_lossy(response.body()).to_string();
if !status.is_success() {
let delay = retry_after(response.headers(), self.clock.as_ref());
let error = Self::map_status(status, &text, delay);
tracing::warn!(
provider = "linear",
code = status.as_u16(),
outcome = "failed",
detail = %text,
error = %error,
"tracker outcome"
);
return Err(error);
}
Ok(text)
}
fn map_status(
status: StatusCode,
body: &str,
retry_after: Option<std::time::Duration>,
) -> TrackerError {
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TrackerError::Unauthorized,
status if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
TrackerError::Transient { retry_after }
}
status if status.is_client_error() => {
TrackerError::Rejected(format!("linear {status}: {}", flatten_errors(body)))
}
_ => TrackerError::Transient { retry_after },
}
}
async fn graphql(
&self,
cred: &Credential,
query: &str,
variables: serde_json::Value,
accepted: impl Fn(&Payload) -> bool,
) -> Result<Payload, TrackerError> {
let body = serde_json::json!({ "query": query, "variables": variables });
let json =
serde_json::to_vec(&body).map_err(|_| TrackerError::Transient { retry_after: None })?;
let request = Self::request(cred, self.endpoint(), Bytes::from(json))?;
let text = self.send_checked(request).await?;
let response: Response = serde_json::from_str(&text)
.map_err(|_| TrackerError::Transient { retry_after: None })?;
if response.errors.is_empty() || accepted(&response.data) {
return Ok(response.data);
}
Err(map_errors(&response.errors))
}
}
#[async_trait]
impl Tracker for LinearTracker {
async fn file(
&self,
dest: &Destination,
cred: &Credential,
draft: &TicketDraft,
) -> Result<Filed, TrackerError> {
let team = team_of(dest)?;
let input = serde_json::json!({
"teamId": team,
"title": truncate_title(&draft.title),
"description": description(draft),
});
let data = self
.graphql(
cred,
ISSUE_CREATE,
serde_json::json!({ "input": input }),
|data| {
data.issue_create
.as_ref()
.is_some_and(|made| made.success == Some(true))
},
)
.await?;
let created = data
.issue_create
.as_ref()
.filter(|made| made.success != Some(false))
.ok_or_else(|| {
TrackerError::Rejected("linear issueCreate reported success: false".to_owned())
})?;
let issue = created
.issue
.as_ref()
.ok_or(TrackerError::Transient { retry_after: None })?;
let external_id = issue
.id
.as_deref()
.ok_or(TrackerError::Transient { retry_after: None })?;
let url = issue
.url
.as_deref()
.ok_or(TrackerError::Transient { retry_after: None })?;
tracing::info!(
provider = "linear",
outcome = "created",
idempotency = %draft.idempotency_key,
issue = %external_id,
"tracker outcome"
);
Ok(Filed {
external_id: external_id.to_owned(),
url: url.to_owned(),
})
}
async fn status(
&self,
dest: &Destination,
cred: &Credential,
external_id: &str,
) -> Result<TicketStatus, TrackerError> {
team_of(dest)?;
validate_external_id(external_id)?;
let data = self
.graphql(
cred,
ISSUE_QUERY,
serde_json::json!({ "id": external_id }),
|_| false,
)
.await?;
let issue = data.issue.as_ref().ok_or_else(|| {
TrackerError::Rejected(format!("linear has no issue {external_id}"))
})?;
Ok(TicketStatus {
external_id: external_id.to_owned(),
state: state_of(issue.state.as_ref().and_then(|state| state.kind.as_deref())),
url: issue.url.clone(),
})
}
async fn comment(
&self,
dest: &Destination,
cred: &Credential,
external_id: &str,
comment: &TicketComment,
) -> Result<(), TrackerError> {
team_of(dest)?;
validate_external_id(external_id)?;
let body = match comment.link.as_deref() {
Some(link)
if (link.starts_with("https://") || link.starts_with("http://"))
&& !link.chars().any(char::is_control) =>
{
format!("{}\n\n{link}", comment.body_markdown.trim_end())
}
Some(_) => {
return Err(TrackerError::Rejected(
"linear comment link must be an http(s) URL with no control characters"
.to_owned(),
));
}
None => comment.body_markdown.clone(),
};
let input = serde_json::json!({
"issueId": external_id,
"body": format!("{body}\n\n---\n\n{IDEM_PREFIX}{}", idem_hash(&comment.idempotency_key)),
});
let data = self
.graphql(
cred,
COMMENT_CREATE,
serde_json::json!({ "input": input }),
|data| {
data.comment_create
.as_ref()
.is_some_and(|made| made.success == Some(true))
},
)
.await?;
if data
.comment_create
.as_ref()
.is_none_or(|made| made.success == Some(false))
{
return Err(TrackerError::Rejected(
"linear commentCreate reported success: false".to_owned(),
));
}
tracing::info!(
provider = "linear",
outcome = "commented",
idempotency = %comment.idempotency_key,
issue = external_id,
"tracker outcome"
);
Ok(())
}
}
#[derive(Deserialize, Default)]
struct Response {
#[serde(default)]
errors: Vec<GraphqlError>,
#[serde(default)]
data: Payload,
}
#[derive(Deserialize, Default)]
struct Payload {
#[serde(rename = "issueCreate")]
issue_create: Option<Mutation>,
#[serde(rename = "commentCreate")]
comment_create: Option<Mutation>,
issue: Option<Issue>,
}
#[derive(Deserialize, Default)]
struct Mutation {
#[serde(default)]
success: Option<bool>,
#[serde(default)]
issue: Option<Issue>,
}
#[derive(Deserialize)]
struct Issue {
#[serde(default)]
id: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
state: Option<WorkflowState>,
}
#[derive(Deserialize)]
struct WorkflowState {
#[serde(rename = "type", default)]
kind: Option<String>,
}
#[derive(Deserialize, Default)]
struct GraphqlError {
#[serde(default)]
message: Option<String>,
#[serde(default)]
extensions: Option<GraphqlErrorExtensions>,
}
#[derive(Deserialize, Default)]
struct GraphqlErrorExtensions {
#[serde(default)]
code: Option<String>,
}
pub struct LinearStatusWebhook;
impl StatusWebhook for LinearStatusWebhook {
fn kind(&self) -> &'static str {
"linear"
}
fn verifier(&self) -> WebhookVerifier {
WebhookVerifier::new(ProviderScheme {
signature: "Linear-Signature",
encoding: SignatureEncoding::Hex,
prefix: None,
timestamp: None,
})
}
fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError> {
let event: Event = serde_json::from_slice(body)
.map_err(|error| InboundStatusError::Malformed(error.to_string()))?;
if event.kind.as_deref() != Some("Issue") || event.action.as_deref() != Some("update") {
return Ok(None);
}
let data = event.data.as_ref();
let Some(state_type) = data
.and_then(|data| data.state.as_ref())
.and_then(|state| state.kind.as_deref())
else {
return Ok(None);
};
if !event.state_changed() {
return Ok(None);
}
let external_id = data.and_then(|data| data.id.as_deref()).ok_or_else(|| {
InboundStatusError::Malformed("a Linear state change carried no issue id".to_owned())
})?;
Ok(Some(StatusUpdate {
external_id: external_id.to_owned(),
state: state_of(Some(state_type)),
url: event.url.clone(),
}))
}
}
#[derive(Deserialize)]
struct Event {
#[serde(rename = "type", default)]
kind: Option<String>,
#[serde(default)]
action: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
data: Option<EventData>,
#[serde(rename = "updatedFrom", default)]
updated_from: Option<UpdatedFrom>,
}
impl Event {
fn state_changed(&self) -> bool {
self.updated_from
.as_ref()
.is_some_and(|from| from.state_id.is_some())
}
}
#[derive(Deserialize)]
struct EventData {
#[serde(default)]
id: Option<String>,
#[serde(default)]
state: Option<WorkflowState>,
}
#[derive(Deserialize)]
struct UpdatedFrom {
#[serde(rename = "stateId", default)]
state_id: Option<String>,
}
fn auth_header(secret: &str) -> String {
if secret.starts_with(PERSONAL_KEY_PREFIX) {
secret.to_owned()
} else {
format!("Bearer {secret}")
}
}
fn map_errors(errors: &[GraphqlError]) -> TrackerError {
let code = |wanted: &[&str]| {
errors
.iter()
.filter_map(|error| error.extensions.as_ref())
.filter_map(|extensions| extensions.code.as_deref())
.any(|code| wanted.contains(&code))
};
if code(&["AUTHENTICATION", "AUTHORIZATION", "INVALID_TOKEN"]) {
return TrackerError::Unauthorized;
}
if code(&[
"RATELIMITED",
"INTERNAL_SERVER_ERROR",
"SERVICE_UNAVAILABLE",
"TIMEOUT",
]) {
return TrackerError::Transient { retry_after: None };
}
TrackerError::Rejected(format!("linear graphql error: {}", join_messages(errors)))
}
fn join_messages(errors: &[GraphqlError]) -> String {
let detail = errors
.iter()
.filter_map(|error| error.message.as_deref())
.filter(|message| !message.is_empty())
.collect::<Vec<_>>()
.join("; ");
if detail.is_empty() {
"the response carried no message".to_owned()
} else {
detail
}
}
fn flatten_errors(body: &str) -> String {
match serde_json::from_str::<Response>(body) {
Ok(response) if !response.errors.is_empty() => join_messages(&response.errors),
_ => body.to_owned(),
}
}
fn team_of(dest: &Destination) -> Result<&str, TrackerError> {
match dest {
Destination::Linear { team } => {
validate_team(team)?;
Ok(team.as_str())
}
other => Err(TrackerError::unsupported_destination(other)),
}
}
fn validate_team(team: &str) -> Result<(), TrackerError> {
let valid = !team.is_empty()
&& team.len() <= 64
&& team
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if valid {
Ok(())
} else {
Err(TrackerError::Rejected(
"linear team must be a team UUID or key (letters, digits, '-' and '_')".to_owned(),
))
}
}
fn validate_external_id(external_id: &str) -> Result<(), TrackerError> {
let is_uuid = external_id.len() == 36
&& external_id
.chars()
.all(|c| c.is_ascii_hexdigit() || c == '-')
&& external_id.matches('-').count() == 4;
let is_identifier = match external_id.split_once('-') {
Some((key, number)) => {
!key.is_empty()
&& !number.is_empty()
&& number.bytes().all(|b| b.is_ascii_digit())
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
None => false,
};
if is_uuid || is_identifier {
Ok(())
} else {
Err(TrackerError::Rejected(
"linear external id must be an issue UUID or an ENG-123 identifier".to_owned(),
))
}
}
fn description(draft: &TicketDraft) -> String {
let mut facts = vec![format!("severity/{}", draft.severity.name())];
facts.extend(draft.labels.iter().map(|label| format!("label/{label}")));
if let Some(environment) = &draft.environment {
facts.push(format!("environment/{environment}"));
}
format!(
"{}\n\n---\n\n{}\n\n{IDEM_PREFIX}{}",
draft.body_markdown.trim_end(),
facts.join(" · "),
idem_hash(&draft.idempotency_key)
)
}
fn idem_hash(idempotency_key: &str) -> String {
hex::encode(&Sha256::digest(idempotency_key.as_bytes())[..16])
}
fn truncate_title(title: &str) -> &str {
match title.char_indices().nth(MAX_TITLE_CHARS) {
Some((at, _)) => &title[..at],
None => title,
}
}
fn state_of(state_type: Option<&str>) -> TicketState {
match state_type {
Some("triage" | "backlog" | "unstarted") => TicketState::Open,
Some("started") => TicketState::InProgress,
Some("completed") => TicketState::Resolved,
Some("canceled") => TicketState::Closed,
_ => TicketState::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_long_title_is_truncated_on_a_character_boundary() {
assert_eq!(truncate_title("short"), "short");
let long = "é".repeat(400);
assert_eq!(truncate_title(&long).chars().count(), MAX_TITLE_CHARS);
}
}