wsb-github 0.248.6265

GitHub issues, pull requests, gists and Projects boards, mirrored once per machine for every project under $PRJ
Documentation
//! When the mirror asks GitHub anything, and when it deliberately does not.

use super::*;
use crate::mirror::FILE;

fn now() -> chrono::DateTime<chrono::Utc> {
    chrono::Utc::now()
}

/// A state whose last attempt was `seconds` ago, and how it went.
fn attempted(seconds: i64, error: Option<&str>) -> SyncState {
    let when = (now() - chrono::Duration::seconds(seconds)).to_rfc3339();
    SyncState {
        attempted_at: Some(when.clone()),
        synced_at: Some(when),
        last_error: error.map(str::to_string),
        ..Default::default()
    }
}

fn mirror() -> (tempfile::TempDir, Mirror) {
    let dir = tempfile::tempdir().expect("a scratch directory");
    let mirror = Mirror::open(&dir.path().join(FILE)).expect("a mirror");
    (dir, mirror)
}

fn issue(number: i64) -> Document {
    Document::of(
        number.to_string(),
        serde_json::json!({
            "number": number,
            "title": "an issue",
            "state": "open",
            "updated_at": "2026-01-01T00:00:00Z",
        }),
    )
}

fn checks_of(repo: &str, commit: &str) -> Scope {
    Scope::Checks {
        repo: repo.to_string(),
        commit: commit.to_string(),
    }
}

fn targets(repos: &[(&str, &[&str])], boards: &[&str]) -> Targets {
    Targets {
        repositories: repos
            .iter()
            .map(|(repo, commits)| {
                (
                    repo.to_string(),
                    commits.iter().map(|c| c.to_string()).collect(),
                )
            })
            .collect(),
        boards: boards.iter().map(|b| b.parse().expect("a board")).collect(),
        problems: Vec::new(),
    }
}

/// A board as `list_projects` returns one, enough to resolve by number.
fn board(id: &str, owner: &str, number: i64) -> Document {
    Document::of(
        id,
        serde_json::json!({
            "id": id, "number": number, "title": format!("board {number}"),
            "url": format!("https://github.com/orgs/{owner}/projects/{number}"),
            "short_description": null, "closed": false,
            "updated_at": "2026-01-01T00:00:00Z", "owner": owner, "fields": [],
        }),
    )
}

/// TEST10009: a scope just asked about is not asked again.
#[test]
fn test10009_a_fresh_scope_is_not_re_asked() {
    let issues = Scope::Issues("owner/repo".to_string());
    assert!(
        !due(&issues, &attempted(5, None), now()),
        "a scope asked five seconds ago is current"
    );
    assert!(
        due(&issues, &attempted(SWEEP.as_secs() as i64 + 5, None), now()),
        "a scope older than the sweep is due"
    );
    // Never asked about at all: due immediately, or a project opened for the
    // first time would show nothing until the first sweep elapsed.
    assert!(due(&issues, &SyncState::default(), now()));
}

/// TEST10039: what cannot be asked for free is asked less often.
#[test]
fn test10039_a_scope_that_cannot_be_conditional_is_asked_less_often() {
    let issues = Scope::Issues("owner/repo".to_string());
    let checks = checks_of("owner/repo", "abc");
    let traffic = Scope::Traffic("owner/repo".to_string());
    let cards = Scope::ProjectItems("PVT_1".to_string());

    assert_eq!(interval(&issues), SWEEP);
    assert_eq!(interval(&checks), SWEEP_UNCONDITIONAL);
    assert_eq!(interval(&cards), SWEEP_UNCONDITIONAL);
    assert_eq!(interval(&traffic), SWEEP_TRAFFIC);
    assert!(
        SWEEP < SWEEP_UNCONDITIONAL && SWEEP_UNCONDITIONAL < SWEEP_TRAFFIC,
        "the intervals must be ordered by what an ask costs"
    );

    let three_minutes = attempted(180, None);
    assert!(due(&issues, &three_minutes, now()));
    assert!(!due(&checks, &three_minutes, now()));
    assert!(!due(&traffic, &three_minutes, now()));

    let an_hour = attempted(3600, None);
    assert!(due(&checks, &an_hour, now()));
    assert!(!due(&traffic, &an_hour, now()));
    assert!(due(&traffic, &attempted(SWEEP_TRAFFIC.as_secs() as i64 + 60, None), now()));
}

