Skip to main content

onetaskgraph_github_projects/
change_token.rs

1//! An item's change token on a GitHub board: 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: the parts the token is built from, the
7//! change kinds each covers member by member, and why a token waits for its item to settle. What
8//! is here is how this module holds to it. Each part is selected by every list, search and
9//! detail read already, so the token costs no request; an item missing any part — a draft, an
10//! item this process wrote and has not read back, or one whose labels or board field values
11//! came back as only a first page — gives none. [`order`] says how two tokens of one issue
12//! stand to each other, and an item whose later time is within [`SETTLED`] of the response's
13//! `Date`, or read from a response without one, gives no token.
14
15use std::cmp::Ordering;
16
17use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
18use onetaskgraph_plugin_api::{Label, Repository, asset_sha256};
19use serde::Serialize;
20
21/// How long before the response that read it an item must last have changed for its token to
22/// be given.
23///
24/// GitHub aborts a GraphQL query that runs past ten seconds, so the state a response reports
25/// was read at most about ten seconds before the `Date` it is stamped with. Sixty seconds is
26/// that bound with a wide margin: an item whose later time is further back than this was last
27/// changed in an earlier second than the read, so no change after the read can share its
28/// second.
29pub const SETTLED: TimeDelta = TimeDelta::seconds(60);
30
31/// Whether an issue last changed at `issue`, whose board item last changed at `item`, had
32/// settled by `served` — the `Date` of the response that read it — so its token may be given.
33#[must_use]
34pub fn settled(issue: DateTime<Utc>, item: DateTime<Utc>, served: DateTime<Utc>) -> bool {
35    served - issue.max(item) > SETTLED
36}
37
38/// What every token this plugin gives starts with, so a reader can tell it from another
39/// plugin's.
40///
41/// `gh3` since the item's facets — its own kind, its board field values and its labels — joined
42/// the token. A `gh2` token, which lacked them, could stay equal across a label renamed or
43/// recoloured and a field option renamed, and order a token read after a far end's deletion
44/// before the one held; a `gh1` token lacked the relationship count and the repository too.
45/// Neither is a token this plugin orders or ever gives, so an entry or a copy that recorded one
46/// reads its item again once rather than trusting a version that could not see those.
47pub const PREFIX: &str = "gh3|";
48
49/// What one token of this plugin is made of.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Parts {
52    /// When the issue last changed.
53    pub issue: DateTime<Utc>,
54    /// When its board item last changed.
55    pub item: DateTime<Utc>,
56    /// How many comments it holds.
57    pub comments: u64,
58    /// How many relationship events — dependencies and project memberships added or removed —
59    /// its timeline holds.
60    pub relationships: u64,
61    /// The digest of the item's [`Facets`].
62    pub facets: FacetsDigest,
63    /// The repository it is in. Spelled last, and read back as everything after the facets, so
64    /// any origin a [`Repository`] admits comes back as it went.
65    pub repository: Repository,
66}
67
68/// What an item is and holds that neither of its times moves for: its own kind, the value of
69/// every board field it holds one of, and every label it carries, each named by what a person
70/// can rename — a field option's name, a label's name and colour.
71///
72/// Its canonical JSON is
73/// `{"kind":<"task"|"project"|"document">,"fields":[[<field>,<option or text>],...],"labels":[[<id>,<name>,<color>],...]}`,
74/// each list sorted by its first element; the token carries its digest.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct Facets {
77    /// The item's own kind, as this plugin derives it from the response — the `DESIGN: ` title
78    /// prefix, its sub-issues — rather than the raw sub-issue total, so a project gaining a task
79    /// does not move it.
80    kind: FacetKind,
81    /// Each board field this item holds a value of, by name, with the option's name or the text.
82    fields: Vec<(String, String)>,
83    /// Each label, by id, with its name and colour.
84    labels: Vec<(String, String, Option<String>)>,
85}
86
87/// The kind of item a token's facets name: the one closed set the canonical JSON's `kind` holds.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "lowercase")]
90pub enum FacetKind {
91    /// A task.
92    Task,
93    /// A project.
94    Project,
95    /// A document.
96    Document,
97}
98
99/// The lowercase hex SHA-256 of an item's [`Facets`]: the token's facets part, held to that
100/// spelling so a token spelled from it is always one [`order`] can read back.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
102#[serde(try_from = "String", into = "String")]
103pub struct FacetsDigest(String);
104
105/// Spelled as the string it serializes to, inline, exactly as the `String` it replaced was.
106impl schemars::JsonSchema for FacetsDigest {
107    fn inline_schema() -> bool {
108        true
109    }
110
111    fn schema_name() -> std::borrow::Cow<'static, str> {
112        String::schema_name()
113    }
114
115    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
116        String::json_schema(generator)
117    }
118}
119
120impl TryFrom<String> for FacetsDigest {
121    type Error = String;
122
123    fn try_from(digest: String) -> Result<Self, Self::Error> {
124        Self::parse(&digest).ok_or_else(|| format!("{digest:?} is not a lowercase hex SHA-256"))
125    }
126}
127
128impl From<FacetsDigest> for String {
129    fn from(digest: FacetsDigest) -> Self {
130        digest.0
131    }
132}
133
134impl FacetsDigest {
135    /// `digest`, when it is spelled as a lowercase hex SHA-256.
136    #[must_use]
137    pub fn parse(digest: &str) -> Option<Self> {
138        is_digest(digest).then(|| Self(digest.to_owned()))
139    }
140
141    /// The digest as the token spells it.
142    #[must_use]
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146}
147
148impl Facets {
149    /// The facets of an item of `kind` holding `fields` and carrying `labels`, in any order.
150    #[must_use]
151    pub fn new(kind: FacetKind, mut fields: Vec<(String, String)>, labels: &[Label]) -> Self {
152        fields.sort();
153        let mut labels: Vec<(String, String, Option<String>)> = labels
154            .iter()
155            .map(|label| (label.id.0.clone(), label.name.clone(), label.color.clone()))
156            .collect();
157        labels.sort();
158        Self {
159            kind,
160            fields,
161            labels,
162        }
163    }
164
165    /// The lowercase hex SHA-256 of their canonical JSON: the token's facets part.
166    #[must_use]
167    pub fn digest(&self) -> FacetsDigest {
168        // A struct of strings and lists of them always serializes, and a SHA-256 is always
169        // spelled as one.
170        FacetsDigest(asset_sha256(
171            serde_json::to_string(self).unwrap_or_default().as_bytes(),
172        ))
173    }
174}
175
176/// The token these parts spell.
177#[must_use]
178pub fn spell(parts: &Parts) -> String {
179    format!(
180        "{PREFIX}{}|{}|{}|{}|{}|{}",
181        parts.issue.to_rfc3339_opts(SecondsFormat::AutoSi, true),
182        parts.item.to_rfc3339_opts(SecondsFormat::AutoSi, true),
183        parts.comments,
184        parts.relationships,
185        parts.facets.as_str(),
186        parts.repository.as_str()
187    )
188}
189
190/// Whether `facets` is spelled as a digest is.
191fn is_digest(facets: &str) -> bool {
192    facets.len() == 64
193        && facets
194            .bytes()
195            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
196}
197
198/// The parts of a token this plugin gave, or `None` for any other value.
199fn parts(token: &str) -> Option<Parts> {
200    let mut parts = token.strip_prefix(PREFIX)?.splitn(6, '|');
201    let issue = DateTime::parse_from_rfc3339(parts.next()?).ok()?;
202    let item = DateTime::parse_from_rfc3339(parts.next()?).ok()?;
203    let comments = parts.next()?.parse().ok()?;
204    let relationships = parts.next()?.parse().ok()?;
205    let facets = FacetsDigest::parse(parts.next()?)?;
206    let repository = Repository::try_from(parts.next()?.to_owned()).ok()?;
207    Some(Parts {
208        issue: issue.with_timezone(&Utc),
209        item: item.with_timezone(&Utc),
210        comments,
211        relationships,
212        facets,
213        repository,
214    })
215}
216
217/// How the version `a` of one issue stands to its version `b`: `Greater` when `a` is the later,
218/// `Less` when it is the earlier, `Equal` only when every part is equal — and `None` when either
219/// is not a token of this plugin, or the two cannot be put in order.
220///
221/// Two versions naming different repositories, different facets or different relationship
222/// counts are never ordered: a transfer, a label renamed, a field option renamed, a kind
223/// changed or a far end deleted says nothing about which reading came first, and a count that
224/// can fall — a deleted far end takes its events with it — orders nothing. Otherwise both times
225/// only ever move forward, so a later version holds neither earlier; one holding one of them
226/// ahead and the other behind is no version read after the other. When both times are equal and
227/// the comment counts are not, the version with fewer comments is the later: the one change that
228/// moves the comment count and nothing else is a comment deleted, because adding one moves the
229/// issue's time.
230#[must_use]
231pub fn order(a: &str, b: &str) -> Option<Ordering> {
232    let (a, b) = (parts(a)?, parts(b)?);
233    if a.repository != b.repository || a.facets != b.facets || a.relationships != b.relationships {
234        return None;
235    }
236    let forward = [a.issue.cmp(&b.issue), a.item.cmp(&b.item)];
237    if forward.iter().all(|part| part.is_eq()) {
238        Some(b.comments.cmp(&a.comments))
239    } else if forward.iter().all(|part| part.is_le()) {
240        Some(Ordering::Less)
241    } else if forward.iter().all(|part| part.is_ge()) {
242        Some(Ordering::Greater)
243    } else {
244        None
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use chrono::TimeZone as _;
252    use onetaskgraph_plugin_api::NativeId;
253
254    fn at(second: u32) -> DateTime<Utc> {
255        Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, second).unwrap()
256    }
257
258    fn label(id: &str, name: &str, color: &str) -> Label {
259        Label {
260            id: NativeId::from(id),
261            name: name.to_owned(),
262            color: Some(color.to_owned()),
263        }
264    }
265
266    /// The facets of a task in `Todo` carrying the label `bug`.
267    fn facets() -> FacetsDigest {
268        Facets::new(
269            FacetKind::Task,
270            vec![("Status".to_owned(), "Todo".to_owned())],
271            &[label("L_1", "bug", "d73a4a")],
272        )
273        .digest()
274    }
275
276    fn token(issue: u32, item: u32, comments: u64, relationships: u64) -> String {
277        token_with(issue, item, comments, relationships, &facets())
278    }
279
280    fn token_with(
281        issue: u32,
282        item: u32,
283        comments: u64,
284        relationships: u64,
285        facets: &FacetsDigest,
286    ) -> String {
287        spell(&Parts {
288            issue: at(issue),
289            item: at(item),
290            comments,
291            relationships,
292            facets: facets.clone(),
293            repository: repository("github.com/acme/widgets"),
294        })
295    }
296
297    fn repository(origin: &str) -> Repository {
298        Repository::try_from(origin.to_owned()).expect("a repository origin")
299    }
300
301    #[test]
302    fn the_facets_are_the_digest_of_their_canonical_json_whatever_order_they_arrive_in() {
303        let one = Facets::new(
304            FacetKind::Project,
305            vec![
306                ("Status".to_owned(), "Todo".to_owned()),
307                ("Priority".to_owned(), "P1".to_owned()),
308            ],
309            &[label("L_2", "ui", "ffffff"), label("L_1", "bug", "d73a4a")],
310        );
311        let other = Facets::new(
312            FacetKind::Project,
313            vec![
314                ("Priority".to_owned(), "P1".to_owned()),
315                ("Status".to_owned(), "Todo".to_owned()),
316            ],
317            &[label("L_1", "bug", "d73a4a"), label("L_2", "ui", "ffffff")],
318        );
319        assert_eq!(one, other);
320        assert_eq!(
321            serde_json::to_string(&one).unwrap(),
322            r#"{"kind":"project","fields":[["Priority","P1"],["Status","Todo"]],"labels":[["L_1","bug","d73a4a"],["L_2","ui","ffffff"]]}"#
323        );
324        assert_eq!(
325            one.digest().as_str(),
326            asset_sha256(serde_json::to_string(&one).unwrap().as_bytes())
327        );
328    }
329
330    #[test]
331    fn every_change_kind_the_times_carry_orders_the_later_version_after_the_earlier() {
332        let before = token(1, 1, 2, 3);
333        for after in [
334            token(2, 1, 3, 3), // a comment added
335            token(2, 1, 2, 3), // a comment edited, a body or title edit
336            token(1, 1, 1, 3), // a comment deleted
337            token(1, 2, 2, 3), // a board field write that leaves its value's name as it was
338        ] {
339            assert_eq!(order(&after, &before), Some(Ordering::Greater), "{after}");
340            assert_eq!(order(&before, &after), Some(Ordering::Less), "{after}");
341        }
342        assert_eq!(order(&before, &before), Some(Ordering::Equal));
343    }
344
345    #[test]
346    fn another_facet_another_relationship_count_or_another_repository_is_never_ordered() {
347        let before = token(1, 1, 2, 3);
348        let renamed = Facets::new(
349            FacetKind::Task,
350            vec![("Status".to_owned(), "Todo".to_owned())],
351            &[label("L_1", "defect", "d73a4a")],
352        )
353        .digest();
354        let recoloured = Facets::new(
355            FacetKind::Task,
356            vec![("Status".to_owned(), "Todo".to_owned())],
357            &[label("L_1", "bug", "000000")],
358        )
359        .digest();
360        let option_renamed = Facets::new(
361            FacetKind::Task,
362            vec![("Status".to_owned(), "To do".to_owned())],
363            &[label("L_1", "bug", "d73a4a")],
364        )
365        .digest();
366        let became_a_project = Facets::new(
367            FacetKind::Project,
368            vec![("Status".to_owned(), "Todo".to_owned())],
369            &[label("L_1", "bug", "d73a4a")],
370        )
371        .digest();
372        for (change, facets) in [
373            ("a label renamed", &renamed),
374            ("a label recoloured", &recoloured),
375            ("a field option renamed", &option_renamed),
376            ("its own kind changed", &became_a_project),
377        ] {
378            // Not even a later time puts it in order: nothing says which reading came first.
379            for after in [
380                token_with(1, 1, 2, 3, facets),
381                token_with(2, 2, 2, 3, facets),
382            ] {
383                assert_eq!(order(&after, &before), None, "{change}");
384                assert_eq!(order(&before, &after), None, "{change}");
385            }
386        }
387        for relationships in [2, 4, 5] {
388            assert_eq!(
389                order(&token(2, 2, 2, relationships), &before),
390                None,
391                "a relationship count of {relationships} against 3: a far end deleted lowers it"
392            );
393        }
394        let transferred = spell(&Parts {
395            issue: at(1),
396            item: at(1),
397            comments: 2,
398            relationships: 3,
399            facets: facets(),
400            repository: repository("github.com/acme/gadgets"),
401        });
402        assert_eq!(order(&transferred, &before), None);
403    }
404
405    #[test]
406    fn a_token_is_given_only_once_the_later_time_is_more_than_the_margin_behind_the_read() {
407        let served = at(0) + SETTLED + TimeDelta::seconds(1);
408        assert!(settled(at(0), at(0), served));
409        assert!(
410            !settled(at(0), at(1), served),
411            "the board item's later time counts"
412        );
413        assert!(!settled(at(1), at(0), served), "and so does the issue's");
414        assert!(
415            !settled(at(0), at(0), at(0) + SETTLED),
416            "exactly the margin is not past it"
417        );
418        assert!(
419            !settled(at(5), at(5), at(5)),
420            "read in the second of the change"
421        );
422    }
423
424    #[test]
425    fn crossed_times_and_foreign_or_older_values_are_not_ordered() {
426        assert_eq!(order(&token(2, 1, 0, 0), &token(1, 2, 0, 0)), None);
427        assert_eq!(order("md1|abc", &token(1, 1, 0, 0)), None);
428        let digest = facets().as_str().to_owned();
429        for spelled in ["not-a-digest", &digest.to_uppercase(), &digest[1..]] {
430            assert_eq!(FacetsDigest::parse(spelled), None, "{spelled}");
431            assert!(
432                serde_json::from_value::<FacetsDigest>(serde_json::json!(spelled)).is_err(),
433                "a context record holding {spelled} is refused"
434            );
435        }
436        assert_eq!(
437            serde_json::from_value::<FacetsDigest>(serde_json::json!(digest)).ok(),
438            Some(facets())
439        );
440        for malformed in [
441            format!("gh3|not-a-time|x|1|0|{digest}|github.com/a/b"),
442            format!("gh3|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|{digest}|"),
443            format!("gh3|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|{digest}|a/b"),
444            "gh3|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|not-a-digest|github.com/a/b"
445                .to_owned(),
446        ] {
447            assert_eq!(order(&malformed, &malformed), None, "{malformed}");
448        }
449        for older in [
450            "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|github.com/a/b",
451            "gh1|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0",
452        ] {
453            assert_eq!(
454                order(older, older),
455                None,
456                "a token from before the facets is not ordered, even against itself"
457            );
458        }
459        assert_eq!(
460            order(&format!("{}|extra", token(1, 1, 0, 0)), &token(1, 1, 0, 0)),
461            None,
462            "anything after the repository is part of it, and names another"
463        );
464    }
465}