Skip to main content

onetaskgraph_github_projects/
change_token.rs

1//! A task's change token on a GitHub board: what [`Task::version`](onetaskgraph_plugin_api::Task::version)
2//! holds here, and the one ordering of two of them.
3//!
4//! `docs/plugin-protocol.md` §4.13d is the specification: the five parts the token is built
5//! from, the change kinds each covers member by member, and why a token waits for its task to
6//! settle. What is here is how this module holds to it. Each part is selected by every list,
7//! search and detail read already, so the token costs no request; an item missing any part — a
8//! draft, or an item this process wrote and has not read back — gives none. [`order`] says how
9//! two tokens of one issue stand to each other, and an item whose later time is within
10//! [`SETTLED`] of the response's `Date`, or read from a response without one, gives no token.
11
12use std::cmp::Ordering;
13
14use chrono::{DateTime, SecondsFormat, TimeDelta, Utc};
15use onetaskgraph_plugin_api::Repository;
16
17/// How long before the response that read it an item must last have changed for its token to
18/// be given.
19///
20/// GitHub aborts a GraphQL query that runs past ten seconds, so the state a response reports
21/// was read at most about ten seconds before the `Date` it is stamped with. Sixty seconds is
22/// that bound with a wide margin: an item whose later time is further back than this was last
23/// changed in an earlier second than the read, so no change after the read can share its
24/// second.
25pub const SETTLED: TimeDelta = TimeDelta::seconds(60);
26
27/// Whether an issue last changed at `issue`, whose board item last changed at `item`, had
28/// settled by `served` — the `Date` of the response that read it — so its token may be given.
29#[must_use]
30pub fn settled(issue: DateTime<Utc>, item: DateTime<Utc>, served: DateTime<Utc>) -> bool {
31    served - issue.max(item) > SETTLED
32}
33
34/// What every token this plugin gives starts with, so a reader can tell it from another
35/// plugin's.
36///
37/// `gh2` since the relationship count and the repository joined the token: a `gh1` token,
38/// which lacked both, is not one this plugin orders or ever gives, so a copy that recorded one
39/// reads its task again once rather than trusting a version that could not see an edge, a
40/// project membership or a transfer move.
41pub const PREFIX: &str = "gh2|";
42
43/// What one token of this plugin is made of.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Parts {
46    /// When the issue last changed.
47    pub issue: DateTime<Utc>,
48    /// When its board item last changed.
49    pub item: DateTime<Utc>,
50    /// How many comments it holds.
51    pub comments: u64,
52    /// How many relationship events — dependencies and project memberships added or removed —
53    /// its timeline holds.
54    pub relationships: u64,
55    /// The repository it is in. Spelled last, and read back as everything after the fourth
56    /// count, so any origin a [`Repository`] admits comes back as it went.
57    pub repository: Repository,
58}
59
60/// The token these parts spell.
61#[must_use]
62pub fn spell(parts: &Parts) -> String {
63    format!(
64        "{PREFIX}{}|{}|{}|{}|{}",
65        parts.issue.to_rfc3339_opts(SecondsFormat::AutoSi, true),
66        parts.item.to_rfc3339_opts(SecondsFormat::AutoSi, true),
67        parts.comments,
68        parts.relationships,
69        parts.repository.as_str()
70    )
71}
72
73/// The parts of a token this plugin gave, or `None` for any other value.
74fn parts(token: &str) -> Option<Parts> {
75    let mut parts = token.strip_prefix(PREFIX)?.splitn(5, '|');
76    let issue = DateTime::parse_from_rfc3339(parts.next()?).ok()?;
77    let item = DateTime::parse_from_rfc3339(parts.next()?).ok()?;
78    let comments = parts.next()?.parse().ok()?;
79    let relationships = parts.next()?.parse().ok()?;
80    let repository = Repository::try_from(parts.next()?.to_owned()).ok()?;
81    Some(Parts {
82        issue: issue.with_timezone(&Utc),
83        item: item.with_timezone(&Utc),
84        comments,
85        relationships,
86        repository,
87    })
88}
89
90/// How the version `a` of one issue stands to its version `b`: `Greater` when `a` is the later,
91/// `Less` when it is the earlier, `Equal` when nothing the token covers changed between them —
92/// and `None` when either is not a token of this plugin, or the two cannot both be versions of
93/// one issue read in order.
94///
95/// Two versions naming different repositories are never ordered: a transfer moves the issue
96/// and says nothing about which reading came first. Both times and the relationship count only
97/// ever move forward, so a later version holds none of the three earlier or smaller; one
98/// holding one of them ahead and another behind is no version read after the other. When all
99/// three are equal and the comment counts are not, the version with fewer comments is the
100/// later: the one change that moves the comment count and nothing else is a comment deleted,
101/// because adding one moves the issue's time.
102#[must_use]
103pub fn order(a: &str, b: &str) -> Option<Ordering> {
104    let (a, b) = (parts(a)?, parts(b)?);
105    if a.repository != b.repository {
106        return None;
107    }
108    let forward = [
109        a.issue.cmp(&b.issue),
110        a.item.cmp(&b.item),
111        a.relationships.cmp(&b.relationships),
112    ];
113    if forward.iter().all(|part| part.is_eq()) {
114        Some(b.comments.cmp(&a.comments))
115    } else if forward.iter().all(|part| part.is_le()) {
116        Some(Ordering::Less)
117    } else if forward.iter().all(|part| part.is_ge()) {
118        Some(Ordering::Greater)
119    } else {
120        None
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use chrono::TimeZone as _;
128
129    fn at(second: u32) -> DateTime<Utc> {
130        Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, second).unwrap()
131    }
132
133    fn token(issue: u32, item: u32, comments: u64, relationships: u64) -> String {
134        spell(&Parts {
135            issue: at(issue),
136            item: at(item),
137            comments,
138            relationships,
139            repository: repository("github.com/acme/widgets"),
140        })
141    }
142
143    fn repository(origin: &str) -> Repository {
144        Repository::try_from(origin.to_owned()).expect("a repository origin")
145    }
146
147    #[test]
148    fn every_change_kind_orders_the_later_version_after_the_earlier() {
149        let before = token(1, 1, 2, 3);
150        for after in [
151            token(2, 1, 3, 3), // a comment added
152            token(2, 1, 2, 3), // a comment edited, a body or title edit, a label
153            token(1, 1, 1, 3), // a comment deleted
154            token(1, 2, 2, 3), // a board field: status, priority, origin
155            token(1, 1, 2, 4), // a dependency or a project membership added or removed
156            token(1, 1, 2, 5), // either replaced: one removed and one added
157        ] {
158            assert_eq!(order(&after, &before), Some(Ordering::Greater), "{after}");
159            assert_eq!(order(&before, &after), Some(Ordering::Less), "{after}");
160        }
161        assert_eq!(order(&before, &before), Some(Ordering::Equal));
162    }
163
164    #[test]
165    fn a_token_is_given_only_once_the_later_time_is_more_than_the_margin_behind_the_read() {
166        let served = at(0) + SETTLED + TimeDelta::seconds(1);
167        assert!(settled(at(0), at(0), served));
168        assert!(
169            !settled(at(0), at(1), served),
170            "the board item's later time counts"
171        );
172        assert!(!settled(at(1), at(0), served), "and so does the issue's");
173        assert!(
174            !settled(at(0), at(0), at(0) + SETTLED),
175            "exactly the margin is not past it"
176        );
177        assert!(
178            !settled(at(5), at(5), at(5)),
179            "read in the second of the change"
180        );
181    }
182
183    #[test]
184    fn crossed_parts_another_repository_and_foreign_values_are_not_ordered() {
185        assert_eq!(order(&token(2, 1, 0, 0), &token(1, 2, 0, 0)), None);
186        assert_eq!(
187            order(&token(2, 1, 0, 0), &token(1, 1, 0, 1)),
188            None,
189            "a later time beside fewer relationship events is no version read after the other"
190        );
191        let transferred = spell(&Parts {
192            issue: at(1),
193            item: at(1),
194            comments: 0,
195            relationships: 0,
196            repository: repository("github.com/acme/gadgets"),
197        });
198        assert_eq!(
199            order(&transferred, &token(1, 1, 0, 0)),
200            None,
201            "a token naming another repository is not equal, and not ordered"
202        );
203        assert_eq!(order("md1|abc", &token(1, 1, 0, 0)), None);
204        assert_eq!(
205            order(
206                "gh2|not-a-time|x|1|0|github.com/a/b",
207                "gh2|not-a-time|x|1|0|github.com/a/b"
208            ),
209            None
210        );
211        assert_eq!(
212            order(
213                "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|",
214                "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|"
215            ),
216            None,
217            "a token naming no repository"
218        );
219        assert_eq!(
220            order(
221                "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|a/b",
222                "gh2|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0|0|a/b"
223            ),
224            None,
225            "a token naming something that is no repository origin"
226        );
227        assert_eq!(
228            order(
229                "gh1|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0",
230                "gh1|2026-10-01T00:00:01Z|2026-10-01T00:00:01Z|0"
231            ),
232            None,
233            "a token from before the relationship count is not ordered, even against itself"
234        );
235        assert_eq!(
236            order(&format!("{}|extra", token(1, 1, 0, 0)), &token(1, 1, 0, 0)),
237            None,
238            "anything after the repository is part of it, and names another"
239        );
240    }
241}