/// TEST10010: a scope that failed backs off, rather than failing every sweep.
#[test]
fn test10010_a_failed_scope_waits_longer_before_being_retried() {
    let scope = Scope::Gists;
    assert!(
        !due(&scope, &attempted(SWEEP.as_secs() as i64 + 5, Some("status 403")), now()),
        "a failing scope must not be retried at sweep speed"
    );
    assert!(
        due(&scope, &attempted(AFTER_FAILURE.as_secs() as i64 + 5, Some("status 403")), now()),
        "it is retried once the back-off is over"
    );
}

/// TEST10011: a repository no project has any more stops being mirrored —
/// every kind of it.
#[tokio::test]
async fn test10011_a_departed_repository_is_forgotten() {
    let (_dir, mirror) = mirror();
    let kept = Scope::Issues("owner/kept".to_string());
    let gone = Scope::Issues("owner/gone".to_string());
    let gone_releases = Scope::Releases("owner/gone".to_string());
    for scope in [&kept, &gone, &gone_releases] {
        mirror.put(scope, &[issue(1)]).expect("stored");
        mirror.synced(scope, None, None).expect("recorded");
    }

    let report = sync(&mirror, None, &targets(&[("owner/kept", &[])], &[]), false).await;

    assert!(mirror.documents(&gone).expect("read").is_empty());
    assert!(mirror.documents(&gone_releases).expect("read").is_empty(), "every kind goes");
    assert!(
        report.changed.contains(&gone.key()) && report.changed.contains(&gone_releases.key()),
        "the readers must be told what went: {report:?}"
    );
    assert!(!report.changed.contains(&kept.key()), "{report:?}");
    assert_eq!(
        mirror.documents(&kept).expect("read").len(),
        1,
        "a repository that is still here must be left alone"
    );
}

/// TEST10012: with no token, nothing is asked, nothing fails, and nothing that
/// is still wanted is lost.
#[tokio::test]
async fn test10012_no_token_is_not_a_failure_of_any_scope() {
    let (_dir, mirror) = mirror();
    let issues = Scope::Issues("owner/repo".to_string());
    mirror.put(&issues, &[issue(1)]).expect("stored");
    mirror.synced(&issues, Some("etag"), None).expect("recorded");
    mirror.put(&Scope::Gists, &[Document::of("g", serde_json::json!({"id":"g","files":{}}))]).expect("stored");

    let report = sync(&mirror, None, &targets(&[("owner/repo", &[])], &[]), true).await;

    assert!(report.no_token, "the pass must say there was no token");
    assert!(report.failed.is_empty(), "no scope failed — none was asked: {report:?}");
    assert!(!report.touched_anything(), "nothing was fetched, so nothing changed: {report:?}");
    assert_eq!(mirror.documents(&issues).expect("read").len(), 1);
    assert_eq!(mirror.documents(&Scope::Gists).expect("read").len(), 1);
    assert_eq!(mirror.state(&issues).expect("read").etag.as_deref(), Some("etag"));
}

