Skip to main content

onetaskgraph_linear/
change_token.rs

1//! A Linear item's change token, `lin1`: what [`Task::version`](onetaskgraph_plugin_api::Task::version),
2//! [`Project::version`](onetaskgraph_plugin_api::Project::version) and
3//! [`Document::version`](onetaskgraph_plugin_api::Document::version) hold here, and the one
4//! ordering of two of them.
5//!
6//! `docs/plugin-protocol.md` §4.13d is the specification. Every part is spelled from the response
7//! the source already receives: the item's `updatedAt`, and the digest of its *facets* — what a
8//! person can change about it without moving that time, which `spike-linear-coverage` measured
9//! `updatedAt` alone missing: a label renamed or recoloured, a workflow state renamed, a
10//! document's content and slot edited. With the store's cache attached a task's token carries a
11//! third part, the digest of its first page of relations, because a relation added or removed
12//! moved no time the spike could see.
13//!
14//! An item whose labels came back as 50 nodes or more — Linear's default page, which the
15//! documents here select without a `first` or a `pageInfo` — gives no token, and so does a task
16//! whose relations page reports another page or holds more than [`RELATIONS_PAGE`] rows: a part
17//! read from part of a connection vouches for nothing.
18
19use std::cmp::Ordering;
20
21use chrono::{DateTime, Utc};
22use onetaskgraph_plugin_api::asset_sha256;
23use serde::Serialize;
24use serde_json::Value;
25
26/// What every token this plugin gives starts with.
27pub const PREFIX: &str = "lin1|";
28
29/// What the relations part of a task's token starts with.
30const RELATIONS: &str = "r:";
31
32/// The rows the relations page a task's token is spelled from holds at most: the `first` of the
33/// page the cache's documents select.
34pub const RELATIONS_PAGE: usize = 10;
35
36/// The labels Linear answers a `labels` connection with when it names no `first`: a response
37/// holding this many may be a first page of more, so it gives no token.
38pub const DEFAULT_LABEL_PAGE: usize = 50;
39
40/// One label as a facet spells it: its id, its name and its colour, which Linear may leave null.
41type LabelFacet<'a> = (&'a str, &'a str, Option<&'a str>);
42
43/// A workflow state's or a project status's name and type.
44type NamedType<'a> = (&'a str, &'a str);
45
46/// A task's facets, as its token's digest spells them.
47#[derive(Serialize)]
48struct TaskFacets<'a> {
49    key: &'a str,
50    labels: Vec<LabelFacet<'a>>,
51    project: Option<&'a str>,
52    state: NamedType<'a>,
53    url: Option<&'a str>,
54}
55
56/// A project's facets.
57#[derive(Serialize)]
58struct ProjectFacets<'a> {
59    labels: Vec<LabelFacet<'a>>,
60    status: NamedType<'a>,
61}
62
63/// A lowercase hex SHA-256 — of a document's content, of a facets part's canonical JSON, of a
64/// relations page — made only by hashing, or read back out of a token and refused when it is not
65/// one.
66#[derive(Debug, PartialEq, Eq, Serialize)]
67#[serde(transparent)]
68struct Sha256Hex(String);
69
70impl Sha256Hex {
71    fn of(bytes: &[u8]) -> Self {
72        Self(asset_sha256(bytes))
73    }
74
75    fn parse(text: &str) -> Option<Self> {
76        (text.len() == 64
77            && text
78                .bytes()
79                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
80        .then(|| Self(text.to_owned()))
81    }
82}
83
84impl std::fmt::Display for Sha256Hex {
85    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        formatter.write_str(&self.0)
87    }
88}
89
90/// A document's facets.
91#[derive(Serialize)]
92struct DocumentFacets<'a> {
93    content: Sha256Hex,
94    project: Option<&'a str>,
95    title: &'a str,
96    url: &'a str,
97}
98
99/// The lowercase hex SHA-256 of `facets`' canonical JSON.
100fn digest(facets: &impl Serialize) -> Option<Sha256Hex> {
101    Some(Sha256Hex::of(
102        serde_json::to_string(facets).ok()?.as_bytes(),
103    ))
104}
105
106/// `key` of `item` when it is a string — and `None`, so no token, when it is missing or anything
107/// else: a part read as something Linear's schema does not give vouches for nothing.
108fn text<'a>(item: &'a Value, key: &str) -> Option<&'a str> {
109    item.get(key)?.as_str()
110}
111
112/// `key` of `item` when it is a string or null, `None` within when null — and `None` when it is
113/// missing or anything else.
114fn nullable_text<'a>(item: &'a Value, key: &str) -> Option<Option<&'a str>> {
115    match item.get(key)? {
116        Value::Null => Some(None),
117        Value::String(text) => Some(Some(text)),
118        _ => None,
119    }
120}
121
122/// Every label of `item`, as `[id, name, color]` sorted by id — or `None` when the connection is
123/// missing, may be only a first page, or holds a label without a string id and name and a
124/// string or null colour.
125fn labels(item: &Value) -> Option<Vec<LabelFacet<'_>>> {
126    let nodes = item.pointer("/labels/nodes")?.as_array()?;
127    if nodes.len() >= DEFAULT_LABEL_PAGE {
128        return None;
129    }
130    let mut labels = nodes
131        .iter()
132        .map(|label| {
133            Some((
134                text(label, "id")?,
135                text(label, "name")?,
136                nullable_text(label, "color")?,
137            ))
138        })
139        .collect::<Option<Vec<_>>>()?;
140    labels.sort_unstable();
141    Some(labels)
142}
143
144/// The project `item` is filed under, as its facets name it: its id, or `Some(None)` when it is
145/// in none — and `None` when the member is missing or names a project without a string id.
146fn project(item: &Value) -> Option<Option<&str>> {
147    match item.get("project")? {
148        Value::Null => Some(None),
149        project => Some(Some(text(project, "id")?)),
150    }
151}
152
153/// A `{name, type}` status or workflow state, as a facet names it, when both are strings.
154fn named_type(status: &Value) -> Option<NamedType<'_>> {
155    Some((text(status, "name")?, text(status, "type")?))
156}
157
158/// `updatedAt`, read as an instant and spelled back the one way this module spells it.
159fn updated(item: &Value) -> Option<String> {
160    let at: DateTime<Utc> = item.get("updatedAt")?.as_str()?.parse().ok()?;
161    Some(at.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true))
162}
163
164/// The digest of a task's first page of relations, or `None` when the page reports another or
165/// holds more than [`RELATIONS_PAGE`] rows, or is missing.
166fn relations(issue: &Value) -> Option<Sha256Hex> {
167    let page = issue.get("relations")?;
168    if page.pointer("/pageInfo/hasNextPage") != Some(&Value::Bool(false)) {
169        return None;
170    }
171    let nodes = page.get("nodes")?.as_array()?;
172    if nodes.len() > RELATIONS_PAGE {
173        return None;
174    }
175    let mut rows = nodes
176        .iter()
177        .map(|node| {
178            Some((
179                node.get("id")?.as_str()?,
180                node.get("type")?.as_str()?,
181                node.pointer("/relatedIssue/id")?.as_str()?,
182            ))
183        })
184        .collect::<Option<Vec<_>>>()?;
185    rows.sort_unstable();
186    Some(Sha256Hex::of(serde_json::to_string(&rows).ok()?.as_bytes()))
187}
188
189/// The token of the issue `issue`, as one response answered it — with the digest of its first
190/// page of relations when `attached`, and none at all when that page is not whole — or `None`
191/// when the response does not carry every part whole.
192#[must_use]
193pub fn task(issue: &Value, attached: bool) -> Option<String> {
194    let facets = TaskFacets {
195        key: text(issue, "identifier")?,
196        labels: labels(issue)?,
197        project: project(issue)?,
198        state: named_type(issue.get("state")?)?,
199        url: nullable_text(issue, "url")?,
200    };
201    let base = format!("{PREFIX}{}|{}", updated(issue)?, digest(&facets)?);
202    if attached {
203        Some(format!("{base}|{RELATIONS}{}", relations(issue)?))
204    } else {
205        Some(base)
206    }
207}
208
209/// The token of the project `project`, as one response answered it.
210#[must_use]
211pub fn project_token(project: &Value) -> Option<String> {
212    let facets = ProjectFacets {
213        labels: labels(project)?,
214        status: named_type(project.get("status")?)?,
215    };
216    Some(format!(
217        "{PREFIX}{}|{}",
218        updated(project)?,
219        digest(&facets)?
220    ))
221}
222
223/// The token of the document `document`, as one response answered it: its content's digest is
224/// of the content as Linear holds it, metadata slot and all.
225#[must_use]
226pub fn document_token(document: &Value) -> Option<String> {
227    let facets = DocumentFacets {
228        content: Sha256Hex::of(
229            nullable_text(document, "content")?
230                .unwrap_or_default()
231                .as_bytes(),
232        ),
233        project: project(document)?,
234        title: text(document, "title")?,
235        url: text(document, "url")?,
236    };
237    Some(format!(
238        "{PREFIX}{}|{}",
239        updated(document)?,
240        digest(&facets)?
241    ))
242}
243
244/// The token `token` with its relations part taken off: what a later page of one task's walk,
245/// read with no relations page of its own, is held to.
246#[must_use]
247pub fn without_relations(token: &str) -> &str {
248    token
249        .split_once(&format!("|{RELATIONS}"))
250        .map_or(token, |(base, _)| base)
251}
252
253/// What one token of this plugin is made of.
254struct Parts {
255    updated: DateTime<Utc>,
256    facets: Sha256Hex,
257    relations: Option<Sha256Hex>,
258}
259
260fn parts(token: &str) -> Option<Parts> {
261    let mut parts = token.strip_prefix(PREFIX)?.split('|');
262    let updated = DateTime::parse_from_rfc3339(parts.next()?)
263        .ok()?
264        .with_timezone(&Utc);
265    let facets = Sha256Hex::parse(parts.next()?)?;
266    let relations = match parts.next() {
267        None => None,
268        Some(part) => Some(Sha256Hex::parse(part.strip_prefix(RELATIONS)?)?),
269    };
270    parts.next().is_none().then_some(Parts {
271        updated,
272        facets,
273        relations,
274    })
275}
276
277/// How the version `a` of one item stands to its version `b`: by `updatedAt` as an instant when
278/// their facets and their relations parts — present or absent, and their value — are equal,
279/// `Equal` only when every part is, and `None` when either is not a token of this plugin or any
280/// of those differ, which says nothing about which reading came first.
281#[must_use]
282pub fn order(a: &str, b: &str) -> Option<Ordering> {
283    let (a, b) = (parts(a)?, parts(b)?);
284    (a.facets == b.facets && a.relations == b.relations).then(|| a.updated.cmp(&b.updated))
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use serde_json::json;
291
292    fn issue(updated: &str, label: &str, relations: Value) -> Value {
293        json!({
294            "id": "i1", "identifier": "ENG-1", "url": "https://linear.app/x/issue/ENG-1",
295            "updatedAt": updated, "state": {"name": "Todo", "type": "unstarted"},
296            "project": {"id": "p1"},
297            "labels": {"nodes": [{"id": "l1", "name": label, "color": "#ff0000"}]},
298            "relations": relations,
299        })
300    }
301
302    fn page(rows: &[(&str, &str)], more: bool) -> Value {
303        json!({
304            "nodes": rows.iter().map(|(id, far)| json!({"id": id, "type": "blocks",
305                                                        "relatedIssue": {"id": far}}))
306                .collect::<Vec<_>>(),
307            "pageInfo": {"hasNextPage": more},
308        })
309    }
310
311    #[test]
312    fn a_later_time_with_the_same_facets_and_relations_orders_after() {
313        let none = page(&[], false);
314        let before = task(&issue("2026-10-01T00:00:00Z", "bug", none.clone()), true).unwrap();
315        let after = task(&issue("2026-10-01T00:00:01Z", "bug", none), true).unwrap();
316        assert_eq!(order(&after, &before), Some(Ordering::Greater));
317        assert_eq!(order(&before, &after), Some(Ordering::Less));
318        assert_eq!(order(&before, &before), Some(Ordering::Equal));
319    }
320
321    #[test]
322    fn another_facet_or_relations_part_is_never_ordered() {
323        let none = page(&[], false);
324        let before = task(&issue("2026-10-01T00:00:00Z", "bug", none.clone()), true).unwrap();
325        let renamed = task(&issue("2026-10-01T00:00:05Z", "defect", none.clone()), true).unwrap();
326        assert_eq!(order(&renamed, &before), None, "a label renamed");
327        let related = task(
328            &issue("2026-10-01T00:00:05Z", "bug", page(&[("r1", "i2")], false)),
329            true,
330        )
331        .unwrap();
332        assert_eq!(order(&related, &before), None, "a relation added");
333        let unattached = task(&issue("2026-10-01T00:00:00Z", "bug", none), false).unwrap();
334        assert!(!unattached.contains("|r:"));
335        assert_eq!(
336            order(&unattached, &before),
337            None,
338            "a token with relations and one without"
339        );
340        assert_eq!(order("gh3|x", &before), None);
341        assert_eq!(order(&format!("{before}|extra"), &before), None);
342    }
343
344    #[test]
345    fn a_part_read_from_part_of_a_connection_gives_no_token() {
346        let more = page(&[("r1", "i2")], true);
347        assert_eq!(
348            task(&issue("2026-10-01T00:00:00Z", "bug", more), true),
349            None
350        );
351        let rows: Vec<(String, String)> = (0..=RELATIONS_PAGE)
352            .map(|n| (format!("r{n}"), format!("i{n}")))
353            .collect();
354        let eleven: Vec<(&str, &str)> = rows
355            .iter()
356            .map(|(id, far)| (id.as_str(), far.as_str()))
357            .collect();
358        assert_eq!(
359            task(
360                &issue("2026-10-01T00:00:00Z", "bug", page(&eleven, false)),
361                true
362            ),
363            None
364        );
365        let mut crowded = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
366        crowded["labels"]["nodes"] = Value::Array(
367            (0..DEFAULT_LABEL_PAGE)
368                .map(|n| json!({"id": format!("l{n}"), "name": "x", "color": null}))
369                .collect(),
370        );
371        assert_eq!(task(&crowded, true), None);
372        assert_eq!(task(&crowded, false), None);
373    }
374
375    #[test]
376    fn a_part_of_another_json_type_than_linears_schema_gives_no_token() {
377        let whole = issue("2026-10-01T00:00:00Z", "bug", page(&[], false));
378        assert!(task(&whole, true).is_some());
379        for (part, value) in [
380            ("/identifier", json!(7)),
381            ("/url", json!(["a"])),
382            ("/project", json!({"id": 7})),
383            ("/project", json!("p1")),
384            ("/state/type", json!(null)),
385            ("/state/name", json!(1)),
386            ("/labels/nodes/0/name", json!(null)),
387            ("/labels/nodes/0/id", json!(1)),
388            ("/labels/nodes/0/color", json!(0)),
389        ] {
390            let mut broken = whole.clone();
391            *broken.pointer_mut(part).expect("the part") = value.clone();
392            assert_eq!(task(&broken, true), None, "{part} as {value}");
393        }
394        let document = json!({"title": "Design", "url": "https://linear.app/x/document/design",
395                              "project": null, "updatedAt": "2026-10-01T00:00:00Z",
396                              "content": null});
397        assert!(
398            document_token(&document).is_some(),
399            "Linear's content may be null"
400        );
401        for (part, value) in [
402            ("/title", json!(null)),
403            ("/url", json!(null)),
404            ("/content", json!(1)),
405        ] {
406            let mut broken = document.clone();
407            *broken.pointer_mut(part).expect("the part") = value.clone();
408            assert_eq!(document_token(&broken), None, "{part} as {value}");
409        }
410    }
411
412    #[test]
413    fn the_relations_page_a_token_is_read_from_is_the_one_the_cache_selects() {
414        assert!(
415            crate::graphql::RELATIONS_PAGE.contains(&format!("relations(first:{RELATIONS_PAGE})")),
416            "{} selects a page of {RELATIONS_PAGE}",
417            crate::graphql::RELATIONS_PAGE
418        );
419    }
420
421    #[test]
422    fn a_documents_token_moves_with_its_content_and_the_relations_part_comes_off() {
423        let document = |content: &str| {
424            json!({"title": "Design", "url": "https://linear.app/x/document/design", "project": null,
425                   "updatedAt": "2026-10-01T00:00:00Z", "content": content})
426        };
427        let one = document_token(&document("a")).unwrap();
428        let other = document_token(&document("a, edited")).unwrap();
429        assert_eq!(order(&one, &other), None);
430        let token = task(
431            &issue("2026-10-01T00:00:00Z", "bug", page(&[], false)),
432            true,
433        )
434        .unwrap();
435        assert_eq!(
436            without_relations(&token),
437            task(
438                &issue("2026-10-01T00:00:00Z", "bug", page(&[], false)),
439                false
440            )
441            .unwrap()
442        );
443    }
444}