cratefield_adapter_linear/lib.rs
1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use bytes::Bytes;
8use cratefield_core::{
9 Clock, Credential, Destination, Filed, HttpClient, HttpError, InboundStatusError,
10 ProviderScheme, SignatureEncoding, StatusUpdate, StatusWebhook, TicketComment, TicketDraft,
11 TicketState, TicketStatus, Tracker, TrackerError, WebhookVerifier, retry_after,
12};
13use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderValue};
14use http::{Request, StatusCode};
15use serde::Deserialize;
16use sha2::{Digest, Sha256};
17
18/// Linear's GraphQL endpoint — one URL for every operation, so the
19/// operation rides the body's `query` and the arguments its `variables`.
20const GRAPHQL_URL: &str = "https://api.linear.app/graphql";
21/// The prefix every Linear **personal API key** carries, and the only thing
22/// that tells a personal key from an OAuth access token in core's
23/// one-opaque-string [`Credential`]. A key is sent bare; a token behind
24/// `Bearer `.
25const PERSONAL_KEY_PREFIX: &str = "lin_api_";
26/// Linear's cap on `title`; a longer one is truncated on a character boundary
27/// rather than failing the create.
28const MAX_TITLE_CHARS: usize = 255;
29/// The idempotency stamp folded into the description and every comment body,
30/// so an at-least-once redelivery that files or notes twice leaves a
31/// duplicate a single text search can find. The crate's own prefix, so a
32/// tenant's text can never collide with it.
33const IDEM_PREFIX: &str = "cratefield-idem-";
34
35/// `mutation IssueCreate($input: IssueCreateInput!)` — one create, the
36/// issue's UUID and `url` read straight back off it.
37const ISSUE_CREATE: &str = "mutation IssueCreate($input: IssueCreateInput!) { \
38 issueCreate(input: $input) { success issue { id url } } }";
39/// `query Issue($id: String!)`. Linear's `issue(id:)` takes the UUID or an
40/// `ENG-123` identifier; this adapter files and reports by UUID, because
41/// `CommentCreateInput.issueId` is only documented to take the former.
42const ISSUE_QUERY: &str = "query Issue($id: String!) { issue(id: $id) { id url state { type } } }";
43/// `mutation CommentCreate($input: CommentCreateInput!)`.
44const COMMENT_CREATE: &str = "mutation CommentCreate($input: CommentCreateInput!) { commentCreate(input: $input) { success } }";
45
46/// `Tracker` over the Linear GraphQL API.
47///
48/// No `Debug` derive: kept explicit so a field added later cannot print
49/// `Debug` would print it wherever a log line or a panic message met the
50/// adapter.
51pub struct LinearTracker {
52 http: Arc<dyn HttpClient>,
53 /// Needed only to read the HTTP-date form of `Retry-After` (issue #278),
54 /// the same reason the GitHub Issues and Jira adapters hold one. A
55 /// constructor argument rather than a builder default so a deployment
56 /// that forgets it fails to compile instead of silently retrying a
57 /// date-form 429 immediately.
58 clock: Arc<dyn Clock>,
59 /// `None` — the default — posts to [`GRAPHQL_URL`]; a value overrides it
60 /// for tests pointed at a fake.
61 base: Option<String>,
62}
63
64impl std::fmt::Debug for LinearTracker {
65 /// Holds no credential to leak: the Linear key or OAuth token arrives
66 /// per call (#453).
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("LinearTracker")
69 .field("base", &self.base)
70 // The http and clock ports are struct fields too; nothing about
71 // either belongs in a log line.
72 .finish_non_exhaustive()
73 }
74}
75
76impl LinearTracker {
77 /// An adapter pointed at Linear's GraphQL API. It holds no credential:
78 /// which team, addressed under whose key, is tenant data and arrives
79 /// with each call (#453).
80 #[must_use]
81 pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
82 Self {
83 http,
84 clock,
85 base: None,
86 }
87 }
88
89 /// Overrides the URL every request is posted to — a fake in tests —
90 /// instead of Linear's own `https://api.linear.app/graphql`.
91 #[must_use]
92 pub fn with_base(mut self, base: impl Into<String>) -> Self {
93 self.base = Some(base.into());
94 self
95 }
96
97 /// The absolute URL requests are posted to.
98 fn endpoint(&self) -> &str {
99 self.base.as_deref().unwrap_or(GRAPHQL_URL)
100 }
101
102 /// The headers every Linear request carries: the tenant's
103 /// `Authorization`, and the JSON accept Linear answers with.
104 ///
105 /// A credential the HTTP layer cannot carry is refused here rather than
106 /// sent. Neither that nor an empty one is retryable: an empty one is
107 /// [`TrackerError::NotConfigured`], a malformed one
108 /// [`TrackerError::Rejected`].
109 fn request(cred: &Credential, uri: &str, body: Bytes) -> Result<Request<Bytes>, TrackerError> {
110 let secret = cred.expose();
111 if secret.is_empty() {
112 return Err(TrackerError::NotConfigured);
113 }
114 // Narrower than `HeaderValue::from_str` accepts, which admits a
115 // space and any byte above 127 as obs-text — neither of which a
116 // Linear key carries.
117 let malformed =
118 || TrackerError::Rejected("linear credential is not a legal header value".to_owned());
119 if !secret.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) {
120 return Err(malformed());
121 }
122 let value = HeaderValue::from_str(&auth_header(secret)).map_err(|_| malformed())?;
123 Request::builder()
124 .method(http::Method::POST)
125 .uri(uri)
126 .header(AUTHORIZATION, value)
127 .header(ACCEPT, "application/json")
128 .header(CONTENT_TYPE, "application/json")
129 .body(body)
130 .map_err(|_| TrackerError::Rejected("linear request could not be built".into()))
131 }
132
133 /// Sends one request and returns the body of a `2xx` answer. Anything
134 /// else — a transport failure or a non-2xx status — comes back as a
135 /// [`TrackerError`], already mapped.
136 async fn send_checked(&self, request: Request<Bytes>) -> Result<String, TrackerError> {
137 let response = self.http.send(request).await.map_err(|err: HttpError| {
138 match err {
139 // The port's SSRF vetting refused the destination URL (a
140 // scheme, userinfo, or a loopback / private / link-local /
141 // metadata destination, re-vetted per redirect hop): a config
142 // error, not weather.
143 HttpError::BlockedDestination(detail) => {
144 TrackerError::Rejected(format!("linear destination refused: {detail}"))
145 }
146 // Any other transport failure is weather — retry. The
147 // provider's text cannot ride `Transient`, which carries
148 // only the delay, so it goes to the log the way the sibling
149 // adapters' does: an operator still gets to read it.
150 other => {
151 tracing::warn!(provider = "linear", outcome = "failed", error = %other, "tracker outcome");
152 TrackerError::Transient { retry_after: None }
153 }
154 }
155 })?;
156 let status = response.status();
157 let text = String::from_utf8_lossy(response.body()).to_string();
158 if !status.is_success() {
159 // One parser for both `Retry-After` forms (issue #214/#278); the
160 // date form needs the clock this adapter is constructed with.
161 let delay = retry_after(response.headers(), self.clock.as_ref());
162 let error = Self::map_status(status, &text, delay);
163 tracing::warn!(
164 provider = "linear",
165 code = status.as_u16(),
166 outcome = "failed",
167 detail = %text,
168 error = %error,
169 "tracker outcome"
170 );
171 return Err(error);
172 }
173 Ok(text)
174 }
175
176 /// Maps a Linear HTTP status onto the port's error taxonomy — the same
177 /// table the GitHub Issues and Jira adapters apply: `401`/`403` say the
178 /// key is wrong or powerless; the rest of the `4xx` family is a fact
179 /// about our request; `429` and the `5xx` family are the provider having
180 /// a moment.
181 fn map_status(
182 status: StatusCode,
183 body: &str,
184 retry_after: Option<std::time::Duration>,
185 ) -> TrackerError {
186 match status {
187 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TrackerError::Unauthorized,
188 // Must precede the `4xx` arm — 429 *is* a client error.
189 // (`retry_after` reads both header forms and answers `None` when
190 // no header came, so a bare 500 stays unscheduled.)
191 status if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS => {
192 TrackerError::Transient { retry_after }
193 }
194 status if status.is_client_error() => {
195 TrackerError::Rejected(format!("linear {status}: {}", flatten_errors(body)))
196 }
197 _ => TrackerError::Transient { retry_after },
198 }
199 }
200
201 /// Posts one GraphQL operation and returns its `data`.
202 ///
203 /// The GraphQL-specific half of "is this a failure": Linear answers a
204 /// failed operation with a **`200`** whose body carries a top-level
205 /// `errors` array, so a status check alone would read every rejection as
206 /// a success.
207 ///
208 /// `accepted` says whether this operation's own payload reports that it
209 /// took effect. A mutation that did — `issueCreate { success: true }` —
210 /// wins over a non-fatal `errors` array, which Linear attaches to partial
211 /// successes; failing such a create would file a duplicate on the next
212 /// attempt. The errors are mapped only when nothing accepted the call.
213 async fn graphql(
214 &self,
215 cred: &Credential,
216 query: &str,
217 variables: serde_json::Value,
218 accepted: impl Fn(&Payload) -> bool,
219 ) -> Result<Payload, TrackerError> {
220 let body = serde_json::json!({ "query": query, "variables": variables });
221 let json =
222 serde_json::to_vec(&body).map_err(|_| TrackerError::Transient { retry_after: None })?;
223 let request = Self::request(cred, self.endpoint(), Bytes::from(json))?;
224 let text = self.send_checked(request).await?;
225 // Every response shape is read through a typed struct, never by
226 // indexing a `Value`: indexing where an object was expected panics,
227 // and these bodies come from the network. A 2xx we cannot read is
228 // not one we can trust — transient, and let the caller retry.
229 let response: Response = serde_json::from_str(&text)
230 .map_err(|_| TrackerError::Transient { retry_after: None })?;
231 if response.errors.is_empty() || accepted(&response.data) {
232 return Ok(response.data);
233 }
234 Err(map_errors(&response.errors))
235 }
236}
237
238#[async_trait]
239impl Tracker for LinearTracker {
240 /// One `issueCreate` mutation. `title` is the draft's own, truncated to
241 /// Linear's cap; `description` is the Markdown body verbatim (Linear
242 /// renders Markdown, so there is no document format to translate) plus a
243 /// footnote carrying what the port's shape has and Linear's input has no
244 /// field for: the labels, the environment, the severity, the stamp.
245 ///
246 /// **`external_id` is the issue's UUID**, the only id
247 /// `CommentCreateInput.issueId` is documented to take. **`Filed` is not
248 /// idempotent:** Linear offers no stamp to search an issue by, so the
249 /// at-least-once outbox may file twice; see the README.
250 async fn file(
251 &self,
252 dest: &Destination,
253 cred: &Credential,
254 draft: &TicketDraft,
255 ) -> Result<Filed, TrackerError> {
256 let team = team_of(dest)?;
257 let input = serde_json::json!({
258 "teamId": team,
259 "title": truncate_title(&draft.title),
260 "description": description(draft),
261 });
262 let data = self
263 .graphql(
264 cred,
265 ISSUE_CREATE,
266 serde_json::json!({ "input": input }),
267 |data| {
268 data.issue_create
269 .as_ref()
270 .is_some_and(|made| made.success == Some(true))
271 },
272 )
273 .await?;
274 let created = data
275 .issue_create
276 .as_ref()
277 // Linear's `success: false` with no `errors` is the other shape
278 // of a refusal; both must fail the call.
279 .filter(|made| made.success != Some(false))
280 .ok_or_else(|| {
281 TrackerError::Rejected("linear issueCreate reported success: false".to_owned())
282 })?;
283 // A 2xx carrying no issue is not a create this adapter can trust:
284 // transient, and let the next attempt settle it.
285 let issue = created
286 .issue
287 .as_ref()
288 .ok_or(TrackerError::Transient { retry_after: None })?;
289 let external_id = issue
290 .id
291 .as_deref()
292 .ok_or(TrackerError::Transient { retry_after: None })?;
293 let url = issue
294 .url
295 .as_deref()
296 .ok_or(TrackerError::Transient { retry_after: None })?;
297 tracing::info!(
298 provider = "linear",
299 outcome = "created",
300 idempotency = %draft.idempotency_key,
301 issue = %external_id,
302 "tracker outcome"
303 );
304 Ok(Filed {
305 external_id: external_id.to_owned(),
306 url: url.to_owned(),
307 })
308 }
309
310 /// One `issue(id:)` query, mapping `state.type` — Linear's own
311 /// workflow-type rollup, the field that survives every custom workflow a
312 /// tenant renames — onto the port's five.
313 async fn status(
314 &self,
315 dest: &Destination,
316 cred: &Credential,
317 external_id: &str,
318 ) -> Result<TicketStatus, TrackerError> {
319 team_of(dest)?;
320 validate_external_id(external_id)?;
321 let data = self
322 .graphql(
323 cred,
324 ISSUE_QUERY,
325 serde_json::json!({ "id": external_id }),
326 |_| false,
327 )
328 .await?;
329 let issue = data.issue.as_ref().ok_or_else(|| {
330 // `issue: null` is Linear's not-found — the same fact a bare
331 // `404` carries, and the same answer: `Rejected`, since a retry
332 // of a missing issue changes nothing. `Unknown` would read as
333 // "the state is one this port cannot name", a different thing.
334 TrackerError::Rejected(format!("linear has no issue {external_id}"))
335 })?;
336 Ok(TicketStatus {
337 external_id: external_id.to_owned(),
338 state: state_of(issue.state.as_ref().and_then(|state| state.kind.as_deref())),
339 // Linear's own URL, or none: its `linear.app/{workspace}/issue/…`
340 // form needs the workspace slug, which nothing here carries, and
341 // a guess that would 404 is worse than the port's `None`.
342 url: issue.url.clone(),
343 })
344 }
345
346 /// One `commentCreate` mutation: the note's Markdown verbatim plus, when
347 /// it carries one, a trailing line linking it. No search-before-write, so
348 /// a redelivery may duplicate the note — but the key rides the body as
349 /// the same `cratefield-idem-…` stamp, so any duplicate is findable.
350 async fn comment(
351 &self,
352 dest: &Destination,
353 cred: &Credential,
354 external_id: &str,
355 comment: &TicketComment,
356 ) -> Result<(), TrackerError> {
357 team_of(dest)?;
358 validate_external_id(external_id)?;
359 let body = match comment.link.as_deref() {
360 // Linear renders Markdown, so the link is one markdown line;
361 // only an http(s) link with no control character in it is worth
362 // one, decided before any request.
363 Some(link)
364 if (link.starts_with("https://") || link.starts_with("http://"))
365 && !link.chars().any(char::is_control) =>
366 {
367 format!("{}\n\n{link}", comment.body_markdown.trim_end())
368 }
369 Some(_) => {
370 return Err(TrackerError::Rejected(
371 "linear comment link must be an http(s) URL with no control characters"
372 .to_owned(),
373 ));
374 }
375 None => comment.body_markdown.clone(),
376 };
377 let input = serde_json::json!({
378 "issueId": external_id,
379 "body": format!("{body}\n\n---\n\n{IDEM_PREFIX}{}", idem_hash(&comment.idempotency_key)),
380 });
381 let data = self
382 .graphql(
383 cred,
384 COMMENT_CREATE,
385 serde_json::json!({ "input": input }),
386 |data| {
387 data.comment_create
388 .as_ref()
389 .is_some_and(|made| made.success == Some(true))
390 },
391 )
392 .await?;
393 if data
394 .comment_create
395 .as_ref()
396 .is_none_or(|made| made.success == Some(false))
397 {
398 return Err(TrackerError::Rejected(
399 "linear commentCreate reported success: false".to_owned(),
400 ));
401 }
402 tracing::info!(
403 provider = "linear",
404 outcome = "commented",
405 idempotency = %comment.idempotency_key,
406 issue = external_id,
407 "tracker outcome"
408 );
409 Ok(())
410 }
411}
412
413/// One GraphQL response, read as a struct: `serde` fails on a body whose
414/// shape is not this one, which keeps a hostile or simply odd `200` from
415/// turning into a panic.
416#[derive(Deserialize, Default)]
417struct Response {
418 #[serde(default)]
419 errors: Vec<GraphqlError>,
420 #[serde(default)]
421 data: Payload,
422}
423
424/// The `data` object. Every field is optional: which one an operation fills
425/// is the operation's own business, and a missing one is a `2xx` read as an
426/// answer this adapter cannot trust — never as an index panic.
427#[derive(Deserialize, Default)]
428struct Payload {
429 #[serde(rename = "issueCreate")]
430 issue_create: Option<Mutation>,
431 #[serde(rename = "commentCreate")]
432 comment_create: Option<Mutation>,
433 issue: Option<Issue>,
434}
435
436/// One mutation's `success` and, for a create, the issue it made.
437#[derive(Deserialize, Default)]
438struct Mutation {
439 #[serde(default)]
440 success: Option<bool>,
441 #[serde(default)]
442 issue: Option<Issue>,
443}
444
445/// The slice of an issue this adapter reads.
446#[derive(Deserialize)]
447struct Issue {
448 #[serde(default)]
449 id: Option<String>,
450 #[serde(default)]
451 url: Option<String>,
452 #[serde(default)]
453 state: Option<WorkflowState>,
454}
455
456/// A workflow state, read for its `type` — the rollup, not the name, which a
457/// tenant renames freely.
458#[derive(Deserialize)]
459struct WorkflowState {
460 #[serde(rename = "type", default)]
461 kind: Option<String>,
462}
463
464/// One entry of a GraphQL `errors` array, and the `extensions` it carries for
465/// its machine-readable code.
466#[derive(Deserialize, Default)]
467struct GraphqlError {
468 #[serde(default)]
469 message: Option<String>,
470 #[serde(default)]
471 extensions: Option<GraphqlErrorExtensions>,
472}
473
474#[derive(Deserialize, Default)]
475struct GraphqlErrorExtensions {
476 #[serde(default)]
477 code: Option<String>,
478}
479
480/// Linear's inbound status webhook, over core's [`ProviderScheme`]:
481/// `Linear-Signature: <hex>`, HMAC-SHA256 over the raw body with no prefix,
482/// read by [`cratefield_core::receive_status`] — verify, then parse.
483///
484/// **Replay protection is not provided here.** Linear signs the raw body
485/// alone — its `webhookTimestamp` (ms) rides *inside* that body, not in a
486/// header — and core's [`ProviderScheme`] can only bind a timestamp *header*
487/// into the signed payload, so the verifier's replay tolerance has nothing to
488/// run on and a captured delivery verifies forever, exactly as with Jira.
489/// This type parses the body and nothing else; a caller wanting a freshness
490/// window must read `webhookTimestamp` (and the delivery's `webhookId`) out of
491/// the already-verified body itself. Terminate on TLS and rotate the
492/// per-tenant secret either way.
493pub struct LinearStatusWebhook;
494
495impl StatusWebhook for LinearStatusWebhook {
496 fn kind(&self) -> &'static str {
497 "linear"
498 }
499
500 /// Constant-time compare, fail-closed on unreadable input: inherited
501 /// from core's [`WebhookVerifier`], never re-derived here.
502 fn verifier(&self) -> WebhookVerifier {
503 WebhookVerifier::new(ProviderScheme {
504 signature: "Linear-Signature",
505 encoding: SignatureEncoding::Hex,
506 // Linear sends bare hex — no `sha256=` prefix, unlike GitHub.
507 prefix: None,
508 // Linear's timestamp is in the body, not a header, so it cannot
509 // be bound into the signed payload here. See the replay note.
510 timestamp: None,
511 })
512 }
513
514 /// Reads one **verified** delivery. An `update` on `"type": "Issue"`
515 /// that carries a state **and** names the state it came from
516 /// (`updatedFrom.stateId`) maps onto a [`StatusUpdate`] keyed by the same
517 /// issue UUID [`Tracker::file`] returns; every other event, and an update
518 /// that touched no state, is `Ok(None)` — verified silence, not an error.
519 ///
520 /// # Errors
521 ///
522 /// [`InboundStatusError::Malformed`] when the verified body is not JSON or
523 /// not an event of the shape Linear sends, or a state change arrives with
524 /// no id to key it by. Never [`InboundStatusError::Signature`] — that
525 /// belongs to [`cratefield_core::receive_status`], before parsing.
526 fn parse(&self, body: &[u8]) -> Result<Option<StatusUpdate>, InboundStatusError> {
527 // Typed, so a body that is a JSON array or a bare string is
528 // `Malformed` rather than a panic on a missing field.
529 let event: Event = serde_json::from_slice(body)
530 .map_err(|error| InboundStatusError::Malformed(error.to_string()))?;
531 // A comment, a project, an issue *created*: verified, but not a
532 // state change we were told to trust.
533 if event.kind.as_deref() != Some("Issue") || event.action.as_deref() != Some("update") {
534 return Ok(None);
535 }
536 let data = event.data.as_ref();
537 let Some(state_type) = data
538 .and_then(|data| data.state.as_ref())
539 .and_then(|state| state.kind.as_deref())
540 else {
541 return Ok(None);
542 };
543 // `updatedFrom.stateId` is Linear's own "this field changed" marker:
544 // without it the update touched something else (an assignee, a
545 // title) and the state here is the one it already had.
546 if !event.state_changed() {
547 return Ok(None);
548 }
549 let external_id = data.and_then(|data| data.id.as_deref()).ok_or_else(|| {
550 InboundStatusError::Malformed("a Linear state change carried no issue id".to_owned())
551 })?;
552 Ok(Some(StatusUpdate {
553 external_id: external_id.to_owned(),
554 state: state_of(Some(state_type)),
555 url: event.url.clone(),
556 }))
557 }
558}
559
560/// One inbound webhook event, in the shape [`LinearStatusWebhook::parse`]
561/// reads. Linear puts `updatedFrom` **beside** `data`, not inside it: the
562/// fields this delivery changed.
563#[derive(Deserialize)]
564struct Event {
565 #[serde(rename = "type", default)]
566 kind: Option<String>,
567 #[serde(default)]
568 action: Option<String>,
569 #[serde(default)]
570 url: Option<String>,
571 #[serde(default)]
572 data: Option<EventData>,
573 #[serde(rename = "updatedFrom", default)]
574 updated_from: Option<UpdatedFrom>,
575}
576
577impl Event {
578 /// Whether the **state** is what changed: the presence of
579 /// `updatedFrom.stateId`, which is the whole of its meaning — its value
580 /// is never read.
581 fn state_changed(&self) -> bool {
582 self.updated_from
583 .as_ref()
584 .is_some_and(|from| from.state_id.is_some())
585 }
586}
587
588/// The `data` of an issue event.
589#[derive(Deserialize)]
590struct EventData {
591 #[serde(default)]
592 id: Option<String>,
593 #[serde(default)]
594 state: Option<WorkflowState>,
595}
596
597/// Linear's `updatedFrom`, present only on an update that changed something.
598#[derive(Deserialize)]
599struct UpdatedFrom {
600 #[serde(rename = "stateId", default)]
601 state_id: Option<String>,
602}
603
604/// The `Authorization` value for Linear's two credential shapes.
605///
606/// Core's [`Credential`] is one opaque string, so this is decided by Linear's
607/// own documented prefix: a **personal API key** (always `lin_api_…`) is sent
608/// **bare**, with no `Bearer` — Linear rejects the prefix on personal keys —
609/// while anything else is an OAuth **access token** and takes `Bearer `.
610///
611/// The returned `String` is a copy the HTTP layer owns; only the
612/// [`Credential`]'s own buffer is zeroised on drop. The token appears here,
613/// in that header, and nowhere else — never in an error, a log or a `Debug`.
614fn auth_header(secret: &str) -> String {
615 if secret.starts_with(PERSONAL_KEY_PREFIX) {
616 secret.to_owned()
617 } else {
618 format!("Bearer {secret}")
619 }
620}
621
622/// The GraphQL `errors` array, mapped onto the port's taxonomy.
623///
624/// Linear signals a rate limit with `RATELIMITED` in
625/// `errors[].extensions.code` and **HTTP 200**, so a status-only check would
626/// file a throttled create as a success. Authentication comes first: Linear
627/// answers an expired OAuth token with `AUTHENTICATION`, and a team the key
628/// cannot reach with `AUTHORIZATION`.
629fn map_errors(errors: &[GraphqlError]) -> TrackerError {
630 let code = |wanted: &[&str]| {
631 errors
632 .iter()
633 .filter_map(|error| error.extensions.as_ref())
634 .filter_map(|extensions| extensions.code.as_deref())
635 .any(|code| wanted.contains(&code))
636 };
637 if code(&["AUTHENTICATION", "AUTHORIZATION", "INVALID_TOKEN"]) {
638 return TrackerError::Unauthorized;
639 }
640 if code(&[
641 "RATELIMITED",
642 "INTERNAL_SERVER_ERROR",
643 "SERVICE_UNAVAILABLE",
644 "TIMEOUT",
645 ]) {
646 // Linear names no `Retry-After` alongside a 200, so there is no delay
647 // to schedule; the caller's own backoff is the answer.
648 return TrackerError::Transient { retry_after: None };
649 }
650 TrackerError::Rejected(format!("linear graphql error: {}", join_messages(errors)))
651}
652
653/// Every `errors[].message` flattened to one line, or a stand-in when none
654/// came. It rides [`TrackerError::Rejected`], whose `Display` scrubs it — a
655/// Linear error can quote tenant data.
656fn join_messages(errors: &[GraphqlError]) -> String {
657 let detail = errors
658 .iter()
659 .filter_map(|error| error.message.as_deref())
660 .filter(|message| !message.is_empty())
661 .collect::<Vec<_>>()
662 .join("; ");
663 if detail.is_empty() {
664 "the response carried no message".to_owned()
665 } else {
666 detail
667 }
668}
669
670/// The human text inside an error **body**, whether or not it is a GraphQL
671/// envelope: its `errors[].message` array flattened to one line, or the body
672/// quoted whole when it is not JSON or carries no message.
673fn flatten_errors(body: &str) -> String {
674 match serde_json::from_str::<Response>(body) {
675 Ok(response) if !response.errors.is_empty() => join_messages(&response.errors),
676 _ => body.to_owned(),
677 }
678}
679
680/// The team a [`Destination::Linear`] names, or the rejection for any other
681/// destination — decided before any request, the way the GitHub adapter
682/// refuses a webhook.
683///
684/// Linear's `issueCreate` takes `teamId`, the team's UUID, not its key, so
685/// that is what a tenant configures here.
686fn team_of(dest: &Destination) -> Result<&str, TrackerError> {
687 match dest {
688 Destination::Linear { team } => {
689 validate_team(team)?;
690 Ok(team.as_str())
691 }
692 other => Err(TrackerError::unsupported_destination(other)),
693 }
694}
695
696/// A team id must be a UUID or a team key: ASCII alphanumerics, `-` and `_`,
697/// nothing else, and not empty or absurdly long.
698///
699/// This is **fail-fast input checking, not injection defence** — the value
700/// rides as a GraphQL *variable*, which serde JSON-encodes, so it cannot
701/// reshape the operation whatever it holds. What it buys is naming a
702/// misconfigured team at the call site instead of surfacing as an opaque
703/// GraphQL rejection.
704fn validate_team(team: &str) -> Result<(), TrackerError> {
705 let valid = !team.is_empty()
706 && team.len() <= 64
707 && team
708 .chars()
709 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
710 if valid {
711 Ok(())
712 } else {
713 Err(TrackerError::Rejected(
714 "linear team must be a team UUID or key (letters, digits, '-' and '_')".to_owned(),
715 ))
716 }
717}
718
719/// An external id must be one Linear's `issue(id:)` accepts — the UUID
720/// [`Tracker::file`] returns, or an `ENG-123` identifier — before it is sent.
721/// Same fail-fast rationale as [`validate_team`]: it rides as a GraphQL
722/// variable, so it names a malformed value at the call site.
723fn validate_external_id(external_id: &str) -> Result<(), TrackerError> {
724 let is_uuid = external_id.len() == 36
725 && external_id
726 .chars()
727 .all(|c| c.is_ascii_hexdigit() || c == '-')
728 && external_id.matches('-').count() == 4;
729 let is_identifier = match external_id.split_once('-') {
730 Some((key, number)) => {
731 !key.is_empty()
732 && !number.is_empty()
733 && number.bytes().all(|b| b.is_ascii_digit())
734 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
735 }
736 None => false,
737 };
738 if is_uuid || is_identifier {
739 Ok(())
740 } else {
741 Err(TrackerError::Rejected(
742 "linear external id must be an issue UUID or an ENG-123 identifier".to_owned(),
743 ))
744 }
745}
746
747/// The description a filed issue carries: the draft's Markdown verbatim
748/// (Linear renders Markdown natively, so unlike Jira there is no document
749/// format to translate) plus a footnote with everything the port's shape has
750/// and `IssueCreateInput` has no field for.
751///
752/// **The labels are folded in here rather than mapped to `labelIds`.** Linear
753/// addresses a label by its UUID, and the port's `labels` are free-form text
754/// a tenant chose — guessing a UUID from a label name would attach a
755/// *different* tenant's label, or none, so the text rides the body where it is
756/// at least findable.
757fn description(draft: &TicketDraft) -> String {
758 let mut facts = vec![format!("severity/{}", draft.severity.name())];
759 facts.extend(draft.labels.iter().map(|label| format!("label/{label}")));
760 if let Some(environment) = &draft.environment {
761 facts.push(format!("environment/{environment}"));
762 }
763 format!(
764 "{}\n\n---\n\n{}\n\n{IDEM_PREFIX}{}",
765 draft.body_markdown.trim_end(),
766 facts.join(" · "),
767 idem_hash(&draft.idempotency_key)
768 )
769}
770
771/// The stamp tying an issue or a note to an outbox idempotency key:
772/// [`IDEM_PREFIX`] plus the first 16 bytes of SHA-256 of the key, hex —
773/// hashed, because the body is text a Linear search greps for and a caller's
774/// key may quote tenant data.
775fn idem_hash(idempotency_key: &str) -> String {
776 hex::encode(&Sha256::digest(idempotency_key.as_bytes())[..16])
777}
778
779/// Linear caps `title` at [`MAX_TITLE_CHARS`]; a longer title is truncated on
780/// a character boundary — a split character is something serde's JSON writer
781/// would refuse to emit.
782fn truncate_title(title: &str) -> &str {
783 match title.char_indices().nth(MAX_TITLE_CHARS) {
784 Some((at, _)) => &title[..at],
785 None => title,
786 }
787}
788
789/// Maps Linear's `state.type` — its workflow-type rollup, which survives
790/// every custom workflow a tenant renames — onto the port's five. A type the
791/// port cannot name is said so rather than guessed into one of the four.
792/// Canceled is done, not done-and-fixed: `Closed` is the port's "will not be
793/// reopened".
794fn state_of(state_type: Option<&str>) -> TicketState {
795 match state_type {
796 // Filed, triaged, backlogged or scheduled, but nobody started it.
797 Some("triage" | "backlog" | "unstarted") => TicketState::Open,
798 Some("started") => TicketState::InProgress,
799 Some("completed") => TicketState::Resolved,
800 Some("canceled") => TicketState::Closed,
801 _ => TicketState::Unknown,
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 #[test]
810 fn a_long_title_is_truncated_on_a_character_boundary() {
811 assert_eq!(truncate_title("short"), "short");
812 // Multi-byte characters: the cut must land between characters, not
813 // inside one.
814 let long = "é".repeat(400);
815 assert_eq!(truncate_title(&long).chars().count(), MAX_TITLE_CHARS);
816 }
817}