use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zeroize::Zeroizing;
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Destination {
GitHub {
owner: String,
repo: String,
},
Jira {
site: String,
project: String,
},
Linear {
team: String,
},
Zendesk {
subdomain: String,
},
Intercom,
Salesforce {
instance: String,
},
HubSpot,
Slack {
channel: String,
},
Webhook {
url: String,
},
}
impl Destination {
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Destination::GitHub { .. } => "github",
Destination::Jira { .. } => "jira",
Destination::Linear { .. } => "linear",
Destination::Zendesk { .. } => "zendesk",
Destination::Intercom => "intercom",
Destination::Salesforce { .. } => "salesforce",
Destination::HubSpot => "hubspot",
Destination::Slack { .. } => "slack",
Destination::Webhook { .. } => "webhook",
}
}
}
impl std::fmt::Debug for Destination {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Destination::GitHub { owner, repo } => write!(
f,
"Destination::GitHub {{ owner: {owner:?}, repo: {repo:?} }}"
),
Destination::Jira { site, project } => write!(
f,
"Destination::Jira {{ site: {site:?}, project: {project:?} }}"
),
Destination::Linear { team } => {
write!(f, "Destination::Linear {{ team: {team:?} }}")
}
Destination::Zendesk { subdomain } => {
write!(f, "Destination::Zendesk {{ subdomain: {subdomain:?} }}")
}
Destination::Intercom => f.write_str("Destination::Intercom"),
Destination::Salesforce { instance } => {
write!(f, "Destination::Salesforce {{ instance: {instance:?} }}")
}
Destination::HubSpot => f.write_str("Destination::HubSpot"),
Destination::Slack { channel } => {
write!(f, "Destination::Slack {{ channel: {channel:?} }}")
}
Destination::Webhook { .. } => f.write_str("Destination::Webhook { url: [redacted] }"),
}
}
}
#[derive(Clone)]
pub struct Credential(Zeroizing<String>);
impl Credential {
#[must_use]
pub fn new(secret: impl Into<String>) -> Self {
Self(Zeroizing::new(secret.into()))
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Credential([redacted])")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Info,
Warning,
Error,
Critical,
}
impl Severity {
pub fn name(&self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
Severity::Critical => "critical",
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketDraft {
pub idempotency_key: String,
pub title: String,
pub body_markdown: String,
pub severity: Severity,
pub labels: Vec<String>,
pub environment: Option<String>,
}
impl TicketDraft {
#[must_use]
pub fn new(
idempotency_key: impl Into<String>,
title: impl Into<String>,
body_markdown: impl Into<String>,
severity: Severity,
) -> Self {
Self {
idempotency_key: idempotency_key.into(),
title: title.into(),
body_markdown: body_markdown.into(),
severity,
labels: Vec::new(),
environment: None,
}
}
#[must_use]
pub fn labels(mut self, labels: Vec<String>) -> Self {
self.labels = labels;
self
}
#[must_use]
pub fn environment(mut self, environment: impl Into<String>) -> Self {
self.environment = Some(environment.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Filed {
pub external_id: String,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TicketState {
Open,
InProgress,
Resolved,
Closed,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketStatus {
pub external_id: String,
pub state: TicketState,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TrackerError {
#[error("tracker is not configured")]
NotConfigured,
#[error("tracker refused the credential")]
Unauthorized,
#[error("tracker rejected the request: {scrubbed}", scrubbed = crate::logging::scrub_text(.0))]
Rejected(String),
#[error(
"tracker request failed, retryable: {delay}",
delay = retry_after_description(.retry_after)
)]
Transient {
retry_after: Option<Duration>,
},
}
impl TrackerError {
#[must_use]
pub fn unsupported_destination(dest: &Destination) -> Self {
TrackerError::Rejected(format!(
"unsupported destination: this adapter does not serve {}",
dest.kind()
))
}
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
match self {
TrackerError::Transient { retry_after } => *retry_after,
_ => None,
}
}
}
#[allow(clippy::ref_option)]
fn retry_after_description(retry_after: &Option<Duration>) -> String {
match retry_after {
Some(after) => format!("retry after {}s", after.as_secs()),
None => "no delay was stated".to_owned(),
}
}
#[async_trait]
pub trait Tracker: Send + Sync {
async fn file(
&self,
dest: &Destination,
cred: &Credential,
draft: &TicketDraft,
) -> Result<Filed, TrackerError>;
async fn status(
&self,
dest: &Destination,
cred: &Credential,
external_id: &str,
) -> Result<TicketStatus, TrackerError>;
}
#[derive(Default, Clone)]
pub struct RoutingTracker {
github: Option<Arc<dyn Tracker>>,
jira: Option<Arc<dyn Tracker>>,
linear: Option<Arc<dyn Tracker>>,
zendesk: Option<Arc<dyn Tracker>>,
intercom: Option<Arc<dyn Tracker>>,
salesforce: Option<Arc<dyn Tracker>>,
hubspot: Option<Arc<dyn Tracker>>,
slack: Option<Arc<dyn Tracker>>,
webhook: Option<Arc<dyn Tracker>>,
}
impl RoutingTracker {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn github(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.github = Some(tracker);
self
}
#[must_use]
pub fn jira(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.jira = Some(tracker);
self
}
#[must_use]
pub fn linear(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.linear = Some(tracker);
self
}
#[must_use]
pub fn zendesk(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.zendesk = Some(tracker);
self
}
#[must_use]
pub fn intercom(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.intercom = Some(tracker);
self
}
#[must_use]
pub fn salesforce(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.salesforce = Some(tracker);
self
}
#[must_use]
pub fn hubspot(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.hubspot = Some(tracker);
self
}
#[must_use]
pub fn slack(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.slack = Some(tracker);
self
}
#[must_use]
pub fn webhook(mut self, tracker: Arc<dyn Tracker>) -> Self {
self.webhook = Some(tracker);
self
}
#[must_use]
pub fn route_for(&self, destination: &Destination) -> Option<&Arc<dyn Tracker>> {
match destination {
Destination::GitHub { .. } => self.github.as_ref(),
Destination::Jira { .. } => self.jira.as_ref(),
Destination::Linear { .. } => self.linear.as_ref(),
Destination::Zendesk { .. } => self.zendesk.as_ref(),
Destination::Intercom => self.intercom.as_ref(),
Destination::Salesforce { .. } => self.salesforce.as_ref(),
Destination::HubSpot => self.hubspot.as_ref(),
Destination::Slack { .. } => self.slack.as_ref(),
Destination::Webhook { .. } => self.webhook.as_ref(),
}
}
}
impl std::fmt::Debug for RoutingTracker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RoutingTracker")
.field("github", &self.github.is_some())
.field("jira", &self.jira.is_some())
.field("linear", &self.linear.is_some())
.field("zendesk", &self.zendesk.is_some())
.field("intercom", &self.intercom.is_some())
.field("salesforce", &self.salesforce.is_some())
.field("hubspot", &self.hubspot.is_some())
.field("slack", &self.slack.is_some())
.field("webhook", &self.webhook.is_some())
.finish()
}
}
#[async_trait]
impl Tracker for RoutingTracker {
async fn file(
&self,
dest: &Destination,
cred: &Credential,
draft: &TicketDraft,
) -> Result<Filed, TrackerError> {
match self.route_for(dest) {
Some(tracker) => tracker.file(dest, cred, draft).await,
None => Err(TrackerError::NotConfigured),
}
}
async fn status(
&self,
dest: &Destination,
cred: &Credential,
external_id: &str,
) -> Result<TicketStatus, TrackerError> {
match self.route_for(dest) {
Some(tracker) => tracker.status(dest, cred, external_id).await,
None => Err(TrackerError::NotConfigured),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_destination_round_trips_through_json() {
for dest in all_destinations() {
let json = serde_json::to_string(&dest).expect("serialises");
let back: Destination = serde_json::from_str(&json).expect("deserialises");
assert_eq!(dest, back);
}
}
fn all_destinations() -> Vec<Destination> {
vec![
Destination::GitHub {
owner: "acme".to_owned(),
repo: "api".to_owned(),
},
Destination::Jira {
site: "acme".to_owned(),
project: "PROJ".to_owned(),
},
Destination::Linear {
team: "Platform".to_owned(),
},
Destination::Zendesk {
subdomain: "acme".to_owned(),
},
Destination::Intercom,
Destination::Salesforce {
instance: "acme.my.salesforce.com".to_owned(),
},
Destination::HubSpot,
Destination::Slack {
channel: "C0123456789".to_owned(),
},
Destination::Webhook {
url: "https://hooks.example.test/services/TOKEN/SECRET".to_owned(),
},
]
}
#[test]
fn every_destination_kind_names_itself() {
let kinds: Vec<&'static str> = all_destinations().iter().map(Destination::kind).collect();
assert_eq!(
kinds,
[
"github",
"jira",
"linear",
"zendesk",
"intercom",
"salesforce",
"hubspot",
"slack",
"webhook",
]
);
}
#[test]
fn the_webhook_debug_prints_no_url() {
#[derive(Debug)]
struct Row {
dest: Destination,
}
let row = Row {
dest: Destination::Webhook {
url: "https://hooks.example.test/services/TOKEN/SECRET".to_owned(),
},
};
let printed = format!("{row:?}");
assert!(printed.contains("Webhook"), "{printed}");
assert!(printed.contains("[redacted]"), "{printed}");
assert!(!printed.contains("hooks.example.test"), "{printed}");
assert!(!printed.contains("TOKEN"), "{printed}");
assert!(!printed.contains("SECRET"), "{printed}");
assert!(!printed.contains("https"), "{printed}");
assert!(matches!(row.dest, Destination::Webhook { .. }));
}
#[test]
fn the_other_destinations_debug_print_their_routing_fields() {
let printed = format!(
"{:?}",
Destination::GitHub {
owner: "acme".to_owned(),
repo: "api".to_owned(),
}
);
assert!(printed.contains("acme"), "{printed}");
assert!(printed.contains("api"), "{printed}");
let printed = format!("{:?}", Destination::Intercom);
assert_eq!(printed, "Destination::Intercom");
let printed = format!("{:?}", Destination::HubSpot);
assert_eq!(printed, "Destination::HubSpot");
}
#[test]
fn the_credential_debug_prints_nothing_but_redacted() {
#[derive(Debug)]
struct Row {
cred: Credential,
}
let row = Row {
cred: Credential::new("ghp_4a1b2c3d4e5fREALtokenVALUEnobodyshouldsee"),
};
let printed = format!("{row:?}");
assert!(!printed.contains("ghp_"), "{printed}");
assert!(!printed.contains("REALtokenVALUE"), "{printed}");
assert!(printed.contains("Credential([redacted])"), "{printed}");
assert_eq!(
row.cred.expose(),
"ghp_4a1b2c3d4e5fREALtokenVALUEnobodyshouldsee"
);
}
#[test]
fn the_credential_exposes_its_text_once_wrapped() {
let cred = Credential::new("token-from-secrets");
assert_eq!(cred.expose(), "token-from-secrets");
let copy = cred.clone();
assert_eq!(copy.expose(), "token-from-secrets");
}
#[test]
fn a_draft_builds_through_the_chaining_setters() {
let draft = TicketDraft::new(
"outbox-42",
"Checkout failing",
"Stripe 500s on `/v1/charges`.",
Severity::Error,
)
.labels(vec!["billing".to_owned(), "incident".to_owned()])
.environment("production");
assert_eq!(draft.idempotency_key, "outbox-42");
assert_eq!(draft.title, "Checkout failing");
assert_eq!(draft.severity, Severity::Error);
assert_eq!(draft.labels, ["billing", "incident"]);
assert_eq!(draft.environment.as_deref(), Some("production"));
}
#[test]
fn a_minimal_draft_has_no_labels_or_environment() {
let draft = TicketDraft::new("k", "t", "b", Severity::Info);
assert!(draft.labels.is_empty());
assert_eq!(draft.environment, None);
}
#[test]
fn severity_names_are_snake_case_on_the_wire_and_in_display() {
for (severity, name) in [
(Severity::Info, "info"),
(Severity::Warning, "warning"),
(Severity::Error, "error"),
(Severity::Critical, "critical"),
] {
assert_eq!(severity.name(), name);
assert_eq!(severity.to_string(), name);
let json = serde_json::to_value(severity).unwrap();
assert_eq!(json, name);
let back: Severity = serde_json::from_value(json).unwrap();
assert_eq!(back, severity);
}
}
#[test]
fn transient_carries_an_optional_retry_after() {
let plain = TrackerError::Transient { retry_after: None };
assert_eq!(plain.retry_after(), None);
let throttled = TrackerError::Transient {
retry_after: Some(Duration::from_secs(30)),
};
assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
assert_eq!(TrackerError::NotConfigured.retry_after(), None);
assert_eq!(TrackerError::Unauthorized.retry_after(), None);
assert_eq!(
TrackerError::Rejected("jira 401".to_owned()).retry_after(),
None
);
}
#[test]
fn the_transient_display_states_the_delay_where_one_was_given() {
let error = TrackerError::Transient {
retry_after: Some(Duration::from_secs(30)),
};
let text = error.to_string();
assert!(text.contains("retryable"), "{text}");
assert!(text.contains("30s"), "{text}");
let text = TrackerError::Transient { retry_after: None }.to_string();
assert!(text.contains("retryable"), "{text}");
assert!(text.contains("no delay"), "{text}");
}
#[test]
fn display_sanitizes_the_rejection_text() {
let error = TrackerError::Rejected(
"webhook 400 for https://hooks.example.test/services/TOKEN?token=SECRET".to_owned(),
);
let text = error.to_string();
assert!(!text.contains("SECRET"), "{text}");
assert!(text.contains("?[redacted]"), "{text}");
let error = TrackerError::Rejected("jira 403 for devops@acme.test".to_owned());
let text = error.to_string();
assert!(!text.contains('@'), "{text}");
assert!(text.contains("[subject_hash:"), "{text}");
assert!(format!("{error:?}").contains("devops@acme.test"));
assert_eq!(
TrackerError::NotConfigured.to_string(),
"tracker is not configured"
);
}
#[test]
fn an_unsupported_destination_names_the_kind() {
let error = TrackerError::unsupported_destination(&Destination::Slack {
channel: "C0123456789".to_owned(),
});
let message = error.to_string();
assert!(message.contains("unsupported destination"), "{message}");
assert!(message.contains("slack"), "{message}");
}
#[test]
fn an_unauthorized_credential_is_distinct_from_a_rejected_ticket() {
let error = TrackerError::Unauthorized;
assert_eq!(error.to_string(), "tracker refused the credential");
assert_eq!(error.retry_after(), None, "an auth failure names no delay");
assert_ne!(error, TrackerError::Rejected("401".to_owned()));
assert!(format!("{error:?}").contains("Unauthorized"));
}
struct Recording {
seen: std::sync::atomic::AtomicUsize,
}
impl Recording {
fn new() -> Arc<Self> {
Arc::new(Self {
seen: std::sync::atomic::AtomicUsize::new(0),
})
}
fn count(&self) -> usize {
self.seen.load(std::sync::atomic::Ordering::Relaxed)
}
}
#[async_trait]
impl Tracker for Recording {
async fn file(
&self,
_dest: &Destination,
_cred: &Credential,
_draft: &TicketDraft,
) -> Result<Filed, TrackerError> {
self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(Filed {
external_id: "PROJ-7".to_owned(),
url: "https://tracker.example.test/browse/PROJ-7".to_owned(),
})
}
async fn status(
&self,
_dest: &Destination,
_cred: &Credential,
external_id: &str,
) -> Result<TicketStatus, TrackerError> {
self.seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(TicketStatus {
external_id: external_id.to_owned(),
state: TicketState::InProgress,
url: None,
})
}
}
fn a_draft() -> TicketDraft {
TicketDraft::new("k", "t", "b", Severity::Info)
}
#[test]
fn an_empty_router_is_not_configured_for_every_destination() {
let router = RoutingTracker::new();
for dest in all_destinations() {
let filed = pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()));
assert_eq!(filed.unwrap_err(), TrackerError::NotConfigured, "{dest:?}");
let status = pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"));
assert_eq!(status.unwrap_err(), TrackerError::NotConfigured, "{dest:?}");
assert!(router.route_for(&dest).is_none());
}
}
#[test]
fn a_wired_slot_reaches_its_adapter_and_the_rest_stay_not_configured() {
let github = Recording::new();
let router = RoutingTracker::new().github(github.clone());
let dest = Destination::GitHub {
owner: "acme".to_owned(),
repo: "api".to_owned(),
};
let filed = pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()))
.expect("the wired adapter answers");
assert_eq!(filed.external_id, "PROJ-7");
let status = pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"))
.expect("the wired adapter answers");
assert_eq!(status.state, TicketState::InProgress);
assert_eq!(github.count(), 2);
assert!(router.route_for(&dest).is_some());
let other = Destination::Slack {
channel: "C0123456789".to_owned(),
};
let error =
pollster::block_on(router.file(&other, &Credential::new("t"), &a_draft())).unwrap_err();
assert_eq!(error, TrackerError::NotConfigured);
assert_eq!(github.count(), 2, "the wired adapter was not called");
assert!(router.route_for(&other).is_none());
}
#[test]
fn every_destination_dispatches_to_the_adapter_wired_for_its_slot() {
let github = Recording::new();
let jira = Recording::new();
let linear = Recording::new();
let zendesk = Recording::new();
let intercom = Recording::new();
let salesforce = Recording::new();
let hubspot = Recording::new();
let slack = Recording::new();
let webhook = Recording::new();
let router = RoutingTracker::new()
.github(github.clone())
.jira(jira.clone())
.linear(linear.clone())
.zendesk(zendesk.clone())
.intercom(intercom.clone())
.salesforce(salesforce.clone())
.hubspot(hubspot.clone())
.slack(slack.clone())
.webhook(webhook.clone());
let slots = [
github, jira, linear, zendesk, intercom, salesforce, hubspot, slack, webhook,
];
for (dest, adapter) in all_destinations().into_iter().zip(slots) {
let wired: Arc<dyn Tracker> = adapter.clone();
assert!(
Arc::ptr_eq(router.route_for(&dest).expect("wired"), &wired),
"{dest:?} routed to the wrong slot",
);
pollster::block_on(router.file(&dest, &Credential::new("t"), &a_draft()))
.expect("the wired adapter answers");
assert_eq!(adapter.count(), 1, "{dest:?} reached the wrong adapter");
pollster::block_on(router.status(&dest, &Credential::new("t"), "PROJ-7"))
.expect("the wired adapter answers");
assert_eq!(adapter.count(), 2, "status did not reuse the slot");
}
}
#[test]
fn the_router_debug_prints_only_wiring_not_adapters() {
let router = RoutingTracker::new().jira(Recording::new());
let printed = format!("{router:?}");
assert!(printed.contains("RoutingTracker"), "{printed}");
assert!(printed.contains("jira: true"), "{printed}");
assert!(printed.contains("github: false"), "{printed}");
assert!(printed.contains("webhook: false"), "{printed}");
}
}