Skip to main content

wsb_github/
sync.rs

1//! Keeping the mirror current.
2//!
3//! One pass asks GitHub about whatever is due, conditionally — an ETag makes
4//! an unchanged repository free, and `since` keeps a changed one small —
5//! writes what it learns into the mirror, and says what moved. Whoever runs
6//! passes — a daemon on a timer, `wsb github sync` from a shell — runs the
7//! same pass, so there is one schedule and one way of writing what came back.
8//!
9//! # What a pass will not do
10//!
11//! * It never empties anything because a fetch failed. A failure is recorded
12//!   beside the data, not instead of it.
13//! * It never forgets anything while some project could not be read: a
14//!   worktree that failed to answer is not a worktree that has nothing.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::time::Duration;
18
19use anyhow::{anyhow, Result};
20use serde::Serialize;
21
22use crate::boards::BoardRef;
23use crate::client::{Conditions, Fetched, GitHubClient, GitHubProject};
24use crate::mirror::{gist_is_whole, newest_update, Document, Mirror, Scope, SyncState, SINGLETON};
25use crate::targets::Targets;
26
27/// How often a CONDITIONAL scope is re-asked about.
28///
29/// Two minutes is affordable because these carry an ETag: GitHub does not
30/// count a 304 against the rate limit at all, so a repository nobody touched
31/// costs nothing to ask about.
32pub const SWEEP: Duration = Duration::from_secs(120);
33
34/// How often a scope that CANNOT be conditional is re-asked about.
35///
36/// Checks and project boards have no ETag to present — the checks endpoints
37/// answer 200 every time, and GraphQL carries none — so every one of these
38/// costs a request against the limit.
39pub const SWEEP_UNCONDITIONAL: Duration = Duration::from_secs(300);
40
41/// How often traffic is re-asked about. GitHub aggregates views and clones by
42/// day, so asking more than a few times a day cannot produce a different
43/// answer.
44pub const SWEEP_TRAFFIC: Duration = Duration::from_secs(6 * 60 * 60);
45
46/// How long a scope that FAILED waits before it is tried again. Longer than
47/// the sweep, because the common failures — no token, a rate limit, no
48/// network — do not resolve in two minutes.
49pub const AFTER_FAILURE: Duration = Duration::from_secs(600);
50
51/// What a pass did.
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
53pub struct SyncReport {
54    /// Scopes whose data actually changed.
55    pub changed: Vec<String>,
56    /// Scopes that were asked and answered "nothing new".
57    pub unchanged: Vec<String>,
58    /// Scopes that could not be asked, and why. Serialised as
59    /// `{ "scope": …, "error": … }`, which is what a reader names them by.
60    #[serde(serialize_with = "failures_as_objects")]
61    pub failed: Vec<(String, String)>,
62    /// True when there was no token to sync with. Not a failure of any scope:
63    /// nothing was attempted, and nothing is wrong with what is held.
64    pub no_token: bool,
65    /// What could not be found out about what to mirror.
66    pub problems: Vec<String>,
67}
68
69impl SyncReport {
70    pub fn touched_anything(&self) -> bool {
71        !self.changed.is_empty()
72    }
73}
74
75fn failures_as_objects<S: serde::Serializer>(
76    failed: &[(String, String)],
77    serializer: S,
78) -> Result<S::Ok, S::Error> {
79    use serde::ser::SerializeSeq;
80    #[derive(Serialize)]
81    struct Failure<'a> {
82        scope: &'a str,
83        error: &'a str,
84    }
85    let mut seq = serializer.serialize_seq(Some(failed.len()))?;
86    for (scope, error) in failed {
87        seq.serialize_element(&Failure { scope, error })?;
88    }
89    seq.end()
90}
91
92/// Every scope the targets want, except the boards' cards.
93///
94/// The cards of a board are known only once its owner's board list is held —
95/// a board is referenced by number and its cards by its node id — so those
96/// are decided after that list is current; see [`board_scopes`].
97pub fn wanted(mirror: &Mirror, targets: &Targets) -> Result<Vec<Scope>> {
98    let mut scopes = vec![Scope::Gists, Scope::Notifications, Scope::RateLimit];
99    for (repo, commits) in &targets.repositories {
100        scopes.extend([
101            Scope::Issues(repo.clone()),
102            Scope::Pulls(repo.clone()),
103            Scope::Repository(repo.clone()),
104            Scope::Releases(repo.clone()),
105            Scope::Tags(repo.clone()),
106            Scope::Milestones(repo.clone()),
107            Scope::Labels(repo.clone()),
108            Scope::Traffic(repo.clone()),
109        ]);
110        scopes.extend(commits.iter().map(|commit| Scope::Checks {
111            repo: repo.clone(),
112            commit: commit.clone(),
113        }));
114    }
115    scopes.extend(targets.board_owners().into_iter().map(Scope::Projects));
116    // Comment threads are mirrored only where one is held: a thread nobody
117    // opened is not worth a request per sweep, and asking for every issue's
118    // comments would be one request per issue.
119    scopes.extend(
120        mirror
121            .states()?
122            .keys()
123            .filter_map(|key| Scope::parse(key))
124            .filter(|scope| match scope {
125                Scope::Comments { repo, .. } => targets.repositories.contains_key(repo),
126                _ => false,
127            }),
128    );
129    Ok(scopes)
130}
131
132/// The boards the targets reference, as the mirror knows them.
133///
134/// A board whose owner's list has not been read yet, or that the list does
135/// not contain, is reported rather than skipped silently: a reference to a
136/// board nobody can read is something a person must hear about.
137pub fn resolve_boards(
138    mirror: &Mirror,
139    boards: &BTreeSet<BoardRef>,
140) -> Result<(Vec<GitHubProject>, Vec<String>)> {
141    let mut found = Vec::new();
142    let mut problems = Vec::new();
143    // Per owner: its board list, or why there is none to look in.
144    let mut lists: BTreeMap<String, std::result::Result<Vec<GitHubProject>, String>> =
145        BTreeMap::new();
146    for board in boards {
147        if !lists.contains_key(&board.owner) {
148            let list = owner_boards(mirror, &board.owner)?;
149            lists.insert(board.owner.clone(), list);
150        }
151        match &lists[&board.owner] {
152            Err(reason) => problems.push(format!("{board}: {reason}")),
153            Ok(list) => match list.iter().find(|project| project.number == board.number) {
154                Some(project) => found.push(project.clone()),
155                None => problems.push(format!("{} has no board {}", board.owner, board.number)),
156            },
157        }
158    }
159    Ok((found, problems))
160}
161
162/// One owner's boards as the mirror holds them, or why it holds none.
163pub fn owner_boards(
164    mirror: &Mirror,
165    owner: &str,
166) -> Result<std::result::Result<Vec<GitHubProject>, String>> {
167    let scope = Scope::Projects(owner.to_string());
168    let state = mirror.state(&scope)?;
169    if !state.ever_synced() {
170        return Ok(Err(match &state.last_error {
171            Some(error) => format!("{owner}'s boards could not be read: {error}"),
172            None => format!("{owner}'s boards have not been read yet"),
173        }));
174    }
175    let boards = mirror
176        .payloads(&scope)?
177        .into_iter()
178        .map(|payload| {
179            serde_json::from_value::<GitHubProject>(payload).map_err(|e| {
180                anyhow!("a mirrored board of {owner} cannot be read by this version ({e}); sync again")
181            })
182        })
183        .collect::<Result<Vec<_>>>()?;
184    Ok(Ok(boards))
185}
186
187/// The card scopes of every referenced board the mirror can resolve.
188pub fn board_scopes(mirror: &Mirror, targets: &Targets) -> Result<(Vec<Scope>, Vec<String>)> {
189    let (found, problems) = resolve_boards(mirror, &targets.boards)?;
190    Ok((
191        found
192            .into_iter()
193            .map(|board| Scope::ProjectItems(board.id))
194            .collect(),
195        problems,
196    ))
197}
198
199/// Sync everything that is due, and say what moved.
200///
201/// `force` ignores the schedule — what a refresh button means — but not the
202/// ETags: a forced sync of an unchanged repository is still one 304.
203///
204/// `client` is `None` when there is no token; the pass then forgets what no
205/// project has any more and asks GitHub nothing.
206pub async fn sync(
207    mirror: &Mirror,
208    client: Option<&GitHubClient>,
209    targets: &Targets,
210    force: bool,
211) -> SyncReport {
212    let mut report = SyncReport {
213        problems: targets.problems.clone(),
214        ..SyncReport::default()
215    };
216
217    let first = match wanted(mirror, targets) {
218        Ok(scopes) => scopes,
219        Err(error) => {
220            report.problems.push(format!("the mirror cannot be read: {error:#}"));
221            return report;
222        }
223    };
224    // The board lists first, then the cards of the boards those lists name.
225    if let Some(client) = client {
226        run(mirror, client, &first, force, &mut report).await;
227    } else {
228        report.no_token = true;
229    }
230    let cards = match board_scopes(mirror, targets) {
231        Ok((scopes, problems)) => {
232            report.problems.extend(problems);
233            scopes
234        }
235        Err(error) => {
236            report.problems.push(format!("the boards cannot be resolved: {error:#}"));
237            Vec::new()
238        }
239    };
240    if let Some(client) = client {
241        run(mirror, client, &cards, force, &mut report).await;
242    }
243
244    let mut all = first;
245    all.extend(cards);
246    forget_departed(mirror, targets, &all, &mut report);
247    report
248}
249
250/// Sync exactly these scopes, due or not as `force` says.
251///
252/// What a reader that is about to show one board does: it brings that board
253/// current, and leaves the rest of the machine's mirror to its schedule.
254pub async fn sync_scopes(
255    mirror: &Mirror,
256    client: &GitHubClient,
257    scopes: &[Scope],
258    force: bool,
259) -> SyncReport {
260    let mut report = SyncReport::default();
261    run(mirror, client, scopes, force, &mut report).await;
262    report
263}
264
265async fn run(
266    mirror: &Mirror,
267    client: &GitHubClient,
268    scopes: &[Scope],
269    force: bool,
270    report: &mut SyncReport,
271) {
272    let now = chrono::Utc::now();
273    for scope in scopes {
274        let state = match mirror.state(scope) {
275            Ok(state) => state,
276            Err(error) => {
277                report.failed.push((scope.key(), format!("{error:#}")));
278                continue;
279            }
280        };
281        if !force && !due(scope, &state, now) {
282            continue;
283        }
284        match sync_one(client, mirror, scope, &state).await {
285            Ok(true) => report.changed.push(scope.key()),
286            Ok(false) => report.unchanged.push(scope.key()),
287            Err(error) => {
288                let message = format!("{error:#}");
289                if let Err(write) = mirror.failed(scope, &message) {
290                    report
291                        .failed
292                        .push((scope.key(), format!("{message}; and the failure could not be recorded: {write:#}")));
293                    continue;
294                }
295                report.failed.push((scope.key(), message));
296            }
297        }
298    }
299}
300
301/// Sync one scope. `true` when the mirror changed.
302///
303/// Every kind does the same three things: ask conditionally, write what came
304/// back, record where the scope now stands. The differences between kinds are
305/// which endpoint and which key — not how a failure is handled, or what an
306/// unchanged answer means.
307async fn sync_one(
308    client: &GitHubClient,
309    mirror: &Mirror,
310    scope: &Scope,
311    held: &SyncState,
312) -> Result<bool> {
313    let conditions = Conditions {
314        etag: held.etag.clone(),
315        since: held.cursor.clone(),
316    };
317
318    match scope {
319        Scope::Issues(repo) => {
320            let (owner, name) = split(repo)?;
321            match client.list_issues_changed(owner, name, &conditions).await? {
322                Fetched::Unchanged => unchanged(mirror, scope),
323                Fetched::Fresh { value, etag } => {
324                    let documents = documents_by(&value, |issue| issue.number.to_string())?;
325                    incremental(mirror, scope, documents, etag)
326                }
327            }
328        }
329        Scope::Pulls(repo) => {
330            let (owner, name) = split(repo)?;
331            match client.list_pulls_changed(owner, name, &conditions).await? {
332                Fetched::Unchanged => unchanged(mirror, scope),
333                Fetched::Fresh { value, etag } => {
334                    let documents = documents_by(&value, |pull| pull.number.to_string())?;
335                    incremental(mirror, scope, documents, etag)
336                }
337            }
338        }
339        Scope::Repository(repo) => {
340            let (owner, name) = split(repo)?;
341            match client.repository_changed(owner, name, &conditions).await? {
342                Fetched::Unchanged => unchanged(mirror, scope),
343                Fetched::Fresh { value, etag } => {
344                    let changed = mirror.put_only(scope, serde_json::to_value(&value)?)?;
345                    mirror.synced(scope, etag.as_deref(), None)?;
346                    Ok(changed)
347                }
348            }
349        }
350        Scope::Checks { repo, commit } => {
351            let (owner, name) = split(repo)?;
352            let checks = client.checks_for(owner, name, commit).await?;
353            let document =
354                Document::of(SINGLETON, serde_json::to_value(&checks)?).with_state(checks.state.clone());
355            let changed = mirror.put(scope, &[document])?;
356            mirror.synced(scope, None, None)?;
357            Ok(changed)
358        }
359        Scope::Releases(repo) => {
360            let (owner, name) = split(repo)?;
361            match client.list_releases_changed(owner, name, &conditions).await? {
362                Fetched::Unchanged => unchanged(mirror, scope),
363                Fetched::Fresh { value, etag } => {
364                    replace(mirror, scope, documents_by(&value, |release| release.id.to_string())?, etag)
365                }
366            }
367        }
368        Scope::Tags(repo) => {
369            let (owner, name) = split(repo)?;
370            match client.list_tags_changed(owner, name, &conditions).await? {
371                Fetched::Unchanged => unchanged(mirror, scope),
372                Fetched::Fresh { value, etag } => {
373                    replace(mirror, scope, documents_by(&value, |tag| tag.name.clone())?, etag)
374                }
375            }
376        }
377        Scope::Milestones(repo) => {
378            let (owner, name) = split(repo)?;
379            match client.list_milestones_changed(owner, name, &conditions).await? {
380                Fetched::Unchanged => unchanged(mirror, scope),
381                Fetched::Fresh { value, etag } => replace(
382                    mirror,
383                    scope,
384                    documents_by(&value, |milestone| milestone.number.to_string())?,
385                    etag,
386                ),
387            }
388        }
389        Scope::Labels(repo) => {
390            let (owner, name) = split(repo)?;
391            match client.list_labels_changed(owner, name, &conditions).await? {
392                Fetched::Unchanged => unchanged(mirror, scope),
393                Fetched::Fresh { value, etag } => {
394                    replace(mirror, scope, documents_by(&value, |label| label.name.clone())?, etag)
395                }
396            }
397        }
398        Scope::Traffic(repo) => {
399            let (owner, name) = split(repo)?;
400            // Needs push access. A repository somebody merely reads answers
401            // 403, which is recorded as this scope's error.
402            let traffic = client.traffic(owner, name).await?;
403            let changed = mirror.put_only(scope, serde_json::to_value(&traffic)?)?;
404            mirror.synced(scope, None, None)?;
405            Ok(changed)
406        }
407        Scope::Comments { repo, number } => {
408            let (owner, name) = split(repo)?;
409            match client
410                .list_comments_changed(owner, name, *number, &conditions)
411                .await?
412            {
413                Fetched::Unchanged => unchanged(mirror, scope),
414                Fetched::Fresh { value, etag } => {
415                    replace(mirror, scope, documents_by(&value, |comment| comment.id.to_string())?, etag)
416                }
417            }
418        }
419        Scope::Gists => match client.list_gists_changed(&conditions).await? {
420            Fetched::Unchanged => unchanged(mirror, scope),
421            Fetched::Fresh { value, etag } => {
422                let mut documents = Vec::new();
423                for gist in &value {
424                    let payload = serde_json::to_value(gist)?;
425                    documents.push(if gist_is_whole(gist) {
426                        Document::of(gist.id.clone(), payload)
427                    } else {
428                        // The list endpoint omits file bodies, so these rows
429                        // are summaries and must say so — or an editor opens
430                        // one and saves empty files over a gist.
431                        Document::summary(gist.id.clone(), payload)
432                    });
433                }
434                replace(mirror, scope, documents, etag)
435            }
436        },
437        Scope::Notifications => match client.list_notifications_changed(&conditions).await? {
438            Fetched::Unchanged => unchanged(mirror, scope),
439            Fetched::Fresh { value, etag } => replace(
440                mirror,
441                scope,
442                documents_by(&value, |notification| notification.id.clone())?,
443                etag,
444            ),
445        },
446        Scope::RateLimit => {
447            let limit = client.rate_limit().await?;
448            let changed = mirror.put_only(scope, serde_json::to_value(&limit)?)?;
449            mirror.synced(scope, None, None)?;
450            Ok(changed)
451        }
452        Scope::Projects(login) => {
453            let projects = client.list_projects(login).await?;
454            replace(mirror, scope, documents_by(&projects, |project| project.id.clone())?, None)
455        }
456        Scope::ProjectItems(id) => {
457            let items = client.project_items(id).await?;
458            replace(mirror, scope, documents_by(&items, |item| item.id.clone())?, None)
459        }
460    }
461}
462
463/// A scope that answered "nothing new".
464fn unchanged(mirror: &Mirror, scope: &Scope) -> Result<bool> {
465    mirror.synced(scope, None, None)?;
466    Ok(false)
467}
468
469/// Write an incremental answer, moving the cursor to the newest thing in it.
470fn incremental(
471    mirror: &Mirror,
472    scope: &Scope,
473    documents: Vec<Document>,
474    etag: Option<String>,
475) -> Result<bool> {
476    let cursor = newest_update(documents.iter());
477    let changed = mirror.put(scope, &documents)?;
478    mirror.synced(scope, etag.as_deref(), cursor.as_deref())?;
479    Ok(changed)
480}
481
482/// Write an authoritative answer.
483fn replace(
484    mirror: &Mirror,
485    scope: &Scope,
486    documents: Vec<Document>,
487    etag: Option<String>,
488) -> Result<bool> {
489    let changed = mirror.put(scope, &documents)?;
490    mirror.synced(scope, etag.as_deref(), None)?;
491    Ok(changed)
492}
493
494/// Turn what GitHub sent into documents, keyed the way that kind is keyed.
495fn documents_by<T: Serialize>(values: &[T], key: impl Fn(&T) -> String) -> Result<Vec<Document>> {
496    values
497        .iter()
498        .map(|value| Ok(Document::of(key(value), serde_json::to_value(value)?)))
499        .collect()
500}
501
502fn split(repo: &str) -> Result<(&str, &str)> {
503    repo.split_once('/')
504        .ok_or_else(|| anyhow!("{repo} is not owner/repo"))
505}
506
507/// The schedule, as a decision about one state at one moment.
508///
509/// Separated from the database on purpose: this is the whole of the policy —
510/// how often a healthy scope is re-asked, how long a failing one waits — and
511/// it is decided by elapsed time, which a test can state and cannot wait for.
512pub fn due(scope: &Scope, state: &SyncState, now: chrono::DateTime<chrono::Utc>) -> bool {
513    let Some(attempted) = state.attempted_at.as_deref().and_then(parse_time) else {
514        // Never asked about: a project opened for the first time must not
515        // wait out a sweep before it shows anything.
516        return true;
517    };
518    let waited = (now - attempted).to_std().unwrap_or(Duration::ZERO);
519    if state.last_error.is_some() {
520        return waited >= AFTER_FAILURE;
521    }
522    waited >= interval(scope)
523}
524
525/// How long a scope waits between askings, decided by whether the ask is
526/// FREE: a conditional request that answers 304 costs nothing, and anything
527/// that spends a request every time has to earn its frequency.
528pub fn interval(scope: &Scope) -> Duration {
529    match scope {
530        Scope::Traffic(_) => SWEEP_TRAFFIC,
531        Scope::Checks { .. } | Scope::Projects(_) | Scope::ProjectItems(_) => SWEEP_UNCONDITIONAL,
532        _ => SWEEP,
533    }
534}
535
536/// Drop what no project has any more.
537///
538/// Nothing is dropped while any project could not be read, or while a
539/// referenced board could not be resolved: either way the list of what is
540/// wanted is incomplete, and acting on an incomplete list deletes what is
541/// still wanted.
542fn forget_departed(mirror: &Mirror, targets: &Targets, wanted: &[Scope], report: &mut SyncReport) {
543    if !report.problems.is_empty() {
544        return;
545    }
546    let keep: BTreeSet<String> = wanted.iter().map(Scope::key).collect();
547    let known = match mirror.states() {
548        Ok(known) => known,
549        Err(error) => {
550            report.problems.push(format!("the mirror cannot be read: {error:#}"));
551            return;
552        }
553    };
554    for key in known.keys() {
555        if keep.contains(key) {
556            continue;
557        }
558        let Some(scope) = Scope::parse(key) else {
559            report.problems.push(format!("the mirror holds a scope it cannot read: {key}"));
560            continue;
561        };
562        let forget = match &scope {
563            // The person's own, not any project's.
564            Scope::Gists | Scope::Notifications | Scope::RateLimit => false,
565            // Wanted ones are all in `keep`: a commit no worktree is on, an
566            // owner whose boards nobody references, a board nobody references.
567            Scope::Checks { .. } | Scope::Projects(_) | Scope::ProjectItems(_) => true,
568            // A repository's own kinds, comment threads included, go when no
569            // project has the repository any more.
570            other => other
571                .repository()
572                .is_some_and(|repo| !targets.repositories.contains_key(repo)),
573        };
574        if forget {
575            match mirror.forget(&scope) {
576                Ok(()) => report.changed.push(scope.key()),
577                Err(error) => report
578                    .failed
579                    .push((scope.key(), format!("cannot forget it: {error:#}"))),
580            }
581        }
582    }
583}
584
585/// How often a long-running process runs a pass. Each pass asks only what is
586/// due, so this is how quickly something due is noticed, not how often GitHub
587/// is asked.
588pub const TICK: Duration = Duration::from_secs(30);
589
590/// Keep the machine's mirror current for as long as the caller runs, and hand
591/// every pass's report to `on_pass`.
592///
593/// What a process that stays — a workbench, a daemon — runs. Every pass reads
594/// what to mirror from the projects again and resolves the token again, so a
595/// worktree added or a `gh auth login` made while it runs is picked up on the
596/// next pass rather than at the next restart.
597pub async fn keep_current(
598    prj: wsb_prj::Prj,
599    mirror: Mirror,
600    mut on_pass: impl FnMut(&SyncReport) + Send,
601) {
602    loop {
603        let targets_of = prj.clone();
604        let read = tokio::task::spawn_blocking(move || {
605            (Targets::of(&targets_of), crate::token::resolve())
606        })
607        .await;
608        let report = match read {
609            Ok((Ok(targets), token)) => {
610                let client = token.ok().map(|token| GitHubClient::new(token.value()));
611                sync(&mirror, client.as_ref(), &targets, false).await
612            }
613            Ok((Err(error), _)) => SyncReport {
614                problems: vec![format!("the projects under {} cannot be read: {error:#}", prj.root().display())],
615                ..SyncReport::default()
616            },
617            Err(error) => SyncReport {
618                problems: vec![format!("reading what to mirror failed: {error}")],
619                ..SyncReport::default()
620            },
621        };
622        on_pass(&report);
623        tokio::time::sleep(TICK).await;
624    }
625}
626
627fn parse_time(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
628    chrono::DateTime::parse_from_rfc3339(value)
629        .ok()
630        .map(|time| time.with_timezone(&chrono::Utc))
631}
632
633#[cfg(test)]
634#[path = "sync_tests.rs"]
635mod tests;