use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use wsb_github::client::{Conditions, Fetched, GitHubClient, ProjectFieldWrite};
use wsb_github::mirror::{newest_update, Document, Mirror, Scope, FILE};
use wsb_github::service::Service;
use wsb_github::targets::Targets;
#[derive(Default)]
struct Asked {
paths: Mutex<Vec<String>>,
conditions: Mutex<Vec<Option<String>>>,
served: AtomicUsize,
bodies: Mutex<Vec<String>>,
}
fn fake_github(routes: Vec<(&'static str, &'static str)>, etag: &'static str) -> (SocketAddr, Arc<Asked>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
let address = listener.local_addr().expect("an address");
let asked = Arc::new(Asked::default());
let log = asked.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let Some(request) = read_request(&mut stream) else {
continue;
};
let first = request.lines().next().unwrap_or_default().to_string();
let path = first.split_whitespace().nth(1).unwrap_or("/").to_string();
let presented = request.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("if-none-match")
.then(|| value.trim().to_string())
});
log.paths.lock().expect("the path log").push(path.clone());
log.conditions
.lock()
.expect("the condition log")
.push(presented.clone());
if let Some((_, body)) = request.split_once("\r\n\r\n") {
if !body.is_empty() {
log.bodies.lock().expect("the body log").push(body.to_string());
}
}
let body = request.split_once("\r\n\r\n").map(|(_, body)| body).unwrap_or("");
let matched = routes
.iter()
.filter(|(route, _)| match route.split_once('#') {
Some((prefix, needle)) => path.starts_with(prefix) && body.contains(needle),
None => path.starts_with(route),
})
.max_by_key(|(route, _)| route.len());
let response = match matched {
None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_string(),
Some(_) if presented.as_deref() == Some(etag) => {
format!(
"HTTP/1.1 304 Not Modified\r\nETag: {etag}\r\nConnection: close\r\n\r\n"
)
}
Some((_, body)) => {
log.served.fetch_add(1, Ordering::SeqCst);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nETag: {etag}\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
};
use std::io::Write;
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
(address, asked)
}
fn read_request(stream: &mut std::net::TcpStream) -> Option<String> {
use std::io::Read;
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
match stream.read(&mut byte) {
Ok(0) => return None,
Ok(_) => head.push(byte[0]),
Err(_) => return None,
}
}
let head = String::from_utf8(head).ok()?;
let length: usize = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().ok())?
})
.unwrap_or(0);
let mut body = vec![0u8; length];
if length > 0 && stream.read_exact(&mut body).is_err() {
return None;
}
Some(format!("{head}{}", String::from_utf8_lossy(&body)))
}
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)
}
const ISSUES: &str = r#"[
{"number":1,"title":"first","body":null,"state":"open",
"user":{"login":"someone","avatar_url":null},"labels":[],"comments":0,
"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z",
"closed_at":null,"html_url":"https://github.com/o/r/issues/1"},
{"number":2,"title":"second","body":null,"state":"closed",
"user":{"login":"someone","avatar_url":null},"labels":[],"comments":0,
"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-03T00:00:00Z",
"closed_at":"2026-01-03T00:00:00Z","html_url":"https://github.com/o/r/issues/2"},
{"number":3,"title":"a pull request","body":null,"state":"open",
"user":{"login":"someone","avatar_url":null},"labels":[],"comments":0,
"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-04T00:00:00Z",
"closed_at":null,"html_url":"https://github.com/o/r/pull/3"}
]"#;
#[tokio::test]
async fn test10021_an_unchanged_repository_costs_a_conditional_request() {
let (address, asked) = fake_github(vec![("/repos/o/r/issues", ISSUES)], "\"etag-one\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let (_dir, mirror) = mirror();
let scope = Scope::Issues("o/r".to_string());
let first = client
.list_issues_changed("o", "r", &Conditions::default())
.await
.expect("the first fetch");
let Fetched::Fresh { value, etag } = first else {
panic!("the first fetch must return data");
};
assert_eq!(value.len(), 2, "pull requests must not be mirrored as issues");
assert_eq!(etag.as_deref(), Some("\"etag-one\""));
let documents: Vec<Document> = value
.iter()
.map(|issue| {
Document::of(
issue.number.to_string(),
serde_json::to_value(issue).expect("an issue serialises"),
)
})
.collect();
mirror.put(&scope, &documents).expect("stored");
mirror
.synced(
&scope,
etag.as_deref(),
newest_update(documents.iter()).as_deref(),
)
.expect("recorded");
let state = mirror.state(&scope).expect("state");
let second = client
.list_issues_changed(
"o",
"r",
&Conditions {
etag: state.etag.clone(),
since: state.cursor.clone(),
},
)
.await
.expect("the second fetch");
assert!(
matches!(second, Fetched::Unchanged),
"an unchanged repository answers 304, and nothing comes back with it"
);
assert_eq!(
asked.served.load(Ordering::SeqCst),
1,
"the body must be transferred once, not twice"
);
let conditions = asked.conditions.lock().expect("the log").clone();
assert_eq!(conditions[0], None, "the first request has nothing to present");
assert_eq!(
conditions[1].as_deref(),
Some("\"etag-one\""),
"the second request must present what the first was given"
);
let paths = asked.paths.lock().expect("the log").clone();
assert!(
paths[1].contains("since=2026-01-03T00%3A00%3A00Z")
|| paths[1].contains("since=2026-01-03T00:00:00Z"),
"the second request must ask only for what changed since the newest held: {}",
paths[1]
);
mirror.synced(&scope, None, None).expect("recorded");
assert_eq!(mirror.documents(&scope).expect("read").len(), 2);
let state = mirror.state(&scope).expect("state");
assert_eq!(
state.etag.as_deref(),
Some("\"etag-one\""),
"a 304 carries no ETag of its own, so the held one stands"
);
assert_eq!(state.last_error, None);
}
#[tokio::test]
async fn test10022_a_refusal_dates_the_mirror_rather_than_emptying_it() {
let (address, _asked) = fake_github(vec![("/repos/o/r/issues", ISSUES)], "\"etag-one\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let (_dir, mirror) = mirror();
let scope = Scope::Issues("o/r".to_string());
let Fetched::Fresh { value, etag } = client
.list_issues_changed("o", "r", &Conditions::default())
.await
.expect("the first fetch")
else {
panic!("the first fetch must return data");
};
let documents: Vec<Document> = value
.iter()
.map(|issue| {
Document::of(
issue.number.to_string(),
serde_json::to_value(issue).expect("an issue serialises"),
)
})
.collect();
mirror.put(&scope, &documents).expect("stored");
mirror.synced(&scope, etag.as_deref(), None).expect("recorded");
let refused = client
.list_issues_changed("o", "missing/nested", &Conditions::default())
.await;
let error = refused.expect_err("a repository that is not there must refuse");
mirror.failed(&scope, &error.to_string()).expect("recorded");
assert_eq!(
mirror.documents(&scope).expect("read").len(),
2,
"a failed refresh must not empty the list"
);
let state = mirror.state(&scope).expect("state");
assert!(state.last_error.is_some(), "the failure is recorded");
assert!(state.ever_synced(), "and what worked before still counts");
assert_eq!(
state.etag.as_deref(),
Some("\"etag-one\""),
"the next attempt must still be conditional"
);
}
const PULLS: &str = r#"[
{"number":10,"title":"a change","body":null,"state":"open","draft":false,
"user":{"login":"someone","avatar_url":null},"labels":[],
"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-05T00:00:00Z",
"closed_at":null,"merged_at":null,"html_url":"https://github.com/o/r/pull/10",
"head":{"ref":"topic","sha":"aaa"},"base":{"ref":"main","sha":"bbb"}},
{"number":11,"title":"merged already","body":null,"state":"closed","draft":false,
"user":{"login":"someone","avatar_url":null},"labels":[],
"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-06T00:00:00Z",
"closed_at":"2026-01-06T00:00:00Z","merged_at":"2026-01-06T00:00:00Z",
"html_url":"https://github.com/o/r/pull/11",
"head":{"ref":"done","sha":"ccc"},"base":{"ref":"main","sha":"bbb"}}
]"#;
const REPOSITORY: &str = r#"{
"full_name":"o/r","private":true,"archived":false,"fork":false,
"default_branch":"main","description":"a repository","html_url":"https://github.com/o/r",
"topics":["rust"],"stargazers_count":7,"watchers_count":7,"forks_count":1,
"open_issues_count":2,"pushed_at":"2026-01-06T00:00:00Z","updated_at":"2026-01-06T00:00:00Z"
}"#;
const CHECK_RUNS: &str = r#"{"check_runs":[
{"name":"build","status":"completed","conclusion":"success","html_url":"https://ci/1"},
{"name":"test","status":"completed","conclusion":"failure","html_url":"https://ci/2"}
]}"#;
const COMMIT_STATUS: &str = r#"{"statuses":[
{"context":"legacy/lint","state":"success","target_url":"https://ci/3"}
]}"#;
const VIEWS: &str = r#"{"count":40,"uniques":9,"views":[{"timestamp":"2026-01-05T00:00:00Z","count":40,"uniques":9}]}"#;
const CLONES: &str = r#"{"count":3,"uniques":2,"clones":[{"timestamp":"2026-01-05T00:00:00Z","count":3,"uniques":2}]}"#;
const PROJECTS: &str = r#"{"data":{"repositoryOwner":{"__typename":"Organization","projectsV2":{"nodes":[
{"id":"PVT_1","number":4,"title":"Roadmap","url":"https://github.com/orgs/o/projects/4",
"shortDescription":"what is next","closed":false,"updatedAt":"2026-01-06T00:00:00Z",
"fields":{"nodes":[
{"id":"F_STATUS","name":"Status","dataType":"SINGLE_SELECT",
"options":[{"id":"OPT_TODO","name":"Todo","color":"GRAY","description":null},
{"id":"OPT_DONE","name":"Done","color":"GREEN","description":"finished"}]},
{"id":"F_TEXT","name":"Notes","dataType":"TEXT"}
]}}
]}}}}"#;
const PROJECT_ITEMS: &str = r#"{"data":{"node":{"items":{
"pageInfo":{"hasNextPage":false,"endCursor":null},
"nodes":[
{"id":"ITEM_1","type":"ISSUE","updatedAt":"2026-01-06T00:00:00Z",
"fieldValues":{"nodes":[
{"name":"Todo","optionId":"OPT_TODO","field":{"id":"F_STATUS"}},
{"text":"a note","field":{"id":"F_TEXT"}}
]},
"content":{"title":"an issue","number":6,"url":"https://github.com/o/r/issues/6",
"state":"OPEN","repository":{"nameWithOwner":"o/r"}}},
{"id":"ITEM_2","type":"DRAFT_ISSUE","updatedAt":"2026-01-05T00:00:00Z",
"fieldValues":{"nodes":[]},
"content":{"title":"a draft"}}
]}}}}"#;
#[tokio::test]
async fn test10034_every_kind_parses_into_what_the_page_needs() {
let (address, _asked) = fake_github(
vec![
("/repos/o/r/pulls", PULLS),
("/repos/o/r/traffic/views", VIEWS),
("/repos/o/r/traffic/clones", CLONES),
("/repos/o/r/commits/HEAD/check-runs", CHECK_RUNS),
("/repos/o/r/commits/HEAD/status", COMMIT_STATUS),
("/repos/o/r", REPOSITORY),
],
"\"etag\"",
);
let client = GitHubClient::against(&format!("http://{address}"), "token");
let Fetched::Fresh { value: pulls, .. } = client
.list_pulls_changed("o", "r", &Conditions::default())
.await
.expect("pull requests")
else {
panic!("a first fetch returns data");
};
assert_eq!(pulls.len(), 2);
assert_eq!(pulls[0].head.branch, "topic");
assert_eq!(pulls[0].base.branch, "main");
assert!(pulls[1].merged_at.is_some(), "a merged pull request says so");
assert_eq!(
pulls[1].state, "closed",
"GitHub reports merged as closed, and the page reads merged_at"
);
let Fetched::Fresh { value: repo, .. } = client
.repository_changed("o", "r", &Conditions::default())
.await
.expect("the repository")
else {
panic!("a first fetch returns data");
};
assert!(repo.private, "a private repository must read as private");
assert_eq!(repo.stargazers_count, 7);
assert_eq!(repo.topics, vec!["rust".to_string()]);
let checks = client.checks_for("o", "r", "HEAD").await.expect("checks");
assert_eq!(checks.runs.len(), 3, "both mechanisms are reported");
assert!(checks.runs.iter().any(|run| run.name == "legacy/lint"));
assert_eq!(
checks.state, "failure",
"one failing run makes the commit failing, whatever else passed"
);
let traffic = client.traffic("o", "r").await.expect("traffic");
assert_eq!(traffic.views, 40);
assert_eq!(traffic.unique_views, 9);
assert_eq!(traffic.clones, 3);
assert_eq!(traffic.view_days.len(), 1);
}
#[tokio::test]
async fn test10035_a_project_board_reads_as_columns_and_cards() {
let (address, asked) = fake_github(vec![("/graphql", PROJECTS)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let projects = client.list_projects("o").await.expect("projects");
assert_eq!(projects.len(), 1);
let board = &projects[0];
assert_eq!(board.id, "PVT_1");
assert_eq!(board.title, "Roadmap");
assert_eq!(board.owner, "o");
let column_field = board.column_field().expect("a Status field");
assert_eq!(column_field.id, "F_STATUS");
assert_eq!(
column_field
.options
.iter()
.map(|option| option.name.as_str())
.collect::<Vec<_>>(),
vec!["Todo", "Done"]
);
assert_eq!(column_field.options[0].description, "");
assert_eq!(column_field.options[1].description, "finished");
let body = asked.bodies.lock().expect("the body log").clone();
assert_eq!(body.len(), 1, "one request, not one per owner kind");
assert!(body[0].contains("projectsV2"), "{}", body[0]);
let (address, asked) = fake_github(vec![("/graphql", PROJECT_ITEMS)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let items = client.project_items("PVT_1").await.expect("items");
assert_eq!(items.len(), 2);
let issue = &items[0];
assert_eq!(issue.kind, "ISSUE");
assert_eq!(issue.number, Some(6));
assert_eq!(issue.repository.as_deref(), Some("o/r"));
assert_eq!(
issue.column("F_STATUS"),
Some("OPT_TODO"),
"a card knows its column by OPTION id, which is what a move names"
);
assert!(
matches!(
issue.field_values.get("F_TEXT"),
Some(wsb_github::client::GitHubProjectValue::Text { text }) if text == "a note"
),
"every field kind survives, not just the columns"
);
let draft = &items[1];
assert_eq!(draft.kind, "DRAFT_ISSUE");
assert_eq!(draft.number, None);
assert_eq!(draft.repository, None);
assert_eq!(draft.column("F_STATUS"), None);
let body = asked.bodies.lock().expect("the body log").clone();
assert!(body[0].contains("hasNextPage"), "pagination is asked for");
}
#[tokio::test]
async fn test11064_a_graphql_error_is_raised_rather_than_read_as_empty() {
const REFUSAL: &str = r#"{"data":null,"errors":[
{"message":"Your token has not been granted the required scopes: read:project"}
]}"#;
let (address, _asked) = fake_github(vec![("/graphql", REFUSAL)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let error = client
.list_projects("o")
.await
.expect_err("a refusal must not read as an empty list");
assert!(
error.to_string().contains("read:project"),
"the refusal must say what is missing: {error}"
);
let error = client
.project_items("PVT_1")
.await
.expect_err("a refusal must not read as an empty board");
assert!(error.to_string().contains("read:project"), "{error}");
}
const CARD: &str = r#""node":{
"id":"ITEM_1","type":"ISSUE","updatedAt":"2026-08-19T10:00:00Z","isArchived":false,
"fieldValues":{"nodes":[
{"name":"Done","optionId":"OPT_DONE","field":{"id":"F_STATUS"}},
{"date":"2026-09-01","field":{"id":"F_DUE"}}
]},
"content":{
"id":"I_1","title":"the thing","body":"why","number":12,
"url":"https://github.com/o/r/issues/12","state":"OPEN","stateReason":null,
"createdAt":"2026-08-01T00:00:00Z","closedAt":null,
"repository":{"nameWithOwner":"o/r"},
"author":{"login":"bahram","avatarUrl":"https://avatars/x"},
"assignees":{"nodes":[{"login":"someone","avatarUrl":null}]},
"labels":{"nodes":[{"name":"bug","color":"d73a4a","description":"broken"}]},
"milestone":{"title":"v2"},
"comments":{"totalCount":3}
}
}"#;
fn sent(asked: &Arc<Asked>) -> Vec<serde_json::Value> {
asked
.bodies
.lock()
.expect("the body log")
.iter()
.map(|body| serde_json::from_str(body).expect("a JSON body"))
.collect()
}
#[tokio::test]
async fn test10037_a_field_write_names_one_card_and_reads_it_back() {
let ok: &'static str = Box::leak(
format!(
r#"{{"data":{{"updateProjectV2ItemFieldValue":{{"projectV2Item":{{"id":"ITEM_1"}}}},{CARD}}}}}"#
)
.into_boxed_str(),
);
let (address, asked) = fake_github(vec![("/graphql", ok)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let card = client
.set_project_field(
"PVT_1",
"ITEM_1",
"F_STATUS",
&ProjectFieldWrite::SingleSelect {
option_id: "OPT_DONE".to_string(),
},
)
.await
.expect("the write is accepted");
let bodies = sent(&asked);
assert_eq!(bodies.len(), 2, "the write, then the card read back");
assert_eq!(bodies[0]["variables"]["project"], "PVT_1");
assert_eq!(bodies[0]["variables"]["item"], "ITEM_1");
assert_eq!(bodies[0]["variables"]["field"], "F_STATUS");
assert_eq!(
bodies[0]["variables"]["value"]["singleSelectOptionId"], "OPT_DONE",
"a single-select is written as an option id, not as a name"
);
assert!(bodies[0]["query"]
.as_str()
.expect("a query")
.contains("updateProjectV2ItemFieldValue"));
assert_eq!(bodies[1]["variables"]["id"], "ITEM_1", "the card is re-read");
assert_eq!(card.title, "the thing");
assert_eq!(card.number, Some(12));
assert_eq!(card.repository.as_deref(), Some("o/r"));
assert_eq!(card.content_id.as_deref(), Some("I_1"));
assert_eq!(card.author.as_ref().expect("an author").login, "bahram");
assert_eq!(card.assignees.len(), 1);
assert_eq!(card.labels[0].name, "bug");
assert_eq!(card.milestone.as_deref(), Some("v2"));
assert_eq!(card.comment_count, 3);
assert!(!card.archived);
assert_eq!(card.column("F_STATUS"), Some("OPT_DONE"));
}
#[tokio::test]
async fn test10078_a_field_is_written_as_its_own_kind() {
let ok: &'static str = Box::leak(
format!(r#"{{"data":{{"updateProjectV2ItemFieldValue":{{}},{CARD}}}}}"#).into_boxed_str(),
);
let (address, asked) = fake_github(vec![("/graphql", ok)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
for (write, member, expected) in [
(
ProjectFieldWrite::Text {
text: "a note".to_string(),
},
"text",
serde_json::json!("a note"),
),
(
ProjectFieldWrite::Number { number: 3.0 },
"number",
serde_json::json!(3.0),
),
(
ProjectFieldWrite::Date {
date: "2026-09-01".to_string(),
},
"date",
serde_json::json!("2026-09-01"),
),
(
ProjectFieldWrite::Iteration {
iteration_id: "IT_1".to_string(),
},
"iterationId",
serde_json::json!("IT_1"),
),
] {
client
.set_project_field("PVT_1", "ITEM_1", "F", &write)
.await
.expect("the write is accepted");
let bodies = sent(&asked);
let written = &bodies[bodies.len() - 2]["variables"]["value"];
assert_eq!(written[member], expected, "{write:?} was written as {written}");
assert_eq!(
written.as_object().expect("an input object").len(),
1,
"a value says one thing: {written}"
);
}
}
#[tokio::test]
async fn test10079_clearing_a_field_is_its_own_mutation() {
let ok: &'static str = Box::leak(
format!(r#"{{"data":{{"clearProjectV2ItemFieldValue":{{}},{CARD}}}}}"#).into_boxed_str(),
);
let (address, asked) = fake_github(vec![("/graphql", ok)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
client
.clear_project_field("PVT_1", "ITEM_1", "F_DUE")
.await
.expect("the clear is accepted");
let bodies = sent(&asked);
let query = bodies[0]["query"].as_str().expect("a query");
assert!(query.contains("clearProjectV2ItemFieldValue"), "{query}");
assert!(
!query.contains("updateProjectV2ItemFieldValue"),
"clearing must not be a write of an empty value: {query}"
);
}
#[tokio::test]
async fn test10080_an_existing_issue_is_added_by_url() {
let ok: &'static str = Box::leak(
format!(
r#"{{"data":{{"resource":{{"__typename":"Issue","id":"I_1"}},"addProjectV2ItemById":{{"item":{{"id":"ITEM_1"}}}},{CARD}}}}}"#
)
.into_boxed_str(),
);
let (address, asked) = fake_github(vec![("/graphql", ok)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let card = client
.add_project_item_by_url("PVT_1", "https://github.com/o/r/issues/12")
.await
.expect("the issue is added");
assert_eq!(card.id, "ITEM_1");
let bodies = sent(&asked);
assert_eq!(bodies.len(), 3, "resolve the url, add it, read the card back");
assert_eq!(bodies[0]["variables"]["url"], "https://github.com/o/r/issues/12");
assert_eq!(
bodies[1]["variables"]["content"], "I_1",
"the content id from the lookup is what is added"
);
}
#[tokio::test]
async fn test10081_a_url_that_cannot_go_on_a_board_is_refused_by_name() {
const REPO: &str = r#"{"data":{"resource":{"__typename":"Repository"}}}"#;
let (address, _asked) = fake_github(vec![("/graphql", REPO)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let refusal = client
.add_project_item_by_url("PVT_1", "https://github.com/o/r")
.await
.expect_err("a repository cannot be a card");
let said = refusal.to_string();
assert!(said.contains("Repository"), "{said}");
assert!(said.contains("issue or a pull request"), "{said}");
}
#[tokio::test]
async fn test10082_a_draft_is_created_and_returned_as_a_card() {
let ok: &'static str = Box::leak(
format!(
r#"{{"data":{{"addProjectV2DraftIssue":{{"projectItem":{{"id":"ITEM_1"}}}},{CARD}}}}}"#
)
.into_boxed_str(),
);
let (address, asked) = fake_github(vec![("/graphql", ok)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let card = client
.add_project_draft("PVT_1", "think about it", "later")
.await
.expect("the draft is created");
assert_eq!(card.id, "ITEM_1");
let bodies = sent(&asked);
assert_eq!(bodies[0]["variables"]["title"], "think about it");
assert_eq!(bodies[0]["variables"]["body"], "later");
assert_eq!(bodies[1]["variables"]["id"], "ITEM_1", "the card is read back");
}
#[tokio::test]
async fn test10083_ordering_names_the_card_it_lands_behind() {
let ok: &'static str = Box::leak(
format!(r#"{{"data":{{"updateProjectV2ItemPosition":{{}},{CARD}}}}}"#).into_boxed_str(),
);
let (address, asked) = fake_github(vec![("/graphql", ok)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
client
.move_project_item_after("PVT_1", "ITEM_1", Some("ITEM_0"))
.await
.expect("the ordering is accepted");
client
.move_project_item_after("PVT_1", "ITEM_1", None)
.await
.expect("the ordering is accepted");
let bodies = sent(&asked);
assert_eq!(bodies[0]["variables"]["after"], "ITEM_0");
assert_eq!(
bodies[2]["variables"]["after"],
serde_json::Value::Null,
"first in the column is an absent card to follow, not a made-up one"
);
}
#[tokio::test]
async fn test10084_archiving_and_unarchiving_are_told_apart() {
let ok: &'static str = Box::leak(
format!(r#"{{"data":{{"archiveProjectV2Item":{{}},"unarchiveProjectV2Item":{{}},{CARD}}}}}"#)
.into_boxed_str(),
);
let (address, asked) = fake_github(vec![("/graphql", ok)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
client
.archive_project_item("PVT_1", "ITEM_1", true)
.await
.expect("archived");
client
.archive_project_item("PVT_1", "ITEM_1", false)
.await
.expect("unarchived");
let bodies = sent(&asked);
let archived = bodies[0]["query"].as_str().expect("a query");
let brought_back = bodies[2]["query"].as_str().expect("a query");
assert!(archived.contains("archiveProjectV2Item"), "{archived}");
assert!(!archived.contains("unarchiveProjectV2Item"), "{archived}");
assert!(brought_back.contains("unarchiveProjectV2Item"), "{brought_back}");
}
#[tokio::test]
async fn test10038_the_rate_limit_reads_as_numbers() {
const LIMIT: &str = r#"{"resources":{"core":{"limit":5000,"remaining":12,"reset":1787000000,"used":4988}}}"#;
let (address, _asked) = fake_github(vec![("/rate_limit", LIMIT)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let limit = client.rate_limit().await.expect("the rate limit");
assert_eq!(limit.limit, 5000);
assert_eq!(limit.remaining, 12);
assert_eq!(limit.used, 4988);
assert_eq!(limit.reset, 1787000000);
}
const ALONE: &str = r#"{"data":{"viewer":{"login":"o","organizations":{
"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[]}}}}"#;
const NO_BOARDS: &str = r#"{"data":{"repositoryOwner":{"__typename":"User","projectsV2":{
"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[]}}}}"#;
fn whole_github() -> (SocketAddr, Arc<Asked>) {
const LIMIT: &str = r#"{"resources":{"core":{"limit":5000,"remaining":4000,"reset":1787000000,"used":1000}}}"#;
fake_github(
vec![
("/graphql#viewer", ALONE),
("/graphql#repositoryOwner", NO_BOARDS),
("/repos/o/r/issues", ISSUES),
("/repos/o/r/pulls", PULLS),
("/repos/o/r/releases", "[]"),
("/repos/o/r/tags", "[]"),
("/repos/o/r/milestones", "[]"),
("/repos/o/r/labels", "[]"),
("/repos/o/r/traffic/views", VIEWS),
("/repos/o/r/traffic/clones", CLONES),
("/repos/o/r/commits/c1/check-runs", CHECK_RUNS),
("/repos/o/r/commits/c1/status", COMMIT_STATUS),
("/repos/o/r", REPOSITORY),
("/gists", "[]"),
("/notifications", "[]"),
("/rate_limit", LIMIT),
],
"\"etag-one\"",
)
}
fn one_repository() -> Targets {
let mut targets = Targets::default();
targets
.repositories
.entry("o/r".to_string())
.or_default()
.insert("c1".to_string());
targets
}
#[tokio::test]
async fn a_pass_over_an_unchanged_github_records_no_change() {
let (address, asked) = whole_github();
let client = GitHubClient::against(&format!("http://{address}"), "token");
let (_dir, mirror) = mirror();
let targets = one_repository();
let first = wsb_github::sync::sync(&mirror, Some(&client), &targets, false).await;
assert!(first.failed.is_empty(), "the first pass failed: {:?}", first.failed);
assert!(first.problems.is_empty(), "{:?}", first.problems);
assert!(first.changed.contains(&"issues:o/r".to_string()), "{first:?}");
assert!(first.changed.contains(&"checks:o/r@c1".to_string()), "CI for the commit a worktree is on: {first:?}");
let checks = mirror
.only(&Scope::Checks { repo: "o/r".to_string(), commit: "c1".to_string() })
.expect("read")
.expect("held");
assert_eq!(checks["state"], "failure");
let after_first = mirror.latest_change().expect("read");
let served_first = asked.served.load(Ordering::SeqCst);
let idle = wsb_github::sync::sync(&mirror, Some(&client), &targets, false).await;
assert!(idle.changed.is_empty() && idle.unchanged.is_empty() && idle.failed.is_empty(), "{idle:?}");
assert_eq!(asked.served.load(Ordering::SeqCst), served_first, "a pass asked about scopes that were not due");
let second = wsb_github::sync::sync(&mirror, Some(&client), &targets, true).await;
assert!(second.failed.is_empty(), "{:?}", second.failed);
assert!(second.changed.is_empty(), "nothing moved, and the pass said otherwise: {:?}", second.changed);
assert!(second.unchanged.contains(&"issues:o/r".to_string()));
assert_eq!(
mirror.latest_change().expect("read"),
after_first,
"an unchanged pass recorded a change"
);
assert_eq!(mirror.documents(&Scope::Issues("o/r".to_string())).expect("read").len(), 2);
}
#[tokio::test]
async fn a_gist_opened_once_is_served_from_the_mirror_after() {
const GIST: &str = r##"{"id":"abc","description":"notes","public":false,
"html_url":"https://gist.github.com/abc","created_at":"2026-01-01T00:00:00Z",
"updated_at":"2026-01-02T00:00:00Z","owner":null,
"files":{"note.md":{"filename":"note.md","type":"text/markdown","language":"Markdown",
"raw_url":null,"size":7,"truncated":false,"content":"# notes"}}}"##;
let (address, asked) = fake_github(vec![("/gists/abc", GIST)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let (_dir, mirror) = mirror();
let service = Service::new(mirror);
let first = service.gist(Some(&client), "abc").await.expect("fetched");
assert!(!first.from_mirror);
assert_eq!(first.gist["files"]["note.md"]["content"], "# notes");
let second = service.gist(None, "abc").await.expect("served");
assert!(second.from_mirror, "the second open went to GitHub");
assert_eq!(second.gist["files"]["note.md"]["content"], "# notes");
assert_eq!(asked.served.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn card_writes_are_mirrored_from_githubs_answer() {
let ok: &'static str = Box::leak(
format!(
r#"{{"data":{{"updateProjectV2ItemFieldValue":{{"projectV2Item":{{"id":"ITEM_1"}}}},"deleteProjectV2Item":{{"deletedItemId":"ITEM_1"}},{CARD}}}}}"#
)
.into_boxed_str(),
);
let (address, _asked) = fake_github(vec![("/graphql", ok)], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let (_dir, mirror) = mirror();
let service = Service::new(mirror.clone());
let cards = Scope::ProjectItems("PVT_1".to_string());
mirror
.put(&cards, &[Document::of("ITEM_1", serde_json::json!({ "id": "ITEM_1", "title": "old", "kind": "ISSUE" }))])
.expect("stored");
let before = mirror.latest_change().expect("read");
let card = service
.set_field(
&client,
"PVT_1",
"ITEM_1",
"F_STATUS",
Some(&ProjectFieldWrite::SingleSelect { option_id: "OPT_DONE".to_string() }),
)
.await
.expect("written");
assert_eq!(card.title, "the thing");
let held = mirror.document(&cards, "ITEM_1").expect("read").expect("held");
assert_eq!(held.payload["title"], "the thing", "the mirror kept what this process imagined");
assert!(mirror.latest_change().expect("read") > before, "the write was not recorded as a change");
service.remove_card(&client, "PVT_1", "ITEM_1").await.expect("removed");
assert!(mirror.document(&cards, "ITEM_1").expect("read").is_none(), "a removed card stayed");
}
#[tokio::test]
async fn an_untitled_draft_is_refused_before_github_is_asked() {
let (address, asked) = fake_github(vec![("/graphql", "{}")], "\"etag\"");
let client = GitHubClient::against(&format!("http://{address}"), "token");
let (_dir, mirror) = mirror();
let service = Service::new(mirror);
let refusal = service.add_draft(&client, "PVT_1", " ", "body").await.expect_err("an untitled draft");
assert!(matches!(refusal, wsb_github::Failure::Invalid(_)), "{refusal}");
assert!(asked.paths.lock().expect("the log").is_empty(), "GitHub was asked anyway");
}
fn board_node(id: &str, owner: &str, number: i64) -> String {
format!(
r#"{{"id":"{id}","number":{number},"title":"{owner} {number}",
"url":"https://github.com/orgs/{owner}/projects/{number}","shortDescription":null,
"closed":false,"updatedAt":"2026-0{number}-01T00:00:00Z","fields":{{"nodes":[]}}}}"#
)
}
fn boards_page(nodes: &[String], next: Option<&str>) -> &'static str {
let page_info = match next {
Some(cursor) => format!(r#"{{"hasNextPage":true,"endCursor":"{cursor}"}}"#),
None => r#"{"hasNextPage":false,"endCursor":null}"#.to_string(),
};
Box::leak(
format!(
r#"{{"data":{{"repositoryOwner":{{"__typename":"Organization","projectsV2":{{
"pageInfo":{page_info},"nodes":[{}]}}}}}}}}"#,
nodes.join(",")
)
.into_boxed_str(),
)
}
#[tokio::test]
async fn test20007_a_pass_mirrors_every_board_of_the_account_and_its_organisations() {
const ORGS_PAGE_1: &str = r#"{"data":{"viewer":{"login":"me","organizations":{
"pageInfo":{"hasNextPage":true,"endCursor":"ORGS2"},"nodes":[{"login":"late"}]}}}}"#;
const ORGS_PAGE_2: &str = r#"{"data":{"viewer":{"login":"me","organizations":{
"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[{"login":"early"}]}}}}"#;
let early_1 = boards_page(&[board_node("PVT_E1", "early", 1)], Some("BOARDS2"));
let early_2 = boards_page(&[board_node("PVT_E2", "early", 2)], None);
let late = boards_page(&[board_node("PVT_L3", "late", 3)], None);
let (address, asked) = fake_github(
vec![
("/graphql#viewer", ORGS_PAGE_1),
("/graphql#\"after\":\"ORGS2\"", ORGS_PAGE_2),
("/graphql#\"login\":\"me\"", NO_BOARDS),
("/graphql#\"login\":\"early\"", early_1),
("/graphql#\"after\":\"BOARDS2\"", early_2),
("/graphql#\"login\":\"late\"", late),
("/gists", "[]"),
("/notifications", "[]"),
("/rate_limit", r#"{"resources":{"core":{"limit":5000,"remaining":4000,"reset":1787000000,"used":1000}}}"#),
],
"\"etag\"",
);
let client = GitHubClient::against(&format!("http://{address}"), "token");
let (_dir, mirror) = mirror();
let report = wsb_github::sync::sync(&mirror, Some(&client), &Targets::default(), false).await;
assert!(report.failed.is_empty(), "{:?}", report.failed);
assert!(report.problems.is_empty(), "{:?}", report.problems);
let early = mirror.documents(&Scope::Projects("early".to_string())).expect("read");
assert_eq!(early.len(), 2, "the second page of early's boards was not read");
assert_eq!(mirror.documents(&Scope::Projects("late".to_string())).expect("read").len(), 1);
let all = Service::new(mirror).all_boards(&[]).expect("read");
let owners: Vec<&str> = all.owners.iter().map(|owner| owner.login.as_str()).collect();
assert_eq!(owners, vec!["me", "early", "late"], "the second page of organisations was not read");
let mut ids: Vec<&str> = all.boards.iter().map(|board| board.id.as_str()).collect();
ids.sort();
assert_eq!(ids, vec!["PVT_E1", "PVT_E2", "PVT_L3"]);
assert!(all.problems.is_empty(), "{:?}", all.problems);
let bodies = asked.bodies.lock().expect("the body log").clone();
assert!(bodies.iter().any(|body| body.contains("\"after\":\"ORGS2\"")), "{bodies:?}");
assert!(bodies.iter().any(|body| body.contains("\"after\":\"BOARDS2\"")), "{bodies:?}");
}
#[tokio::test]
async fn test20012_refreshing_the_boards_asks_only_about_boards() {
const VIEWER: &str = r#"{"data":{"viewer":{"login":"me","organizations":{
"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[{"login":"org"}]}}}}"#;
let org = boards_page(&[board_node("PVT_O1", "org", 1)], None);
let (address, asked) = fake_github(
vec![
("/graphql#viewer", VIEWER),
("/graphql#\"login\":\"me\"", NO_BOARDS),
("/graphql#\"login\":\"org\"", org),
],
"\"etag\"",
);
let client = GitHubClient::against(&format!("http://{address}"), "token");
let (_dir, mirror) = mirror();
let service = Service::new(mirror);
let report = service.refresh_boards(&client, &[]).await;
assert!(report.failed.is_empty(), "{:?}", report.failed);
assert!(report.changed.contains(&"projects:org".to_string()), "{report:?}");
let paths = asked.paths.lock().expect("the path log").clone();
assert!(
paths.iter().all(|path| path == "/graphql"),
"the boards refresh asked about something other than boards: {paths:?}"
);
assert_eq!(service.all_boards(&[]).expect("read").boards.len(), 1);
}
#[tokio::test]
async fn test12910_a_silent_github_is_given_up_on_and_the_sweep_goes_on() {
let silent = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
let silent_address = silent.local_addr().expect("an address");
let (heard, heard_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut held = Vec::new();
for stream in silent.incoming() {
let Ok(mut stream) = stream else { continue };
if read_request(&mut stream).is_some() {
let _ = heard.send(());
}
held.push(stream);
}
});
let (answering, _asked) = fake_github(vec![("/repos/o/r/issues", ISSUES)], "\"etag-one\"");
let bound = std::time::Duration::from_millis(500);
let client = GitHubClient::against_within(&format!("http://{silent_address}"), "token", bound);
let started = std::time::Instant::now();
let unanswered = tokio::time::timeout(
std::time::Duration::from_secs(20),
client.list_issues_changed("o", "r", &Conditions::default()),
)
.await
.expect("a request to a GitHub that never answers waited for ever");
unanswered.expect_err("a request nobody answered cannot have succeeded");
assert!(
heard_rx.try_recv().is_ok(),
"the request must have reached the silent server, or this proved nothing about waiting for an answer"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"the request was given up on only after {:?}, far past its bound of {bound:?}",
started.elapsed()
);
let client = GitHubClient::against_within(&format!("http://{answering}"), "token", bound);
let Fetched::Fresh { value, .. } = client
.list_issues_changed("o", "r", &Conditions::default())
.await
.expect("a GitHub that answers is read after one that did not")
else {
panic!("the first fetch from an answering GitHub must return data");
};
assert!(!value.is_empty(), "the answering GitHub serves issues");
}