wsb-github 0.253.6349

GitHub issues, pull requests, gists and Projects boards, mirrored once per machine for every project under $PRJ
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
//! The mirror against a GitHub that can actually answer.
//!
//! The claim these tests exist for is an economic one: mirroring forty
//! repositories every two minutes is only affordable because an unchanged
//! repository costs a conditional request and nothing else. That cannot be
//! proved by asserting a header is set — it needs a server that answers 304
//! and a client that does the right thing with it. So these run a real HTTP
//! server on a real port and point the client at it.

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;

/// What the fake GitHub was asked, so a test can assert on the ASKING as well
/// as on the answer.
#[derive(Default)]
struct Asked {
    /// Every request path, in order.
    paths: Mutex<Vec<String>>,
    /// Every `If-None-Match` presented, in order.
    conditions: Mutex<Vec<Option<String>>>,
    /// How many responses were answered from the body rather than as 304.
    served: AtomicUsize,
    /// Every request body, for the mutations.
    bodies: Mutex<Vec<String>>,
}

/// A GitHub that serves what a test tells it to, and honours `If-None-Match`.
///
/// Hand-written HTTP rather than a framework: the point of this fixture is to
/// be obviously correct about the behaviours under test — a matching ETag
/// produces a 304 with no body, an unknown path produces a 404, and a POST
/// body reaches the handler — and a framework would hide all three.
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());
                }
            }

            // The longest matching route, so `/repos/o/r/issues` and
            // `/repos/o/r/issues/1/comments` can both be served. A route may
            // also name text the body must contain — `/graphql#viewer` — since
            // every GraphQL request shares one path and differs only in what
            // it asks.
            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) => {
                    // What GitHub does when nothing has changed, and what
                    // makes the whole scheme affordable: no body, no limit.
                    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)
}

/// Read one whole HTTP request: its head, and the body its `Content-Length`
/// declares.
///
/// The body matters — the GraphQL requests ARE bodies, and a fixture that
/// stopped at the head would let a test assert about a mutation it never saw.
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"}
]"#;

/// TEST10021: a second sync of an unchanged repository transfers nothing.
///
/// The whole economics in one test. The first fetch stores an ETag; the second
/// presents it; GitHub answers 304; the mirror keeps what it has and moves its
/// timestamps. Without this, mirroring forty repositories every two minutes
/// would exhaust a rate limit before lunch — which is why the behaviour it
/// replaces fetched only when somebody opened a page, and why that page was
/// empty whenever the fetch failed.
#[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());

    // First pass: nothing is known, so nothing is presented.
    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");
    };
    // A pull request is not an issue to anybody reading this list.
    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");

    // Second pass: the stored ETag is presented, and the answer is a 304.
    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"
    );

    // The cursor keeps the next fetch small: only what moved after the newest
    // thing already held.
    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]
    );

    // And a 304 leaves the data alone while moving the timestamps.
    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);
}

/// TEST10022: a GitHub that refuses leaves the mirror exactly as it was.
///
/// The failure that produced "you have no gists": the request fails, and the
/// list is replaced with an empty one. Here the data must survive, the error
/// must be recorded beside it, and the next attempt must still be conditional.
#[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");

    // A repository the fake server does not serve: a real refusal, from a real
    // request, rather than a constructed error.
    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"}}
  ]}}}}"#;

/// TEST10034: every mirrored kind comes back as the kind it is.
///
/// One test rather than eleven because the failure they share is the one worth
/// catching: a kind whose payload does not parse is a kind the mirror stores
/// nothing for, and the page shows an empty section with no error — which is
/// the exact shape of the bug this whole feature replaced.
#[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");

    // Pull requests carry what makes them pull requests: the branch, the
    // draft flag and the merge — none of which the issues endpoint returns.
    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"
    );

    // The repository, including the one fact the explorer draws.
    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()]);

    // CI: check runs AND commit statuses, because a repository can use either
    // and a workbench that read one would report "no checks" for half a fleet.
    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"
    );

    // Traffic, folded from GitHub's two series into one answer.
    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);
}

/// TEST10035: a board reads as columns and cards, and a card knows its column.
///
/// The board is the one thing here that is GraphQL, so it is the one thing
/// whose parsing is bespoke: a field value carries its FIELD id, and a
/// single-select carries the option id a mutation needs as well as the name a
/// person reads. Getting that wrong means a board that renders and cannot be
/// dragged.
#[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");

    // The columns are the options of the single-select field the board is
    // grouped by. A board with no such field has no columns, and inventing one
    // would put every card in a made-up bucket.
    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"]
    );
    // A column with no description is the ordinary case: GitHub answers
    // `"description": null`, and reading that as a missing field made every
    // board anybody makes without typing one unreadable. The one that HAS a
    // description still carries it, which is what says this is read rather
    // than discarded.
    assert_eq!(column_field.options[0].description, "");
    assert_eq!(column_field.options[1].description, "finished");

    // The query asked for what it needs, in one request.
    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]);

    // And the cards.
    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"
    );

    // A draft card has no repository and no number — there is nothing to open
    // — and a page that assumed otherwise would render a broken link.
    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");
}

/// TEST11064: a GraphQL error is an error, not an empty board.
///
/// GraphQL answers 200 with an `errors` array. A client that only checked the
/// status would read "your token may not see projects" as "you have no
/// projects" — the same class of lie as an empty gist list, and the reason
/// this mirror exists.
#[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}");
}

/// One card, as GitHub answers with it — enough of a real answer that the
/// parsing is exercised rather than mocked away.
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}
  }
}"#;

/// The bodies the fake was sent, parsed.
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()
}