/// While any project could not be read, nothing is forgotten.
///
/// A worktree whose git failed this minute is not a worktree that stopped
/// having repositories; forgetting on its account would delete, on every
/// transient failure, what it will want again in a minute.
#[tokio::test]
async fn nothing_is_forgotten_while_a_project_could_not_be_read() {
    let (_dir, mirror) = mirror();
    let issues = Scope::Issues("owner/unread".to_string());
    mirror.put(&issues, &[issue(1)]).expect("stored");
    mirror.synced(&issues, None, None).expect("recorded");

    let mut unread = targets(&[], &[]);
    unread.problems.push("alpha/fix: git could not read the commit".to_string());
    let report = sync(&mirror, None, &unread, false).await;

    assert_eq!(
        mirror.documents(&issues).expect("read").len(),
        1,
        "a repository was forgotten because its project could not be read"
    );
    assert_eq!(report.problems, unread.problems, "the problem must be reported");
}

/// CI is kept for the commits some worktree is on, and only those.
#[tokio::test]
async fn checks_follow_the_commits_worktrees_are_on() {
    let (_dir, mirror) = mirror();
    let current = checks_of("owner/repo", "c1");
    let left = checks_of("owner/repo", "c0");
    for scope in [&current, &left] {
        mirror
            .put(scope, &[Document::of(SINGLETON, serde_json::json!({"state":"success"}))])
            .expect("stored");
        mirror.synced(scope, None, None).expect("recorded");
    }
    let wanted_now = targets(&[("owner/repo", &["c1"])], &[]);
    assert!(
        wanted(&mirror, &wanted_now).expect("read").contains(&current),
        "the commit a worktree is on must be asked about"
    );

    sync(&mirror, None, &wanted_now, false).await;

    assert!(mirror.only(&current).expect("read").is_some());
    assert!(
        mirror.only(&left).expect("read").is_none(),
        "CI for a commit no worktree is on any more was kept"
    );
}

/// TEST20003: a board's cards are mirrored while a project references it or
/// while they are held for a board its owner still lists, and forgotten when
/// the owner no longer lists the board.
///
/// Every board this person reaches is shown, and one opened from the list has
/// its cards read then. Dropping those on the next pass because no project
/// references the board would empty a board somebody is looking at; keeping
/// them after the board is gone would keep paying a request for nothing.
#[tokio::test]
async fn test20003_cards_follow_referenced_and_opened_boards_while_they_exist() {
    let (_dir, mirror) = mirror();
    let lists = Scope::Projects("machinefabric".to_string());
    mirror
        .put(&lists, &[board("PVT_7", "machinefabric", 7), board("PVT_8", "machinefabric", 8)])
        .expect("stored");
    mirror.synced(&lists, None, None).expect("recorded");
    // PVT_9 was opened once and has since been deleted on GitHub: its owner's
    // list no longer names it.
    for id in ["PVT_7", "PVT_8", "PVT_9"] {
        let cards = Scope::ProjectItems(id.to_string());
        mirror.put(&cards, &[Document::of("PVTI_1", serde_json::json!({"id":"PVTI_1"}))]).expect("stored");
        mirror.synced(&cards, None, None).expect("recorded");
    }

    let referencing_7 = targets(&[], &["machinefabric/7"]);
    let (scopes, problems) = board_scopes(&mirror, &referencing_7).expect("resolved");
    assert_eq!(
        scopes,
        vec![
            Scope::ProjectItems("PVT_7".to_string()),
            Scope::ProjectItems("PVT_8".to_string()),
        ],
        "the referenced board and the opened one its owner still lists"
    );
    assert!(problems.is_empty(), "{problems:?}");

    sync(&mirror, None, &referencing_7, false).await;

    assert_eq!(mirror.documents(&Scope::ProjectItems("PVT_7".to_string())).expect("read").len(), 1);
    assert_eq!(
        mirror.documents(&Scope::ProjectItems("PVT_8".to_string())).expect("read").len(),
        1,
        "the cards of an opened board were dropped because no project references it"
    );
    assert!(
        mirror.documents(&Scope::ProjectItems("PVT_9".to_string())).expect("read").is_empty(),
        "the cards of a board its owner no longer lists were kept"
    );
}

