wsb-github 0.227.4750

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"
    );
}

/// A board's cards are mirrored while some project references the board, and
/// forgotten when none does.
#[tokio::test]
async fn cards_follow_the_boards_projects_reference() {
    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");
    for id in ["PVT_7", "PVT_8"] {
        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())]);
    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!(
        mirror.documents(&Scope::ProjectItems("PVT_8".to_string())).expect("read").is_empty(),
        "the cards of a board nobody references were kept"
    );
}

/// 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);
}