/// TEST10037: a field write names the board, the item, the field and the value
/// — and then reads the card back rather than imagining it.
///
/// A mutation that named fewer of them would be ambiguous: two boards can hold
/// the same issue, and two options can share a name. And what the board shows
/// afterwards is what GitHub returned, because the alternative — patching the
/// held payload by hand — invents the parts of the answer it cannot know: the
/// new timestamp, a field somebody else changed a second ago, an item GitHub
/// closed as a side effect.
#[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");

    // And the card that comes back is the whole card, not just the field that
    // was written: the page renders from this.
    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"));
}

/// TEST10078: each field kind is written as the kind it is.
///
/// GitHub takes one `ProjectV2FieldValue` input with a member per kind, and
/// puts what it is given wherever it is told: a date written into `text` is
/// accepted and then shows nowhere, because the field it landed on is not a
/// text field. So the kind is carried from the caller rather than guessed from
/// the shape of the value.
#[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}"
        );
    }
}

/// TEST10079: clearing a field is not writing an empty one.
///
/// An empty text value IS a value. A board that could not tell "no due date"
/// from "a due date of nothing" would show a column of blanks where it means
/// to show none — and the two are different mutations, so this is about
/// calling the right one.
#[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}"
    );
}

/// TEST10080: an existing issue is added by the URL a person has in hand.
///
/// The URL is what is in the clipboard after reading an issue, and what
/// GitHub's own "add item" takes. It is resolved to a node id here — asking
/// anybody to find a `PVTI_` id by hand is asking them to go to the website,
/// which is the thing this page exists to avoid.
#[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"
    );
}

/// TEST10081: a URL that is not an issue or a pull request is refused, by name.
///
/// A board takes those two and drafts. A repository URL pasted into the box is
/// an ordinary mistake, and "could not resolve to a node" — which is what the
/// add mutation would answer — tells nobody what to do about it.
#[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}");
}

/// TEST10082: a draft card is created with what it says, and comes back as a
/// card.
#[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");
}

/// TEST10083: ordering a card says which card it lands behind, and nothing
/// means first.
///
/// A drag within a column is an ordering, and a board whose order was whatever
/// GitHub last decided is one where a drag does nothing visible.
#[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"
    );
}

/// TEST10084: archiving and bringing a card back are two different mutations.
///
/// Told rather than toggled: a toggle acts on what the caller last saw, and
/// two clicks from two pages would leave the card in whichever state arrived
/// second.
#[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}");
}

/// TEST10038: a rate limit is read even when the budget is gone.
///
/// GitHub exempts this endpoint from the limit it reports, which is the only
/// reason it can be asked at the moment somebody needs it — "why is nothing
/// refreshing" has an answer, and this is it.
#[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);
}

// ---------------------------------------------------------------------------
// A whole pass, and the service's writes, against a GitHub that answers
// ---------------------------------------------------------------------------

/// The account `o`, a member of no organisation.
const ALONE: &str = r#"{"data":{"viewer":{"login":"o","organizations":{
  "pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[]}}}}"#;

/// An owner with no boards.
const NO_BOARDS: &str = r#"{"data":{"repositoryOwner":{"__typename":"User","projectsV2":{
  "pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[]}}}}"#;

/// Everything one repository and one person have, served by the fake.
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
}

/// A pass over a GitHub where nothing moved changes nothing, and records no
/// change — so a sweep tells no reader to re-read.
///
/// The first pass fills the mirror. The second is forced, so every scope is
/// asked again: the conditional ones answer 304, and the unconditional ones —
/// CI, traffic, the rate limit — answer 200 with the same content, which the
/// mirror must recognise as the same.
#[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);

    // Not due yet: an unforced pass right away asks nothing at all.
    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);
}

/// A gist opened once is fetched once; after that the mirror serves it, with
/// no token at all.
#[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);
}

/// A card write is mirrored from what GitHub returned, and a removal takes the
/// card out of the mirror at once.
#[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());
    // What the mirror held before: an older title GitHub no longer has.
    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");
}

/// A draft with no title is refused before GitHub is asked anything.
#[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");
}

/// A board as the owner query returns it.
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":[]}}}}"#
    )
}

/// One page of an owner's boards, continuing at `next` when there is one.
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(),
    )
}

/// TEST20007: one pass mirrors every board of the token's account and of every
/// organisation it belongs to — every page of each — and the service lists
/// them without any project naming one.
///
/// The person asked for "every project of my account under all of my
/// organisations". That is the viewer, its organisations (more than one page
/// of them for anybody in many), and each owner's boards (more than one page
/// for an organisation with many) — all of which this pass has to walk.
#[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);

    // Each page was asked for by its cursor, not re-read from the start.
    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:?}");
}

/// TEST20012: refreshing the boards asks about boards and nothing else.
///
/// The boards page's refresh forced the whole mirror — every repository of
/// every project under `$PRJ` — which was minutes of requests behind a button
/// somebody pressed to see one board move. This pass reads who the token is,
/// each owner's boards and the cards being kept, and asks no repository
/// anything.
#[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);
}

/// TEST12910: a GitHub that never answers is given up on, and the next request
/// is served.
///
/// The connection a request went out on can die without a word — the Mac moves
/// to another network and the pooled connection is bound to an address it no
/// longer has. Unbounded, the call waited for ever, and the sweep that reads
/// every scope in turn never reached the boards again (19 Oct 2026). Here the
/// server takes the request and says nothing: the call must fail within its
/// bound, and the same bound must still let a server that does answer be read.
#[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 open and never written to: a connection that is there and
            // will never answer, not one that was refused or closed.
            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");
}