/// TEST20004: the token's account and every organisation it belongs to are
/// owners whose board lists are mirrored, and leaving an organisation
/// forgets its boards.
///
/// The boards a person has are not only the ones a project names: they are
/// every board of their own account and of each organisation. Those owners
/// come from the mirrored viewer, so a board list is wanted for each — and an
/// organisation that is no longer one of them stops being asked about.
#[tokio::test]
async fn test20004_the_viewers_organisations_are_owners_whose_boards_are_kept() {
    let (_dir, mirror) = mirror();
    let account = |organizations: &[&str]| {
        serde_json::json!({ "login": "bahram", "organizations": organizations })
    };
    mirror.put_only(&Scope::Viewer, account(&["machinefabric", "floom"])).expect("stored");
    mirror.synced(&Scope::Viewer, None, None).expect("recorded");

    let nothing_referenced = targets(&[], &["someone/1"]);
    let owners = board_owners(&mirror, &nothing_referenced).expect("read");
    assert_eq!(
        owners.into_iter().collect::<Vec<_>>(),
        vec!["bahram", "floom", "machinefabric", "someone"],
        "the account, its organisations and the referenced board's owner"
    );
    let asked = wanted(&mirror, &nothing_referenced).expect("read");
    for owner in ["bahram", "floom", "machinefabric"] {
        assert!(asked.contains(&Scope::Projects(owner.to_string())), "{owner}'s boards are not asked for");
    }

    // Each organisation's list is held; then the person leaves floom.
    for owner in ["machinefabric", "floom"] {
        let list = Scope::Projects(owner.to_string());
        mirror.put(&list, &[board(&format!("PVT_{owner}"), owner, 1)]).expect("stored");
        mirror.synced(&list, None, None).expect("recorded");
    }
    mirror.put_only(&Scope::Viewer, account(&["machinefabric"])).expect("stored");

    sync(&mirror, None, &targets(&[], &[]), false).await;

    assert_eq!(mirror.documents(&Scope::Projects("machinefabric".to_string())).expect("read").len(), 1);
    assert!(
        mirror.documents(&Scope::Projects("floom".to_string())).expect("read").is_empty(),
        "the boards of an organisation the person left were kept"
    );
    assert!(
        mirror.only(&Scope::Viewer).expect("read").is_some(),
        "the account is the person's own and is never forgotten"
    );
}

/// A reference that cannot be resolved is reported, and stops forgetting.
#[tokio::test]
async fn an_unresolvable_board_is_reported_and_forgets_nothing() {
    let (_dir, mirror) = mirror();
    let lists = Scope::Projects("machinefabric".to_string());
    mirror.put(&lists, &[board("PVT_7", "machinefabric", 7)]).expect("stored");
    mirror.synced(&lists, None, None).expect("recorded");
    let held = Scope::ProjectItems("PVT_7".to_string());
    mirror.put(&held, &[Document::of("PVTI_1", serde_json::json!({"id":"PVTI_1"}))]).expect("stored");

    // Board 9 does not exist; someone's boards have never been read.
    let report = sync(
        &mirror,
        None,
        &targets(&[], &["machinefabric/7", "machinefabric/9", "someone/1"]),
        false,
    )
    .await;

    assert!(
        report.problems.iter().any(|p| p == "machinefabric has no board 9"),
        "{report:?}"
    );
    assert!(
        report.problems.iter().any(|p| p.starts_with("someone/1: someone's boards have not been read")),
        "{report:?}"
    );
    assert_eq!(mirror.documents(&held).expect("read").len(), 1);
}

/// A report's failures reach a reader as named fields, not as positions.
#[test]
fn a_reports_failures_are_named() {
    let report = SyncReport {
        failed: vec![("issues:o/r".to_string(), "status 403".to_string())],
        ..SyncReport::default()
    };
    let written = serde_json::to_value(&report).expect("serialises");
    assert_eq!(
        written["failed"],
        serde_json::json!([{ "scope": "issues:o/r", "error": "status 403" }])
    );
    assert_eq!(written["no_token"], false);
}